diff --git a/src/renderer/src/components/agent-pane/SecretRequestCard.fixture.tsx b/src/renderer/src/components/agent-pane/SecretRequestCard.fixture.tsx
index 35921fa5d0c..4b188d7cc56 100644
--- a/src/renderer/src/components/agent-pane/SecretRequestCard.fixture.tsx
+++ b/src/renderer/src/components/agent-pane/SecretRequestCard.fixture.tsx
@@ -14,7 +14,9 @@ const ACTIONS: SecretRequestCardActions = {
onNotNow: noop,
onRetry: noop,
onRestart: noop,
- onDismiss: noop
+ onDismiss: noop,
+ onToggleCollapsed: noop,
+ onDetails: noop
}
const BASE: SecretRequestCardModel = {
@@ -28,7 +30,8 @@ const BASE: SecretRequestCardModel = {
phase: 'open',
fieldError: null,
more: 0,
- canRestart: true
+ canRestart: true,
+ collapsed: false
}
/** A terminal pane behind the card, as it sits over a Claude session. */
@@ -69,3 +72,5 @@ export const saved = frame({ phase: 'saved' })
export const savedRepo = frame({ phase: 'saved', scope: 'repo' })
export const savedAfterExpiry = frame({ phase: 'savedAfterExpiry' })
export const expired = frame({ phase: 'expired' })
+export const collapsed = frame({ collapsed: true, more: 1 })
+export const notOnAskingPane = frame({ phase: 'saved', canRestart: false })
diff --git a/src/renderer/src/components/agent-pane/SecretRequestCard.tsx b/src/renderer/src/components/agent-pane/SecretRequestCard.tsx
index 625fc1bc97f..c796da92242 100644
--- a/src/renderer/src/components/agent-pane/SecretRequestCard.tsx
+++ b/src/renderer/src/components/agent-pane/SecretRequestCard.tsx
@@ -1,20 +1,20 @@
import React from 'react'
-import { CircleCheck, KeyRound, Loader2, RotateCw } from 'lucide-react'
-import { useTranslate, type Translate } from '@/i18n/use-translate'
+import { ChevronDown, ChevronUp, KeyRound, Loader2 } from 'lucide-react'
+import { useTranslate } from '@/i18n/use-translate'
import { Button } from '@/components/ui/button'
import { InlineError } from '@/components/ui/inline-error'
import { Label } from '@/components/ui/label'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue
-} from '@/components/ui/select'
import { SecretValueInput } from '@/components/settings/cloud-secrets/SecretValueInput'
-import { cloudSecretScopeLabel } from '@/components/settings/cloud-secrets/cloud-secrets-copy'
import { cn } from '@/lib/utils'
import type { SecretRequestScope } from '../../../../shared/secret-request-contract'
+import {
+ SecretRequestDone,
+ SecretRequestScopeControl,
+ SecretRequestTitle,
+ UntrustedReason
+} from './SecretRequestCardParts'
+
+export { secretRequestTitle } from './SecretRequestCardParts'
export type SecretRequestCardPhase =
| 'open'
@@ -22,6 +22,7 @@ export type SecretRequestCardPhase =
/** The save didn't reach Orca Cloud; nothing changed. */
| 'failed'
| 'saved'
+ /** Saved, but the agent wasn't told: the request expired first, or orcad didn't answer (D3). */
| 'savedAfterExpiry'
| 'expired'
@@ -42,8 +43,10 @@ export type SecretRequestCardModel = {
fieldError: string | null
/** Other requests waiting on this pane. */
more: number
- /** Restart is offered where Orca can restart that agent in the pane. */
+ /** Restart is offered only on the asking agent's own pane. */
canRestart: boolean
+ /** Folded to one line so the output under it can be read, without declining. */
+ collapsed: boolean
}
export type SecretRequestCardActions = {
@@ -54,85 +57,27 @@ export type SecretRequestCardActions = {
onRetry: () => void
onRestart: () => void
onDismiss: () => void
+ onToggleCollapsed: () => void
+ /** Opens the Agent accounts & secrets panel on this name; absent until the panel exists. */
+ onDetails?: () => void
/** Focus entered or left the card (the desktop keeps the request alive while it has focus). */
onFocusChange?: (focused: boolean) => void
}
-const NAME_SLOT = '\u0000'
-
-/** The translated title with the variable name set in monospace. */
-function titleFor(agent: string | null, name: string, t: Translate): React.ReactNode {
- const text = agent
- ? t('secretRequest.title', '{{agent}} asked for {{name}}', { agent, name: NAME_SLOT })
- : t('secretRequest.titleUnknown', 'An agent asked for {{name}}', { name: NAME_SLOT })
- return text.split(NAME_SLOT).map((part, index) => (
-
- {index > 0 ? {name} : null}
- {part}
-
- ))
-}
-
-/** Title text for aria-label and announcements. */
-export function secretRequestTitle(agent: string | null, name: string, t: Translate): string {
- return agent
- ? t('secretRequest.title', '{{agent}} asked for {{name}}', { agent, name })
- : t('secretRequest.titleUnknown', 'An agent asked for {{name}}', { name })
-}
-
-/** Plain text clamped to two lines, with Show more when it overflows. Never linkified. */
-function UntrustedReason({ reason, from }: { reason: string; from: string }): React.JSX.Element {
- const t = useTranslate()
- const ref = React.useRef
(null)
- const [expanded, setExpanded] = React.useState(false)
- const [overflows, setOverflows] = React.useState(false)
- React.useLayoutEffect(() => {
- const element = ref.current
- if (element && !expanded) {
- setOverflows(element.scrollHeight > element.clientHeight + 1)
- }
- }, [reason, expanded])
- return (
-
-
-
- {`“${reason}”`}
-
-
-
- {from}
- {overflows || expanded ? (
- setExpanded(!expanded)}
- >
- {expanded
- ? t('secretRequest.showLess', 'Show less')
- : t('secretRequest.showMore', 'Show more')}
-
- ) : null}
-
-
- )
-}
-
/**
* S4-b: an agent in an Orca Cloud workspace asked for a secret. The value goes from this card to
* Orca Cloud only; the agent is told it's ready and never gets it from Orca. Non-modal: it never takes
- * focus from the terminal.
+ * focus from the terminal (F6 moves focus in and back out).
*/
export function SecretRequestCard({
model,
actions,
- inputRef,
+ rootRef,
className
}: {
model: SecretRequestCardModel
actions: SecretRequestCardActions
- /** Lets F6 move focus into the card. */
- inputRef?: React.Ref
+ rootRef?: React.Ref
className?: string
}): React.JSX.Element {
const t = useTranslate()
@@ -144,107 +89,125 @@ export function SecretRequestCard({
const done = model.phase === 'saved' || model.phase === 'savedAfterExpiry'
const saving = model.phase === 'saving'
const canSave = model.value.length > 0 && !saving
+ const ownRef = React.useRef(null)
+ const focusedWithin = React.useRef(false)
- const onKeyDown = (event: React.KeyboardEvent): void => {
- if (event.key === 'Escape' && !done && model.phase !== 'expired') {
- event.stopPropagation()
- actions.onNotNow()
+ // When the form gives way to the result, focus that was in it lands on the result's first action
+ // instead of falling to the page.
+ React.useLayoutEffect(() => {
+ const root = ownRef.current
+ if (done && focusedWithin.current && root && !root.contains(document.activeElement)) {
+ root.querySelector('button')?.focus()
+ }
+ }, [done])
+
+ const setRefs = (node: HTMLDivElement | null): void => {
+ ownRef.current = node
+ if (typeof rootRef === 'function') {
+ rootRef(node)
+ } else if (rootRef) {
+ rootRef.current = node
}
}
+ const header = (
+
+
+
+
+
+
+ {model.more > 0 ? (
+
+ {t('secretRequest.more', '{{count}} more requests', {
+ count: model.more,
+ defaultValue_one: '{{count}} more request'
+ })}
+
+ ) : null}
+
+
+ {model.collapsed ? : }
+
+
+ )
+
return (
actions.onFocusChange?.(true)}
- onBlur={(event) => {
- if (!event.currentTarget.contains(event.relatedTarget)) {
- actions.onFocusChange?.(false)
+ onKeyDown={(event) => {
+ if (event.key === 'Escape' && !done && !saving && model.phase !== 'expired') {
+ event.stopPropagation()
+ actions.onNotNow()
}
}}
+ onFocus={() => {
+ focusedWithin.current = true
+ actions.onFocusChange?.(true)
+ }}
+ onBlur={(event) => {
+ const root = event.currentTarget
+ if (root.contains(event.relatedTarget)) {
+ return
+ }
+ // A control that unmounts (Add → result) blurs with nowhere to go; that isn't leaving.
+ window.setTimeout(() => {
+ if (!root.isConnected || root.contains(document.activeElement)) {
+ return
+ }
+ if (document.activeElement === document.body) {
+ return
+ }
+ focusedWithin.current = false
+ actions.onFocusChange?.(false)
+ }, 0)
+ }}
className={cn(
- 'flex w-full flex-col gap-3 rounded-lg border border-border bg-popover p-4 font-sans text-sm text-popover-foreground shadow-lg motion-safe:animate-in motion-safe:fade-in-0',
+ 'flex w-full flex-col gap-3 rounded-lg border border-border bg-popover font-sans text-sm text-popover-foreground shadow-lg motion-safe:animate-in motion-safe:fade-in-0',
+ model.collapsed ? 'px-3 py-2' : 'p-4',
className
)}
>
{done ? (
-
-
-
-
- {model.phase === 'savedAfterExpiry'
- ? t(
- 'secretRequest.savedAfterExpiry',
- 'Saved. {{agent}} stopped waiting, so tell it the secret is ready.',
- { agent: model.agent ?? t('secretRequest.theAgent', 'The agent') }
- )
- : model.scope === 'repo'
- ? t('secretRequest.doneRepo', '{{name}} added to this repository’s secrets.', {
- name: model.name
- })
- : t('secretRequest.done', '{{name}} added to your personal secrets.', {
- name: model.name
- })}
-
-
- {model.phase === 'saved'
- ? t(
- 'secretRequest.doneDetail',
- 'New shells get it now. {{agent}} was told it’s ready, and it never gets the value from Orca.',
- { agent: model.agent ?? t('secretRequest.theAgent', 'The agent') }
- )
- : t('secretRequest.doneDetailExpired', 'New shells get it now.')}{' '}
- {model.canRestart
- ? t(
- 'secretRequest.restartDetail',
- 'Restart {{agent}} to give its own shell the new value.',
- { agent: agentName }
- )
- : null}
-
-
-
- {model.canRestart ? (
-
-
- {t('secretRequest.restart', 'Restart {{agent}}', { agent: agentName })}
-
- ) : null}
-
- {t('secretRequest.dismiss', 'Dismiss')}
-
-
-
+
+ ) : model.collapsed ? (
+ header
) : (
<>
-
-
-
-
-
- {titleFor(model.agent, model.name, t)}
-
- {model.more > 0 ? (
-
- {t('secretRequest.more', '{{count}} more requests', {
- count: model.more,
- defaultValue_one: '{{count}} more request'
- })}
-
- ) : null}
-
- {model.reason ? (
-
- ) : null}
+ {header}
+ {model.reason ? (
+
+
-
-
+ ) : null}
{model.phase === 'expired' ? (
) : (
<>
-
+
{t('secretRequest.valueLabel', 'Value for {{name}}', { name: model.name })}
@@ -284,36 +247,13 @@ export function SecretRequestCard({
describedBy={model.fieldError ? `${errorId} ${helpId}` : helpId}
/>
- {model.scopes.length > 1 ? (
-
- actions.onScopeChange(next === 'repo' ? 'repo' : 'personal')
- }
- disabled={saving}
- >
-
-
-
-
- {model.scopes.map((scope) => (
-
- {cloudSecretScopeLabel(scope, t)}
-
- ))}
-
-
- ) : (
-
- {cloudSecretScopeLabel('personal', t)}
-
- )}
+
{model.fieldError ? (
@@ -344,8 +284,20 @@ export function SecretRequestCard({
density="compact"
/>
) : null}
-
-
+
+ {actions.onDetails ? (
+
+ {t('agentHostPanel.details', 'Details')}
+
+ ) : null}
+
{t('secretRequest.notNow', 'Not now')}
+ {text.split(NAME_SLOT).map((part, index) => (
+
+ {index > 0 ? {name} : null}
+ {part}
+
+ ))}
+ >
+ )
+}
+
+/** Title text for aria-label and announcements. */
+export function secretRequestTitle(agent: string | null, name: string, t: Translate): string {
+ return agent
+ ? t('secretRequest.title', '{{agent}} asked for {{name}}', { agent, name })
+ : t('secretRequest.titleUnknown', 'An agent asked for {{name}}', { name })
+}
+
+/** Plain text clamped to two lines, with Show more when it overflows. Never linkified. */
+export function UntrustedReason({
+ reason,
+ from
+}: {
+ reason: string
+ from: string
+}): React.JSX.Element {
+ const t = useTranslate()
+ const ref = React.useRef(null)
+ const [expanded, setExpanded] = React.useState(false)
+ const [overflows, setOverflows] = React.useState(false)
+ React.useLayoutEffect(() => {
+ const element = ref.current
+ if (element && !expanded) {
+ setOverflows(element.scrollHeight > element.clientHeight + 1)
+ }
+ }, [reason, expanded])
+ return (
+
+
+
+ {`“${reason}”`}
+
+
+
+ {from}
+ {overflows || expanded ? (
+ setExpanded(!expanded)}
+ >
+ {expanded
+ ? t('secretRequest.showLess', 'Show less')
+ : t('secretRequest.showMore', 'Show more')}
+
+ ) : null}
+
+
+ )
+}
+
+export function SecretRequestScopeControl({
+ name,
+ scopes,
+ scope,
+ disabled,
+ onChange
+}: {
+ name: string
+ scopes: readonly SecretRequestScope[]
+ scope: SecretRequestScope
+ disabled: boolean
+ onChange: (scope: SecretRequestScope) => void
+}): React.JSX.Element {
+ const t = useTranslate()
+ if (scopes.length < 2) {
+ return (
+
+ {cloudSecretScopeLabel('personal', t)}
+
+ )
+ }
+ return (
+ onChange(next === 'repo' ? 'repo' : 'personal')}
+ disabled={disabled}
+ >
+
+
+
+
+ {scopes.map((option) => (
+
+ {cloudSecretScopeLabel(option, t)}
+
+ ))}
+
+
+ )
+}
+
+/** Saved: when the value reaches new shells, whether the agent was told, and Restart {{agent}} (Q5). */
+export function SecretRequestDone({
+ agent,
+ name,
+ scope,
+ toldAgent,
+ canRestart,
+ titleId,
+ onRestart,
+ onDismiss
+}: {
+ agent: string | null
+ name: string
+ scope: SecretRequestScope
+ /** False when the request expired first or orcad didn't answer: the agent wasn't told. */
+ toldAgent: boolean
+ canRestart: boolean
+ titleId: string
+ onRestart: () => void
+ onDismiss: () => void
+}): React.JSX.Element {
+ const t = useTranslate()
+ const theAgent = agent ?? t('secretRequest.theAgent', 'The agent')
+ const agentName = agent ?? t('secretRequest.anAgent', 'an agent')
+ return (
+
+
+
+
+ {!toldAgent
+ ? t(
+ 'secretRequest.savedAfterExpiry',
+ 'Saved. {{agent}} stopped waiting, so tell it the secret is ready.',
+ { agent: theAgent }
+ )
+ : scope === 'repo'
+ ? t('secretRequest.doneRepo', '{{name}} added to this repository’s secrets.', {
+ name
+ })
+ : t('secretRequest.done', '{{name}} added to your personal secrets.', { name })}
+
+
+ {toldAgent
+ ? t(
+ 'secretRequest.doneDetail',
+ 'New shells get it now. {{agent}} was told it’s ready, and it never gets the value from Orca.',
+ { agent: theAgent }
+ )
+ : t('secretRequest.doneDetailExpired', 'New shells get it now.')}{' '}
+ {canRestart
+ ? t(
+ 'secretRequest.restartDetail',
+ 'Restart {{agent}} to give its own shell the new value.',
+ { agent: agentName }
+ )
+ : null}
+
+
+
+ {canRestart ? (
+
+
+ {t('secretRequest.restart', 'Restart {{agent}}', { agent: agentName })}
+
+ ) : null}
+
+ {t('secretRequest.dismiss', 'Dismiss')}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/agent-pane/SecretRequestPanePortals.tsx b/src/renderer/src/components/agent-pane/SecretRequestPanePortals.tsx
index 4807a44688f..47a55033c9f 100644
--- a/src/renderer/src/components/agent-pane/SecretRequestPanePortals.tsx
+++ b/src/renderer/src/components/agent-pane/SecretRequestPanePortals.tsx
@@ -1,120 +1,50 @@
-import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
+import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useAppStore } from '@/store'
import { useTranslate } from '@/i18n/use-translate'
import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types'
-import { useSecretRequests } from '@/lib/secret-requests/secret-request-store'
+import {
+ useSecretRequests,
+ type SecretRequestEntry
+} from '@/lib/secret-requests/secret-request-store'
import {
routeSecretRequests,
- secretRequestAgentName,
- type SecretRequestPaneSlot
+ secretRequestAgentName
} from '@/lib/secret-requests/secret-request-routing'
import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id'
import type { TerminalPaneController } from '../terminal-pane/controller/use-terminal-pane-controller'
import { SecretRequestCard, secretRequestTitle } from './SecretRequestCard'
+import {
+ registerSecretRequestCard,
+ setSecretRequestSlot,
+ useSecretRequestSlots
+} from './secret-request-focus'
import { useSecretRequestCard } from './use-secret-request-card'
-// Every mounted pane on a remote host, across tabs, so a request lands on exactly one of them.
-const slots = new Map()
-const slotListeners = new Set<() => void>()
-let slotSnapshot: readonly SecretRequestPaneSlot[] = []
-
-function publishSlots(): void {
- slotSnapshot = [...slots.values()]
- for (const listener of slotListeners) {
- listener()
- }
-}
-
-function setSlot(slot: SecretRequestPaneSlot | null, paneKey: string): void {
- const current = slots.get(paneKey)
- if (slot === null) {
- if (current) {
- slots.delete(paneKey)
- publishSlots()
- }
- return
- }
- if (
- current?.environmentId === slot.environmentId &&
- current.handle === slot.handle &&
- current.primary === slot.primary
- ) {
- return
- }
- slots.set(paneKey, slot)
- publishSlots()
-}
-
-function useSlots(): readonly SecretRequestPaneSlot[] {
- return useSyncExternalStore(
- (listener) => {
- slotListeners.add(listener)
- return () => slotListeners.delete(listener)
- },
- () => slotSnapshot,
- () => slotSnapshot
- )
-}
-
-function SecretRequestPaneCard({
- pane,
- paneKey,
- ptyId,
- worktreeId,
- primary
-}: {
- pane: ManagedPane
- paneKey: string
- ptyId: string
- worktreeId: string
- primary: boolean
-}): React.JSX.Element | null {
- const t = useTranslate()
- const requests = useSecretRequests()
- const allSlots = useSlots()
- const routed = useMemo(() => routeSecretRequests(requests, allSlots), [requests, allSlots])
- const mine = routed.get(paneKey) ?? []
- const entry = mine[0]
- if (!entry) {
- return null
- }
- return createPortal(
- ,
- pane.container,
- `secret-request-${pane.id}`
- )
-}
-
function SecretRequestPaneCardBody({
entry,
more,
+ pane,
+ paneKey,
ptyId,
worktreeId,
+ onAskingPane,
primary,
announcement
}: {
- entry: Parameters[0]['entry']
+ entry: SecretRequestEntry
more: number
+ pane: ManagedPane
+ paneKey: string
ptyId: string
worktreeId: string
+ onAskingPane: boolean
primary: boolean
announcement: string
}): React.JSX.Element {
- const { model, actions } = useSecretRequestCard({ entry, more, worktreeId, ptyId })
- const fieldRef = useRef(null)
+ const { model, actions } = useSecretRequestCard({ entry, more, worktreeId, ptyId, onAskingPane })
+ const cardRef = useRef(null)
// Filled after mount so screen readers hear it once; a live region's initial text isn't read.
const [spoken, setSpoken] = useState('')
const open = model.phase === 'open'
@@ -126,26 +56,20 @@ function SecretRequestPaneCardBody({
const timer = window.setTimeout(() => setSpoken(announcement), 150)
return () => window.clearTimeout(timer)
}, [open, announcement])
- // F6 reaches the card from the terminal; the card itself never takes focus when it appears.
- useEffect(() => {
- if (!primary) {
- return
- }
- const onKeyDown = (event: KeyboardEvent): void => {
- if (event.key !== 'F6' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
- return
- }
- const field = fieldRef.current?.querySelector('input, textarea')
- const target = field ?? fieldRef.current
- if (target) {
- event.preventDefault()
- event.stopPropagation()
- target.focus()
- }
- }
- window.addEventListener('keydown', onKeyDown, true)
- return () => window.removeEventListener('keydown', onKeyDown, true)
- }, [primary])
+
+ // F6 reaches this card from its pane (or from anywhere when it's on the active pane), and the card
+ // hands focus back to where it came from when it goes away.
+ useLayoutEffect(
+ () =>
+ registerSecretRequestCard({
+ paneKey,
+ container: pane.container,
+ card: () => cardRef.current,
+ primary
+ }),
+ [paneKey, pane.container, primary]
+ )
+
return (
)
}
-function SecretRequestPaneSlotTracker({
+function SecretRequestPaneSlot({
pane,
tabId,
+ visible,
primary,
worktreeId
}: {
pane: ManagedPane
tabId: string
+ visible: boolean
primary: boolean
worktreeId: string
}): React.JSX.Element | null {
+ const t = useTranslate()
const paneKey = makePaneKey(tabId, pane.leafId)
const ptyId = useAppStore(
(state) => state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[pane.leafId] ?? null
@@ -184,23 +111,41 @@ function SecretRequestPaneSlotTracker({
const handle = remote?.handle ?? null
useEffect(() => {
if (!environmentId) {
- setSlot(null, paneKey)
return
}
- setSlot({ paneKey, environmentId, handle, primary }, paneKey)
- return () => setSlot(null, paneKey)
- }, [paneKey, environmentId, handle, primary])
- if (!environmentId || !ptyId) {
+ setSecretRequestSlot(paneKey, { paneKey, environmentId, handle, visible, primary })
+ return () => setSecretRequestSlot(paneKey, null)
+ }, [paneKey, environmentId, handle, visible, primary])
+
+ const requests = useSecretRequests()
+ const slots = useSecretRequestSlots()
+ const mine = useMemo(
+ () => (environmentId ? (routeSecretRequests(requests, slots).get(paneKey) ?? []) : []),
+ [environmentId, requests, slots, paneKey]
+ )
+ const first = mine[0]
+ if (!first || !ptyId) {
return null
}
- return (
-
+ announcement={t('secretRequest.announce', '{{title}}. Press F6 to answer.', {
+ title: secretRequestTitle(secretRequestAgentName(entry.event.agent), entry.event.name, t)
+ })}
+ />,
+ pane.container,
+ `secret-request-${pane.id}`
)
}
@@ -214,11 +159,12 @@ export function SecretRequestPanePortals({
return (
<>
{managedPanes.map((pane) => (
-
))}
diff --git a/src/renderer/src/components/agent-pane/SecretRequestUnseenNotifier.tsx b/src/renderer/src/components/agent-pane/SecretRequestUnseenNotifier.tsx
new file mode 100644
index 00000000000..f7c16d19bd7
--- /dev/null
+++ b/src/renderer/src/components/agent-pane/SecretRequestUnseenNotifier.tsx
@@ -0,0 +1,48 @@
+import { useEffect } from 'react'
+import { toast } from 'sonner'
+import { useTranslate } from '@/i18n/use-translate'
+import { useSecretRequests } from '@/lib/secret-requests/secret-request-store'
+import {
+ secretRequestAgentName,
+ unseenSecretRequests
+} from '@/lib/secret-requests/secret-request-routing'
+import { secretRequestTitle } from './SecretRequestCard'
+import { useSecretRequestSlots } from './secret-request-focus'
+
+// Panes mount a moment after their stream attaches; don't toast for a card that's about to show.
+const SETTLE_MS = 2_000
+const told = new Set()
+
+/**
+ * A request whose workspace isn't on screen would otherwise wait unseen until it expires: say so once,
+ * and the card is there when the person opens that workspace.
+ */
+export function SecretRequestUnseenNotifier(): null {
+ const t = useTranslate()
+ const requests = useSecretRequests()
+ const slots = useSecretRequestSlots()
+ useEffect(() => {
+ const unseen = unseenSecretRequests(requests, slots).filter(
+ (entry) => !told.has(`${entry.environmentId}:${entry.event.requestId}`)
+ )
+ if (unseen.length === 0) {
+ return
+ }
+ const timer = window.setTimeout(() => {
+ for (const entry of unseen) {
+ told.add(`${entry.environmentId}:${entry.event.requestId}`)
+ toast.info(
+ secretRequestTitle(secretRequestAgentName(entry.event.agent), entry.event.name, t),
+ {
+ description: t(
+ 'secretRequest.unseen',
+ 'Open that workspace to answer. The request waits up to 15 minutes.'
+ )
+ }
+ )
+ }
+ }, SETTLE_MS)
+ return () => window.clearTimeout(timer)
+ }, [requests, slots, t])
+ return null
+}
diff --git a/src/renderer/src/components/agent-pane/secret-request-focus.ts b/src/renderer/src/components/agent-pane/secret-request-focus.ts
new file mode 100644
index 00000000000..f37b8e28c2a
--- /dev/null
+++ b/src/renderer/src/components/agent-pane/secret-request-focus.ts
@@ -0,0 +1,152 @@
+// Where secret-request cards can show (one slot per mounted remote pane), and keyboard focus for the
+// cards: one app-wide F6 handler moves focus into the card for the pane you're in and back out, and a
+// card that goes away hands focus back instead of dropping it on the page.
+import { useSyncExternalStore } from 'react'
+import type { SecretRequestPaneSlot } from '@/lib/secret-requests/secret-request-routing'
+
+const slots = new Map()
+const slotListeners = new Set<() => void>()
+let slotSnapshot: readonly SecretRequestPaneSlot[] = []
+
+export function setSecretRequestSlot(paneKey: string, slot: SecretRequestPaneSlot | null): void {
+ const current = slots.get(paneKey)
+ if (slot === null) {
+ if (!current) {
+ return
+ }
+ slots.delete(paneKey)
+ } else {
+ if (
+ current?.environmentId === slot.environmentId &&
+ current.handle === slot.handle &&
+ current.visible === slot.visible &&
+ current.primary === slot.primary
+ ) {
+ return
+ }
+ slots.set(paneKey, slot)
+ }
+ slotSnapshot = [...slots.values()]
+ for (const listener of slotListeners) {
+ listener()
+ }
+}
+
+export function useSecretRequestSlots(): readonly SecretRequestPaneSlot[] {
+ return useSyncExternalStore(
+ (listener) => {
+ slotListeners.add(listener)
+ return () => slotListeners.delete(listener)
+ },
+ () => slotSnapshot,
+ () => slotSnapshot
+ )
+}
+
+type CardRecord = {
+ paneKey: string
+ container: HTMLElement
+ element: HTMLElement
+ primary: boolean
+ /** Where F6 came from, to go back to. */
+ returnTo: HTMLElement | null
+ hadFocus: boolean
+}
+
+const cards = new Map()
+
+/** The pane's terminal input, when the element focus came from is gone. */
+function terminalInput(container: HTMLElement): HTMLElement | null {
+ return container.querySelector('textarea')
+}
+
+function focusInto(record: CardRecord, from: Element | null): void {
+ record.returnTo = from instanceof HTMLElement && from !== document.body ? from : null
+ const target =
+ record.element.querySelector('input:not([disabled]), textarea:not([disabled])') ??
+ record.element.querySelector('button')
+ target?.focus()
+}
+
+function focusBack(record: CardRecord): void {
+ const back =
+ record.returnTo && record.returnTo.isConnected
+ ? record.returnTo
+ : terminalInput(record.container)
+ back?.focus()
+}
+
+function onKeyDown(event: KeyboardEvent): void {
+ if (event.key !== 'F6' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
+ return
+ }
+ const active = document.activeElement
+ const all = [...cards.values()]
+ const inside = all.find((record) => record.element.contains(active))
+ const target =
+ inside ??
+ all.find((record) => active !== null && record.container.contains(active)) ??
+ all.find((record) => record.primary)
+ if (!target) {
+ return
+ }
+ event.preventDefault()
+ event.stopImmediatePropagation()
+ if (inside) {
+ focusBack(inside)
+ } else {
+ focusInto(target, active)
+ }
+}
+
+export function registerSecretRequestCard(args: {
+ paneKey: string
+ container: HTMLElement
+ card: () => HTMLElement | null
+ primary: boolean
+}): () => void {
+ const element = args.card()
+ if (!element) {
+ return () => {}
+ }
+ const previous = cards.get(args.paneKey)
+ const record: CardRecord = {
+ paneKey: args.paneKey,
+ container: args.container,
+ element,
+ primary: args.primary,
+ returnTo: previous?.element === element ? previous.returnTo : null,
+ hadFocus: previous?.element === element ? previous.hadFocus : false
+ }
+ const onFocusIn = (): void => {
+ record.hadFocus = true
+ }
+ const onFocusOut = (event: FocusEvent): void => {
+ if (event.relatedTarget instanceof Node && !element.contains(event.relatedTarget)) {
+ record.hadFocus = false
+ }
+ }
+ element.addEventListener('focusin', onFocusIn)
+ element.addEventListener('focusout', onFocusOut)
+ if (cards.size === 0) {
+ window.addEventListener('keydown', onKeyDown, true)
+ }
+ cards.set(args.paneKey, record)
+ return () => {
+ element.removeEventListener('focusin', onFocusIn)
+ element.removeEventListener('focusout', onFocusOut)
+ if (cards.get(args.paneKey) === record) {
+ cards.delete(args.paneKey)
+ }
+ if (cards.size === 0) {
+ window.removeEventListener('keydown', onKeyDown, true)
+ }
+ // After the commit: if the card left the page while it held focus, focus goes back, not to .
+ queueMicrotask(() => {
+ const lost = document.activeElement === null || document.activeElement === document.body
+ if (!element.isConnected && record.hadFocus && lost) {
+ focusBack(record)
+ }
+ })
+ }
+}
diff --git a/src/renderer/src/components/agent-pane/use-secret-request-card.test.tsx b/src/renderer/src/components/agent-pane/use-secret-request-card.test.tsx
index fce1b87098c..8c5f65e586a 100644
--- a/src/renderer/src/components/agent-pane/use-secret-request-card.test.tsx
+++ b/src/renderer/src/components/agent-pane/use-secret-request-card.test.tsx
@@ -45,7 +45,13 @@ function Harness(): React.JSX.Element | null {
}
function Card({ entry }: { entry: ReturnType[number] }) {
- const { model, actions } = useSecretRequestCard({ entry, more: 0, worktreeId: null, ptyId: 'p' })
+ const { model, actions } = useSecretRequestCard({
+ entry,
+ more: 0,
+ worktreeId: null,
+ ptyId: 'p',
+ onAskingPane: true
+ })
return
}
@@ -157,6 +163,38 @@ describe('S4-b secret request card', () => {
expect(screen.queryByLabelText('Value for NPM_TOKEN')).toBeNull()
})
+ it('never declines while a save is in flight', async () => {
+ let finish: (value: unknown) => void = () => {}
+ put.mockReturnValue(new Promise((resolve) => (finish = resolve)))
+ render( )
+ fireEvent.change(valueInput(), { target: { value: 'v' } })
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Add secret' }))
+ })
+ await act(async () => {
+ fireEvent.keyDown(valueInput(), { key: 'Escape' })
+ fireEvent.click(screen.getByRole('button', { name: 'Not now' }))
+ })
+ expect(rpc.mock.calls.some(([, , params]) => params?.outcome === 'declined')).toBe(false)
+ await act(async () => finish({ ok: true, value: {} }))
+ expect(screen.getByText('NPM_TOKEN added to your personal secrets.')).toBeTruthy()
+ })
+
+ it('says the agent wasn’t told when orcad doesn’t answer the resolve', async () => {
+ rpc.mockImplementation(async (_target, method: string) => {
+ if (method === 'secrets.resolveRequest') {
+ throw new Error('timeout')
+ }
+ return { expiresAt: NOW }
+ })
+ render( )
+ fireEvent.change(valueInput(), { target: { value: 'v' } })
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Add secret' }))
+ })
+ expect(screen.getByText(/stopped waiting, so tell it the secret is ready/)).toBeTruthy()
+ })
+
it('declines on Escape', async () => {
render( )
await act(async () => {
diff --git a/src/renderer/src/components/agent-pane/use-secret-request-card.ts b/src/renderer/src/components/agent-pane/use-secret-request-card.ts
index 28b65f7c42d..7127d63294f 100644
--- a/src/renderer/src/components/agent-pane/use-secret-request-card.ts
+++ b/src/renderer/src/components/agent-pane/use-secret-request-card.ts
@@ -1,6 +1,6 @@
// Drives one S4-b card: the typed value lives only here, is sent once to Orca Cloud (PUT
// /v1/secrets/:scope/:NAME through main), then orcad is told the outcome. The value never goes to orcad.
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
import { useTranslate } from '@/i18n/use-translate'
import { useAppStore } from '@/store'
import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call'
@@ -8,10 +8,13 @@ import { cloudSecretsErrorCopy } from '@/components/settings/cloud-secrets/cloud
import { cloudSecretRepoKey } from '@/components/settings/cloud-secrets/cloud-secret-repos'
import {
dismissSecretRequest,
+ readSecretRequestTouchedAt,
setSecretRequestExpiry,
setSecretRequestLocal,
+ setSecretRequestTouched,
type SecretRequestEntry
} from '@/lib/secret-requests/secret-request-store'
+import { useAgentHostPanelOpener } from '@/lib/agent-host-panel/agent-host-panel-opener'
import {
restartableSecretRequestAgent,
secretRequestAgentName
@@ -105,8 +108,10 @@ export function useSecretRequestCard(args: {
worktreeId: string | null
/** The pane's PTY, for Restart {{agent}}. */
ptyId: string | null
+ /** The card sits on the asking agent's own pane; only then can Restart target it. */
+ onAskingPane: boolean
}): { model: SecretRequestCardModel; actions: SecretRequestCardActions } {
- const { entry, more, worktreeId, ptyId } = args
+ const { entry, more, worktreeId, ptyId, onAskingPane } = args
const { environmentId, event } = entry
const { requestId } = event
const t = useTranslate()
@@ -116,8 +121,9 @@ export function useSecretRequestCard(args: {
const [fieldError, setFieldError] = useState(null)
const [focused, setFocused] = useState(false)
const [repoWritable, setRepoWritable] = useState(false)
+ const [collapsed, setCollapsed] = useState(false)
const repoKey = useWorkspaceRepoKey(event.worktreeId ?? worktreeId)
- const lastTouch = useRef(0)
+ const openPanel = useAgentHostPanelOpener()
const local = entry.local
useEffect(() => {
@@ -165,7 +171,7 @@ export function useSecretRequestCard(args: {
return
}
const touch = (): void => {
- lastTouch.current = Date.now()
+ setSecretRequestTouched(environmentId, requestId, Date.now())
void callRuntimeRpc(
{ kind: 'environment', environmentId },
SECRETS_TOUCH_REQUEST_METHOD,
@@ -176,8 +182,13 @@ export function useSecretRequestCard(args: {
() => {}
)
}
- // Never more than once a minute, across focus changes too.
- const delay = Math.max(0, lastTouch.current + SECRET_REQUEST_TOUCH_INTERVAL_MS - Date.now())
+ // Never more than once a minute, across focus changes and card remounts too.
+ const delay = Math.max(
+ 0,
+ readSecretRequestTouchedAt(environmentId, requestId) +
+ SECRET_REQUEST_TOUCH_INTERVAL_MS -
+ Date.now()
+ )
let interval: number | undefined
const first = window.setTimeout(() => {
touch()
@@ -225,10 +236,11 @@ export function useSecretRequestCard(args: {
setValue('')
void api.refreshEnv({ runtimeEnvironmentId: environmentId }).catch(() => {})
const outcome = await resolveRequest(environmentId, requestId, 'provided')
+ // Only a confirmed 'provided' means the agent was told; otherwise say so and let the person tell it.
setSecretRequestLocal(
environmentId,
requestId,
- outcome === 'expired' ? 'savedAfterExpiry' : 'saved'
+ outcome === 'provided' ? 'saved' : 'savedAfterExpiry'
)
}, [value, local, environmentId, requestId, effectiveScope, repoKey, event.name, t])
@@ -256,7 +268,8 @@ export function useSecretRequestCard(args: {
phase,
fieldError,
more,
- canRestart: restartAgent !== null && ptyId !== null
+ canRestart: restartAgent !== null && ptyId !== null && onAskingPane,
+ collapsed
},
actions: {
onValueChange: (next) => {
@@ -267,6 +280,10 @@ export function useSecretRequestCard(args: {
onAdd: () => void add(),
onRetry: () => void add(),
onNotNow: () => {
+ // Mid-save the value may already be stored: never tell the agent "declined" then.
+ if (local !== null) {
+ return
+ }
setValue('')
dismissSecretRequest(environmentId, requestId)
void resolveRequest(environmentId, requestId, 'declined')
@@ -278,6 +295,10 @@ export function useSecretRequestCard(args: {
dismissSecretRequest(environmentId, requestId)
},
onDismiss: () => dismissSecretRequest(environmentId, requestId),
+ onToggleCollapsed: () => setCollapsed((current) => !current),
+ onDetails: openPanel
+ ? () => openPanel({ hostId: environmentId, focus: { secret: event.name } })
+ : undefined,
onFocusChange: setFocused
}
}
diff --git a/src/renderer/src/components/cloud-secrets-ask/CloudSecretsAskForm.tsx b/src/renderer/src/components/cloud-secrets-ask/CloudSecretsAskForm.tsx
index a46bc8f28b3..500c60feae6 100644
--- a/src/renderer/src/components/cloud-secrets-ask/CloudSecretsAskForm.tsx
+++ b/src/renderer/src/components/cloud-secrets-ask/CloudSecretsAskForm.tsx
@@ -203,8 +203,13 @@ export function CloudSecretsAskForm({
{t(
'cloudSecretsAsk.buildTeamOnly',
- 'The environment build only uses team values. Ask an owner or admin to add {{name}} as a team secret.',
- { name: listNames(data.missing) }
+ 'The environment build only uses team values. Ask an owner or admin to add {{name}} as team secrets.',
+ {
+ name: listNames(data.missing),
+ count: data.missing.length,
+ defaultValue_one:
+ 'The environment build only uses team values. Ask an owner or admin to add {{name}} as a team secret.'
+ }
)}
) : null}
@@ -283,7 +288,7 @@ export function CloudSecretsAskForm({
>
diff --git a/src/renderer/src/components/cloud-secrets-ask/CloudSecretsMissingNotice.tsx b/src/renderer/src/components/cloud-secrets-ask/CloudSecretsMissingNotice.tsx
index 07492b97e02..1fc732d4930 100644
--- a/src/renderer/src/components/cloud-secrets-ask/CloudSecretsMissingNotice.tsx
+++ b/src/renderer/src/components/cloud-secrets-ask/CloudSecretsMissingNotice.tsx
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useSyncExternalStore } from 'react'
-import { X } from 'lucide-react'
+import { KeyRound, X } from 'lucide-react'
import { useTranslate } from '@/i18n/use-translate'
import { Button } from '@/components/ui/button'
import { InlineError } from '@/components/ui/inline-error'
@@ -56,35 +56,35 @@ export function CloudSecretsMissingNotice({
return null
}
return (
-
- openCloudSecretsAsk(workspaceId)}
- >
- {t('cloudSecretsAsk.add', 'Add')}
-
- clearCloudSecretsMissing(workspaceId)}
- >
-
-
- >
- }
- />
+
+
+
+ {t('cloudSecretsAsk.missing', '{{count}} secrets missing', {
+ count: notice.names.length,
+ defaultValue_one: '{{count}} secret missing'
+ })}
+
+ openCloudSecretsAsk(workspaceId)}
+ >
+ {t('cloudSecretsAsk.add', 'Add')}
+
+ clearCloudSecretsMissing(workspaceId)}
+ >
+
+
+
)
}
diff --git a/src/renderer/src/components/cloud-secrets-ask/use-cloud-secrets-ask.ts b/src/renderer/src/components/cloud-secrets-ask/use-cloud-secrets-ask.ts
index 3b83d959c58..e7e3d436322 100644
--- a/src/renderer/src/components/cloud-secrets-ask/use-cloud-secrets-ask.ts
+++ b/src/renderer/src/components/cloud-secrets-ask/use-cloud-secrets-ask.ts
@@ -49,8 +49,12 @@ export function useCloudSecretsAskLoad(repoKey: string | null): {
if (cancelled) {
return
}
- // No environment (or no access to the list) means nothing is declared: stay out of the way.
- const environment = list?.ok ? environmentForRepo(list.data.environments, repoKey) : null
+ // A list that failed isn't "no environment": the caller must not take it as nothing missing.
+ if (!list || !list.ok) {
+ setLoad({ status: 'failed', error: 'unavailable' })
+ return
+ }
+ const environment = environmentForRepo(list.data.environments, repoKey)
if (!environment) {
setLoad({ status: 'none' })
return
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index d00f94e1ac9..9b60f0a6f1a 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -27140,7 +27140,8 @@
"guestOwner": "Personal values come from {{owner}}, who owns this workspace.",
"guest": "Personal values come from the person who owns this workspace.",
"empty": "No variables from Cloud secrets yet.",
- "overrides": "overrides {{scopes}}"
+ "overrides": "overrides {{scopes}}",
+ "askOwner": "Ask {{owner}} or an admin to add it."
},
"keys": {
"askAdmin": "Ask an owner or admin to add one.",
@@ -27151,7 +27152,9 @@
"shadowedLabel": "Keys not used for {{name}}",
"editLabel": "Edit {{name}}",
"edit": "Edit",
- "empty": "No agent keys in Cloud secrets."
+ "empty": "No agent keys in Cloud secrets.",
+ "emptyMember": "Add your own, or ask an owner or admin for a team key.",
+ "manage": "Open Cloud secrets"
},
"section": {
"keysCloud": "API keys from Cloud secrets",
@@ -27163,7 +27166,9 @@
},
"retry": "Try again",
"asleepFooter": "Changes reach the sandbox when it wakes.",
- "loadingSecrets": "Loading Cloud secrets…"
+ "loadingSecrets": "Loading Cloud secrets…",
+ "unreachable": "Couldn’t reach Orca Cloud.",
+ "details": "Details"
},
"cloudSecretsAsk": {
"scopeLabel": "Scope for {{name}}",
@@ -27193,7 +27198,9 @@
"loading": "Loading secrets…",
"nothingMissing": "Nothing is missing.",
"dialogTitle": "Add missing secrets",
- "dialogDetail": "Values go to your Cloud secrets. New terminals and agents in this workspace get them within a few seconds."
+ "dialogDetail": "Values go to your Cloud secrets. New terminals and agents in this workspace get them within a few seconds.",
+ "buildTeamOnly_one": "The environment build only uses team values. Ask an owner or admin to add {{name}} as a team secret.",
+ "buildTeamOnly_other": "The environment build only uses team values. Ask an owner or admin to add {{name}} as team secrets."
},
"cloudAgents": {
"source": {
@@ -27234,6 +27241,9 @@
"retry": "Try again",
"notNow": "Not now",
"add": "Add secret",
- "announce": "{{title}}. Press F6 to answer."
+ "announce": "{{title}}. Press F6 to answer.",
+ "expand": "Show request",
+ "collapse": "Hide request",
+ "unseen": "Open that workspace to answer. The request waits up to 15 minutes."
}
}
diff --git a/src/renderer/src/lib/agent-host-panel/agent-host-panel-opener.ts b/src/renderer/src/lib/agent-host-panel/agent-host-panel-opener.ts
new file mode 100644
index 00000000000..002bc00878f
--- /dev/null
+++ b/src/renderer/src/lib/agent-host-panel/agent-host-panel-opener.ts
@@ -0,0 +1,42 @@
+// The deep link into the "Agent accounts & secrets" panel (spec §4). The panel shell registers itself
+// here; prompts that point into it (S4-b's Details) show the link only once it's there.
+import { useSyncExternalStore } from 'react'
+
+export type AgentHostPanelFocus = 'claude' | 'codex' | 'keys' | { secret: string }
+
+export type OpenAgentHostPanel = (target: {
+ /** The runtime environment id of the host. */
+ hostId: string
+ focus?: AgentHostPanelFocus
+}) => void
+
+let opener: OpenAgentHostPanel | null = null
+const listeners = new Set<() => void>()
+
+/** Called by the panel shell when it mounts; returns the unregister function. */
+export function registerAgentHostPanelOpener(open: OpenAgentHostPanel): () => void {
+ opener = open
+ for (const listener of listeners) {
+ listener()
+ }
+ return () => {
+ if (opener === open) {
+ opener = null
+ for (const listener of listeners) {
+ listener()
+ }
+ }
+ }
+}
+
+/** The opener, or null while no panel shell is mounted. */
+export function useAgentHostPanelOpener(): OpenAgentHostPanel | null {
+ return useSyncExternalStore(
+ (listener) => {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+ },
+ () => opener,
+ () => opener
+ )
+}
diff --git a/src/renderer/src/lib/orca-cloud/cloud-secrets-missing-notice-store.test.ts b/src/renderer/src/lib/orca-cloud/cloud-secrets-missing-notice-store.test.ts
new file mode 100644
index 00000000000..80a64b574fe
--- /dev/null
+++ b/src/renderer/src/lib/orca-cloud/cloud-secrets-missing-notice-store.test.ts
@@ -0,0 +1,30 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ clearCloudSecretsMissing,
+ narrowCloudSecretsMissing,
+ readCloudSecretsMissing,
+ recordCloudSecretsMissing,
+ resetCloudSecretsMissingForTests
+} from './cloud-secrets-missing-notice-store'
+
+const REPO = 'github.com/acme/web'
+
+describe('cloud secrets missing notice', () => {
+ afterEach(() => resetCloudSecretsMissingForTests())
+
+ it('keeps names per workspace until filled or dismissed', () => {
+ recordCloudSecretsMissing('ws', { names: ['A', 'B'], repoKey: REPO }, 1)
+ narrowCloudSecretsMissing('ws', ['B', 'C'])
+ expect(readCloudSecretsMissing('ws')?.names).toEqual(['B'])
+ narrowCloudSecretsMissing('ws', [])
+ expect(readCloudSecretsMissing('ws')).toBeNull()
+ recordCloudSecretsMissing('ws', { names: ['A'], repoKey: REPO })
+ clearCloudSecretsMissing('ws')
+ expect(readCloudSecretsMissing('ws')).toBeNull()
+ })
+
+ it('records nothing when nothing is missing', () => {
+ recordCloudSecretsMissing('ws', { names: [], repoKey: REPO })
+ expect(readCloudSecretsMissing('ws')).toBeNull()
+ })
+})
diff --git a/src/renderer/src/lib/secret-requests/secret-request-event-guards.ts b/src/renderer/src/lib/secret-requests/secret-request-event-guards.ts
index 76303915656..18481d57f04 100644
--- a/src/renderer/src/lib/secret-requests/secret-request-event-guards.ts
+++ b/src/renderer/src/lib/secret-requests/secret-request-event-guards.ts
@@ -3,9 +3,11 @@ import { z } from 'zod'
import {
SECRET_REQUEST_REASON_MAX,
SECRETS_REQUEST_SETTLED_EVENT,
+ SECRETS_REQUEST_UPDATED_EVENT,
SECRETS_REQUESTED_EVENT,
type SecretRequestedEvent,
- type SecretRequestSettledEvent
+ type SecretRequestSettledEvent,
+ type SecretRequestUpdatedEvent
} from '../../../../shared/secret-request-contract'
const RequestId = z.string().regex(/^sr_[A-Za-z0-9_-]{8,64}$/)
@@ -33,6 +35,16 @@ const SettledSchema = z.object({
status: z.enum(['provided', 'declined', 'expired'])
})
+const UpdatedSchema = z.object({
+ type: z.literal(SECRETS_REQUEST_UPDATED_EVENT),
+ requestId: RequestId,
+ expiresAt: Millis
+})
+
+export function isSecretRequestUpdatedEvent(value: unknown): value is SecretRequestUpdatedEvent {
+ return UpdatedSchema.safeParse(value).success
+}
+
export function isSecretRequestedEvent(value: unknown): value is SecretRequestedEvent {
return RequestedSchema.safeParse(value).success
}
diff --git a/src/renderer/src/lib/secret-requests/secret-request-routing.ts b/src/renderer/src/lib/secret-requests/secret-request-routing.ts
index 7c33bf19b9b..9d2a4726ce9 100644
--- a/src/renderer/src/lib/secret-requests/secret-request-routing.ts
+++ b/src/renderer/src/lib/secret-requests/secret-request-routing.ts
@@ -1,5 +1,4 @@
-// Which pane shows which secret request: the pane running the asking terminal, else the active pane of
-// a visible tab on the same host. One card per pane; the rest count as "N more requests".
+// Which pane shows which secret request. One card per pane; the rest count as "N more requests".
import type { SecretRequestEntry } from './secret-request-store'
export type SecretRequestPaneSlot = {
@@ -7,47 +6,84 @@ export type SecretRequestPaneSlot = {
environmentId: string
/** The remote terminal handle this pane runs, when known. */
handle: string | null
- /** The active pane of a visible tab: where requests without a matching pane land. */
+ /** The pane's tab is the one showing. */
+ visible: boolean
+ /** The active pane of a showing tab: where requests without a showing asking pane land. */
primary: boolean
}
+export type RoutedSecretRequest = {
+ entry: SecretRequestEntry
+ /** Shown on the pane running the asking terminal, so "Restart {{agent}}" restarts the right one. */
+ onAskingPane: boolean
+}
+
+function handleKey(environmentId: string, handle: string): string {
+ return JSON.stringify([environmentId, handle])
+}
+
+/**
+ * The asking terminal's pane when its tab is showing; else the active pane of a showing tab on the
+ * same host; else the asking pane even though hidden (it shows when that tab is opened).
+ */
export function routeSecretRequests(
entries: readonly SecretRequestEntry[],
slots: readonly SecretRequestPaneSlot[]
-): Map {
- const routed = new Map()
- const byHandle = new Map()
+): Map {
+ const routed = new Map()
+ const byHandle = new Map()
for (const slot of slots) {
if (slot.handle) {
- byHandle.set(`${slot.environmentId}\u0000${slot.handle}`, slot.paneKey)
+ byHandle.set(handleKey(slot.environmentId, slot.handle), slot)
}
}
- // Deterministic fallback when several visible tabs share a host: the lowest pane key.
+ // Deterministic when several showing tabs share a host: the lowest pane key.
const primaryByEnvironment = new Map()
for (const slot of slots) {
const current = primaryByEnvironment.get(slot.environmentId)
- if (slot.primary && (current === undefined || slot.paneKey < current)) {
+ if (slot.primary && slot.visible && (current === undefined || slot.paneKey < current)) {
primaryByEnvironment.set(slot.environmentId, slot.paneKey)
}
}
for (const entry of entries) {
const handle = entry.event.terminalHandle
- const paneKey =
- (handle ? byHandle.get(`${entry.environmentId}\u0000${handle}`) : undefined) ??
- primaryByEnvironment.get(entry.environmentId)
+ const asking = handle ? byHandle.get(handleKey(entry.environmentId, handle)) : undefined
+ const primary = primaryByEnvironment.get(entry.environmentId)
+ const paneKey = asking?.visible ? asking.paneKey : (primary ?? asking?.paneKey)
if (!paneKey) {
continue
}
+ const item = { entry, onAskingPane: asking?.paneKey === paneKey }
const list = routed.get(paneKey)
if (list) {
- list.push(entry)
+ list.push(item)
} else {
- routed.set(paneKey, [entry])
+ routed.set(paneKey, [item])
}
}
return routed
}
+/** Pending requests no showing pane displays (the person is looking at another workspace). */
+export function unseenSecretRequests(
+ entries: readonly SecretRequestEntry[],
+ slots: readonly SecretRequestPaneSlot[]
+): SecretRequestEntry[] {
+ const routed = routeSecretRequests(entries, slots)
+ const visibleKeys = new Set(slots.filter((slot) => slot.visible).map((slot) => slot.paneKey))
+ const seen = new Set()
+ for (const [paneKey, items] of routed) {
+ if (visibleKeys.has(paneKey)) {
+ for (const item of items) {
+ seen.add(item.entry)
+ }
+ }
+ }
+ return entries.filter(
+ (entry) => !seen.has(entry) && entry.local === null && entry.settled === null
+ )
+}
+
const AGENT_DISPLAY_NAME: Record = {
claude: 'Claude',
codex: 'Codex',
diff --git a/src/renderer/src/lib/secret-requests/secret-request-store.test.ts b/src/renderer/src/lib/secret-requests/secret-request-store.test.ts
index 5994f13b957..1be2a108997 100644
--- a/src/renderer/src/lib/secret-requests/secret-request-store.test.ts
+++ b/src/renderer/src/lib/secret-requests/secret-request-store.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SecretRequestedEvent } from '../../../../shared/secret-request-contract'
import {
applySecretRequested,
@@ -6,6 +6,7 @@ import {
dismissSecretRequest,
readSecretRequests,
resetSecretRequestStoreForTests,
+ markSecretRequestsReplaying,
setSecretRequestExpiry,
setSecretRequestLocal
} from './secret-request-store'
@@ -13,6 +14,7 @@ import { isSecretRequestedEvent, isSecretRequestSettledEvent } from './secret-re
import {
routeSecretRequests,
secretRequestAgentName,
+ unseenSecretRequests,
type SecretRequestPaneSlot
} from './secret-request-routing'
@@ -78,6 +80,28 @@ describe('secret request store', () => {
expect(readSecretRequests()[0].event.expiresAt).toBe(99_000_000)
})
+ it('drops what orcad no longer replays after the stream reconnects', () => {
+ vi.useFakeTimers()
+ applySecretRequested('env', requested())
+ applySecretRequested('env', requested({ requestId: 'sr_bbbbbbbb', name: 'B' }))
+ setSecretRequestLocal('env', 'sr_bbbbbbbb', 'saved')
+ markSecretRequestsReplaying('env')
+ vi.advanceTimersByTime(6_000)
+ // The settled one is gone; the one this window finished stays until dismissed.
+ expect(readSecretRequests().map((entry) => entry.event.name)).toEqual(['B'])
+ vi.useRealTimers()
+ })
+
+ it('keeps a request orcad replays after a reconnect', () => {
+ vi.useFakeTimers()
+ applySecretRequested('env', requested())
+ markSecretRequestsReplaying('env')
+ applySecretRequested('env', requested())
+ vi.advanceTimersByTime(6_000)
+ expect(readSecretRequests()).toHaveLength(1)
+ vi.useRealTimers()
+ })
+
it('keeps requests from different hosts apart', () => {
applySecretRequested('a', requested())
applySecretRequested('b', requested())
@@ -102,39 +126,54 @@ describe('event guards', () => {
})
describe('routeSecretRequests', () => {
+ afterEach(() => resetSecretRequestStoreForTests())
const slot = (patch: Partial): SecretRequestPaneSlot => ({
paneKey: 'tab:1',
environmentId: 'env',
handle: null,
+ visible: true,
primary: false,
...patch
})
- it('pins a request to the pane running the asking terminal', () => {
+ it('pins a request to the showing pane running the asking terminal', () => {
applySecretRequested('env', requested({ terminalHandle: 'h2' }))
const routed = routeSecretRequests(readSecretRequests(), [
slot({ paneKey: 'tab:1', handle: 'h1', primary: true }),
slot({ paneKey: 'tab:2', handle: 'h2' })
])
expect([...routed.keys()]).toEqual(['tab:2'])
- resetSecretRequestStoreForTests()
+ expect(routed.get('tab:2')?.[0].onAskingPane).toBe(true)
+ })
+
+ it('shows a request from a hidden tab on the showing pane, without Restart rights there', () => {
+ applySecretRequested('env', requested({ terminalHandle: 'h2' }))
+ const routed = routeSecretRequests(readSecretRequests(), [
+ slot({ paneKey: 'tab:1', handle: 'h1', primary: true }),
+ slot({ paneKey: 'tab:2', handle: 'h2', visible: false })
+ ])
+ expect([...routed.keys()]).toEqual(['tab:1'])
+ expect(routed.get('tab:1')?.[0].onAskingPane).toBe(false)
})
it('falls back to the active pane on the same host, and stacks the rest there', () => {
applySecretRequested('env', requested({ terminalHandle: undefined }))
applySecretRequested('env', requested({ requestId: 'sr_cccccccc', terminalHandle: 'gone' }))
applySecretRequested('other', requested({ requestId: 'sr_dddddddd' }))
- const routed = routeSecretRequests(readSecretRequests(), [
+ const slots = [
slot({ paneKey: 'tab:1', handle: 'h1' }),
slot({ paneKey: 'tab:9', handle: 'h9', primary: true })
- ])
- expect(routed.get('tab:9')?.map((entry) => entry.event.requestId)).toEqual([
+ ]
+ const routed = routeSecretRequests(readSecretRequests(), slots)
+ expect(routed.get('tab:9')?.map((item) => item.entry.event.requestId)).toEqual([
'sr_aaaaaaaa',
'sr_cccccccc'
])
- // Nothing on the other host is mounted here, so its request waits.
+ // Nothing on the other host is mounted, so its request is unseen (the notifier says so).
expect(routed.size).toBe(1)
- resetSecretRequestStoreForTests()
+ expect(unseenSecretRequests(readSecretRequests(), slots).map((e) => e.environmentId)).toEqual([
+ 'other'
+ ])
})
})
diff --git a/src/renderer/src/lib/secret-requests/secret-request-store.ts b/src/renderer/src/lib/secret-requests/secret-request-store.ts
index a359b676efb..10822bc2a7f 100644
--- a/src/renderer/src/lib/secret-requests/secret-request-store.ts
+++ b/src/renderer/src/lib/secret-requests/secret-request-store.ts
@@ -24,6 +24,10 @@ export type SecretRequestEntry = {
local: SecretRequestLocalPhase | null
/** This window's clock when the event first arrived (ties in requestedAt keep arrival order). */
receivedAt: number
+ /** When this window last sent touchRequest (D3: at most once a minute, across card remounts). */
+ touchedAt: number
+ /** The stream reconnected and orcad hasn't replayed this request yet; dropped if it never does. */
+ stale: boolean
}
type State = { entries: ReadonlyMap }
@@ -66,11 +70,75 @@ export function applySecretRequested(
event,
settled: existing?.settled ?? null,
local: existing?.local ?? null,
- receivedAt: existing?.receivedAt ?? now
+ receivedAt: existing?.receivedAt ?? now,
+ touchedAt: existing?.touchedAt ?? 0,
+ stale: false
})
commit(next)
}
+/** Any client's touchRequest moved the expiry (secrets.requestUpdated). */
+export function applySecretRequestUpdated(
+ environmentId: string,
+ event: { requestId: string; expiresAt: number }
+): void {
+ setSecretRequestExpiry(environmentId, event.requestId, event.expiresAt)
+}
+
+/** A request this window can no longer act on stays only while it shows what the person did. */
+const REPLAY_GRACE_MS = 5_000
+const staleTimers = new Map>()
+
+/**
+ * The stream (re)attached: orcad replays every request still pending right after. Requests it
+ * settled while this window was away aren't replayed, so whatever isn't replayed soon is dropped.
+ */
+export function markSecretRequestsReplaying(environmentId: string): void {
+ let changed = false
+ const next = new Map(state.entries)
+ for (const [key, entry] of next) {
+ if (entry.environmentId === environmentId && entry.local === null && entry.settled === null) {
+ next.set(key, { ...entry, stale: true })
+ changed = true
+ }
+ }
+ if (!changed) {
+ return
+ }
+ commit(next)
+ clearTimeout(staleTimers.get(environmentId))
+ staleTimers.set(
+ environmentId,
+ setTimeout(() => {
+ staleTimers.delete(environmentId)
+ const pruned = new Map(
+ [...state.entries].filter(
+ ([, entry]) =>
+ !(entry.environmentId === environmentId && entry.stale && entry.local === null)
+ )
+ )
+ if (pruned.size !== state.entries.size) {
+ commit(pruned)
+ }
+ }, REPLAY_GRACE_MS)
+ )
+}
+
+export function setSecretRequestTouched(
+ environmentId: string,
+ requestId: string,
+ touchedAt: number
+): void {
+ const key = keyOf(environmentId, requestId)
+ const existing = state.entries.get(key)
+ if (!existing) {
+ return
+ }
+ const next = new Map(state.entries)
+ next.set(key, { ...existing, touchedAt })
+ commit(next)
+}
+
export function applySecretRequestSettled(
environmentId: string,
event: SecretRequestSettledEvent
@@ -141,16 +209,6 @@ export function dismissSecretRequest(environmentId: string, requestId: string):
commit(next)
}
-/** The environment's stream ended for good (host removed): its requests can't be answered here. */
-export function clearSecretRequestsForEnvironment(environmentId: string): void {
- const next = new Map(
- [...state.entries].filter(([, entry]) => entry.environmentId !== environmentId)
- )
- if (next.size !== state.entries.size) {
- commit(next)
- }
-}
-
const EMPTY: readonly SecretRequestEntry[] = []
let cachedFor: State | null = null
let cachedList: readonly SecretRequestEntry[] = EMPTY
@@ -178,7 +236,16 @@ export function readSecretRequests(): readonly SecretRequestEntry[] {
}
export function resetSecretRequestStoreForTests(): void {
+ for (const timer of staleTimers.values()) {
+ clearTimeout(timer)
+ }
+ staleTimers.clear()
state = { entries: new Map() }
cachedFor = null
cachedList = EMPTY
}
+
+/** When this window last touched the request, read at the time a hold starts. */
+export function readSecretRequestTouchedAt(environmentId: string, requestId: string): number {
+ return state.entries.get(keyOf(environmentId, requestId))?.touchedAt ?? 0
+}
diff --git a/src/renderer/src/runtime/rpc/subscriptions/runtime-client-events.ts b/src/renderer/src/runtime/rpc/subscriptions/runtime-client-events.ts
index 6fa22b46d99..e1dac0a700c 100644
--- a/src/renderer/src/runtime/rpc/subscriptions/runtime-client-events.ts
+++ b/src/renderer/src/runtime/rpc/subscriptions/runtime-client-events.ts
@@ -11,15 +11,19 @@ import { subscribeRuntimeEnvironment } from '../environment-revisions/runtime-en
import { answerBrowserClientHostRequest } from '../../browser/client-hosting/browser-client-host-request-answer'
import {
SECRETS_REQUEST_SETTLED_EVENT,
+ SECRETS_REQUEST_UPDATED_EVENT,
SECRETS_REQUESTED_EVENT
} from '../../../../../shared/secret-request-contract'
import {
applySecretRequested,
- applySecretRequestSettled
+ applySecretRequestSettled,
+ applySecretRequestUpdated,
+ markSecretRequestsReplaying
} from '@/lib/secret-requests/secret-request-store'
import {
isSecretRequestedEvent,
- isSecretRequestSettledEvent
+ isSecretRequestSettledEvent,
+ isSecretRequestUpdatedEvent
} from '@/lib/secret-requests/secret-request-event-guards'
export type RuntimeClientEventSubscription = {
@@ -76,6 +80,8 @@ function handleRuntimeClientEventResponse(
}
const message = response.result as RuntimeClientEventStreamMessage
if (message.type === 'ready') {
+ // orcad replays pending secret requests right after this; ones it doesn't replay are gone.
+ markSecretRequestsReplaying(environmentId)
for (const sshState of message.snapshot?.sshStates ?? []) {
const state = admitSshConnectionState(sshState.state, sshState.targetId)
if (state) {
@@ -100,6 +106,12 @@ function handleRuntimeClientEventResponse(
}
return
}
+ if (message.type === SECRETS_REQUEST_UPDATED_EVENT) {
+ if (isSecretRequestUpdatedEvent(message)) {
+ applySecretRequestUpdated(environmentId, message)
+ }
+ return
+ }
if (message.type === SECRETS_REQUEST_SETTLED_EVENT) {
if (isSecretRequestSettledEvent(message)) {
applySecretRequestSettled(environmentId, message)
From a5859bdfdcefb3755788ff78190a4ec888c23c76 Mon Sep 17 00:00:00 2001
From: Neil
Date: Tue, 10 Nov 2026 06:07:25 -0800
Subject: [PATCH 3/6] fix(cloud): W6 UI review round 2: replay pruning on
ready, Dismiss focus, F6 scope, split-group visibility, lint
---
config/recurring-timer-baseline.txt | 1 +
.../cloud-sections/CloudMissingSecretRow.tsx | 5 +-
.../cloud-sections/CloudPanelSections.tsx | 41 ++++++------
.../use-cloud-panel-sections.ts | 4 +-
.../agent-pane/SecretRequestCard.tsx | 9 ++-
.../agent-pane/SecretRequestCardParts.tsx | 23 ++++---
.../agent-pane/SecretRequestPanePortals.tsx | 5 +-
.../SecretRequestUnseenNotifier.tsx | 44 +++++++++++--
.../agent-pane/secret-request-focus.ts | 5 +-
.../agent-pane/use-secret-request-card.ts | 9 +--
.../cloud-secrets-ask/CloudSecretsAskForm.tsx | 14 ++--
.../use-cloud-secrets-ask.ts | 7 +-
.../src/i18n/en-runtime-required.json | 12 ++++
src/renderer/src/i18n/locales/en.json | 14 ++--
.../secret-request-store.test.ts | 30 ++++-----
.../secret-requests/secret-request-store.ts | 65 +++++++------------
.../subscriptions/runtime-client-events.ts | 6 +-
17 files changed, 157 insertions(+), 137 deletions(-)
diff --git a/config/recurring-timer-baseline.txt b/config/recurring-timer-baseline.txt
index 89443a4430d..cea5d3b42c5 100644
--- a/config/recurring-timer-baseline.txt
+++ b/config/recurring-timer-baseline.txt
@@ -73,6 +73,7 @@
1 src/main/wsl/host-lifecycle/wsl-hosts-policy-live-update.ts
1 src/main/wsl/orcad/wsl-orcad-connect-stop-fence.ts
1 src/renderer/src/app-shell/use-app-session-persistence.ts
+1 src/renderer/src/components/agent-pane/use-secret-request-card.ts
1 src/renderer/src/components/browser-pane/browser-client-page-position-driver.ts
1 src/renderer/src/components/contextual-tours/use-contextual-tour.ts
1 src/renderer/src/components/editor/setup-contextual-copy.ts
diff --git a/src/renderer/src/components/agent-host-panel/cloud-sections/CloudMissingSecretRow.tsx b/src/renderer/src/components/agent-host-panel/cloud-sections/CloudMissingSecretRow.tsx
index a54fe2134db..300e8105d3b 100644
--- a/src/renderer/src/components/agent-host-panel/cloud-sections/CloudMissingSecretRow.tsx
+++ b/src/renderer/src/components/agent-host-panel/cloud-sections/CloudMissingSecretRow.tsx
@@ -91,7 +91,7 @@ export function CloudMissingSecretRow({
{expanded ? null : !canAdd ? (
{data.sharing?.ownerName
- ? t('agentHostPanel.env.askOwner', 'Ask {{owner}} or an admin to add it.', {
+ ? t('agentHostPanel.env.askOwner', 'Ask {{owner}}, an owner or an admin to add it.', {
owner: data.sharing.ownerName
})
: t('agentHostPanel.keys.askAdmin', 'Ask an owner or admin to add one.')}
@@ -149,7 +149,7 @@ export function CloudMissingSecretRow({
>
@@ -203,7 +203,6 @@ export function CloudMissingSecretRow({
aria-disabled={!value || saving || undefined}
aria-busy={saving || undefined}
onClick={value && !saving ? () => void save() : undefined}
- className="aria-disabled:opacity-50"
>
{saving ? : null}
{t('agentHostPanel.env.save', 'Save')}
diff --git a/src/renderer/src/components/agent-host-panel/cloud-sections/CloudPanelSections.tsx b/src/renderer/src/components/agent-host-panel/cloud-sections/CloudPanelSections.tsx
index 0e156998c8b..eefbedf9b1d 100644
--- a/src/renderer/src/components/agent-host-panel/cloud-sections/CloudPanelSections.tsx
+++ b/src/renderer/src/components/agent-host-panel/cloud-sections/CloudPanelSections.tsx
@@ -44,7 +44,11 @@ function ownerLabel(owner: CloudKeyOwner, t: Translate): string {
return 'AWS'
case 'other':
return t('agentHostPanel.keys.other', 'Other')
- default:
+ case 'claude':
+ case 'codex':
+ case 'gemini':
+ case 'cursor':
+ case 'copilot':
return OWNER_LABEL[owner]
}
}
@@ -226,12 +230,15 @@ export function CloudEnvironmentPanelSection({
onRetry,
onAdd,
focusSecret,
- initialExpanded = null
+ initialExpanded = null,
+ showLoadError = true
}: {
load: CloudPanelSectionsLoad
number?: React.ReactNode
onRetry: () => void
onAdd: CloudPanelAddSecret
+ /** Off when section 2 already shows the shared read's error. */
+ showLoadError?: boolean
/** Deep link `{ secret: NAME }`: that missing row opens with its field focused. */
focusSecret?: string
/** Fixtures only: a row shown already expanded. */
@@ -276,12 +283,14 @@ export function CloudEnvironmentPanelSection({
{load.status === 'loading' ? (
) : load.status === 'failed' ? (
-
+ showLoadError ? (
+
+ ) : null
) : rows.length === 0 ? (
{t('agentHostPanel.env.empty', 'No variables from Cloud secrets yet.')}
@@ -356,20 +365,6 @@ export function CloudPanelSections({
initialExpanded?: string | null
className?: string
}): React.JSX.Element {
- const t = useTranslate()
- if (load.status === 'failed') {
- // One read feeds both sections, so one error (and one announcement) covers them.
- return (
-
-
-
- )
- }
return (
)
diff --git a/src/renderer/src/components/agent-host-panel/cloud-sections/use-cloud-panel-sections.ts b/src/renderer/src/components/agent-host-panel/cloud-sections/use-cloud-panel-sections.ts
index e68650685a6..788a52a1a62 100644
--- a/src/renderer/src/components/agent-host-panel/cloud-sections/use-cloud-panel-sections.ts
+++ b/src/renderer/src/components/agent-host-panel/cloud-sections/use-cloud-panel-sections.ts
@@ -48,6 +48,8 @@ export function cloudPanelSectionsData(args: {
}
}
+const NOT_CONFIGURED: CloudSecretsErrorCode = 'not_configured'
+
export type CloudPanelSectionsLoad =
| { status: 'loading' }
| { status: 'failed'; error: CloudSecretsErrorCode }
@@ -73,7 +75,7 @@ export function useCloudPanelSections(args: {
useEffect(() => {
const secrets = window.api.cloudSecrets
if (!secrets) {
- setLoad({ status: 'failed', error: 'not_configured' })
+ setLoad({ status: 'failed', error: NOT_CONFIGURED })
return
}
let cancelled = false
diff --git a/src/renderer/src/components/agent-pane/SecretRequestCard.tsx b/src/renderer/src/components/agent-pane/SecretRequestCard.tsx
index c796da92242..3a89174ca21 100644
--- a/src/renderer/src/components/agent-pane/SecretRequestCard.tsx
+++ b/src/renderer/src/components/agent-pane/SecretRequestCard.tsx
@@ -92,12 +92,12 @@ export function SecretRequestCard({
const ownRef = React.useRef(null)
const focusedWithin = React.useRef(false)
- // When the form gives way to the result, focus that was in it lands on the result's first action
- // instead of falling to the page.
+ // When the form gives way to the result, focus that was in it lands on Dismiss (never Restart, so a
+ // second Enter can't relaunch the agent) instead of falling to the page.
React.useLayoutEffect(() => {
const root = ownRef.current
if (done && focusedWithin.current && root && !root.contains(document.activeElement)) {
- root.querySelector('button')?.focus()
+ root.querySelector('[data-secret-request-dismiss]')?.focus()
}
}, [done])
@@ -294,7 +294,7 @@ export function SecretRequestCard({
type="button"
variant="ghost"
size="xs"
- className="ms-auto aria-disabled:opacity-50"
+ className="ms-auto"
aria-disabled={saving || undefined}
onClick={saving ? undefined : actions.onNotNow}
>
@@ -306,7 +306,6 @@ export function SecretRequestCard({
aria-disabled={!canSave || undefined}
aria-busy={saving || undefined}
onClick={canSave ? actions.onAdd : undefined}
- className="aria-disabled:opacity-50"
>
{saving ? : null}
{t('secretRequest.add', 'Add secret')}
diff --git a/src/renderer/src/components/agent-pane/SecretRequestCardParts.tsx b/src/renderer/src/components/agent-pane/SecretRequestCardParts.tsx
index 02847332094..adbe3219780 100644
--- a/src/renderer/src/components/agent-pane/SecretRequestCardParts.tsx
+++ b/src/renderer/src/components/agent-pane/SecretRequestCardParts.tsx
@@ -27,14 +27,15 @@ export function SecretRequestTitle({
const text = agent
? t('secretRequest.title', '{{agent}} asked for {{name}}', { agent, name: NAME_SLOT })
: t('secretRequest.titleUnknown', 'An agent asked for {{name}}', { name: NAME_SLOT })
+ const at = text.indexOf(NAME_SLOT)
+ if (at === -1) {
+ return <>{text}>
+ }
return (
<>
- {text.split(NAME_SLOT).map((part, index) => (
-
- {index > 0 ? {name} : null}
- {part}
-
- ))}
+ {text.slice(0, at)}
+ {name}
+ {text.slice(at + NAME_SLOT.length)}
>
)
}
@@ -119,7 +120,7 @@ export function SecretRequestScopeControl({
>
@@ -200,7 +201,13 @@ export function SecretRequestDone({
{t('secretRequest.restart', 'Restart {{agent}}', { agent: agentName })}
) : null}
-
+
{t('secretRequest.dismiss', 'Dismiss')}
diff --git a/src/renderer/src/components/agent-pane/SecretRequestPanePortals.tsx b/src/renderer/src/components/agent-pane/SecretRequestPanePortals.tsx
index 47a55033c9f..cc38a0a6d07 100644
--- a/src/renderer/src/components/agent-pane/SecretRequestPanePortals.tsx
+++ b/src/renderer/src/components/agent-pane/SecretRequestPanePortals.tsx
@@ -155,7 +155,7 @@ export function SecretRequestPanePortals({
}: {
controller: TerminalPaneController
}): React.JSX.Element {
- const { managedPanes, tabId, activePane, isActive, worktreeId } = controller
+ const { managedPanes, tabId, activePane, isActive, isRendererVisible, worktreeId } = controller
return (
<>
{managedPanes.map((pane) => (
@@ -164,7 +164,8 @@ export function SecretRequestPanePortals({
pane={pane}
tabId={tabId}
worktreeId={worktreeId}
- visible={isActive}
+ // On screen, even in a split group that isn't the active one.
+ visible={isRendererVisible}
primary={isActive && activePane?.id === pane.id}
/>
))}
diff --git a/src/renderer/src/components/agent-pane/SecretRequestUnseenNotifier.tsx b/src/renderer/src/components/agent-pane/SecretRequestUnseenNotifier.tsx
index f7c16d19bd7..e2d344005e0 100644
--- a/src/renderer/src/components/agent-pane/SecretRequestUnseenNotifier.tsx
+++ b/src/renderer/src/components/agent-pane/SecretRequestUnseenNotifier.tsx
@@ -1,7 +1,13 @@
import { useEffect } from 'react'
import { toast } from 'sonner'
import { useTranslate } from '@/i18n/use-translate'
-import { useSecretRequests } from '@/lib/secret-requests/secret-request-store'
+import { useAppStore } from '@/store'
+import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields'
+import { activateAndRevealWorktree } from '@/lib/workspace/activation/worktree-activation'
+import {
+ useSecretRequests,
+ type SecretRequestEntry
+} from '@/lib/secret-requests/secret-request-store'
import {
secretRequestAgentName,
unseenSecretRequests
@@ -13,9 +19,18 @@ import { useSecretRequestSlots } from './secret-request-focus'
const SETTLE_MS = 2_000
const told = new Set
()
+/** The workspace on this computer that the asking host's request belongs to. */
+function workspaceFor(entry: SecretRequestEntry): { id: string; name: string } | null {
+ const all = Object.values(selectWorktreesByRepo(useAppStore.getState()) ?? {}).flat()
+ const match =
+ all.find((worktree) => worktree.id === entry.event.worktreeId) ??
+ all.find((worktree) => worktree.runtimeOwnerEnvironmentId === entry.environmentId)
+ return match ? { id: match.id, name: match.displayName } : null
+}
+
/**
- * A request whose workspace isn't on screen would otherwise wait unseen until it expires: say so once,
- * and the card is there when the person opens that workspace.
+ * A request whose workspace isn't on screen would otherwise wait unseen until it expires: say so
+ * once, name the workspace, and open it on click.
*/
export function SecretRequestUnseenNotifier(): null {
const t = useTranslate()
@@ -31,13 +46,28 @@ export function SecretRequestUnseenNotifier(): null {
const timer = window.setTimeout(() => {
for (const entry of unseen) {
told.add(`${entry.environmentId}:${entry.event.requestId}`)
+ const workspace = workspaceFor(entry)
toast.info(
secretRequestTitle(secretRequestAgentName(entry.event.agent), entry.event.name, t),
{
- description: t(
- 'secretRequest.unseen',
- 'Open that workspace to answer. The request waits up to 15 minutes.'
- )
+ description: workspace
+ ? t(
+ 'secretRequest.unseenIn',
+ 'In {{workspace}}. The request waits up to 15 minutes.',
+ {
+ workspace: workspace.name
+ }
+ )
+ : t(
+ 'secretRequest.unseen',
+ 'Open that workspace to answer. The request waits up to 15 minutes.'
+ ),
+ action: workspace
+ ? {
+ label: t('secretRequest.openWorkspace', 'Open'),
+ onClick: () => activateAndRevealWorktree(workspace.id)
+ }
+ : undefined
}
)
}
diff --git a/src/renderer/src/components/agent-pane/secret-request-focus.ts b/src/renderer/src/components/agent-pane/secret-request-focus.ts
index f37b8e28c2a..8c61dca0ae9 100644
--- a/src/renderer/src/components/agent-pane/secret-request-focus.ts
+++ b/src/renderer/src/components/agent-pane/secret-request-focus.ts
@@ -86,7 +86,10 @@ function onKeyDown(event: KeyboardEvent): void {
const target =
inside ??
all.find((record) => active !== null && record.container.contains(active)) ??
- all.find((record) => record.primary)
+ // From the terminal or nowhere only: inside Settings or a modal, F6 belongs to that surface.
+ (active === null || active === document.body || active.closest('.xterm') !== null
+ ? all.find((record) => record.primary)
+ : undefined)
if (!target) {
return
}
diff --git a/src/renderer/src/components/agent-pane/use-secret-request-card.ts b/src/renderer/src/components/agent-pane/use-secret-request-card.ts
index 7127d63294f..474d3d44f4b 100644
--- a/src/renderer/src/components/agent-pane/use-secret-request-card.ts
+++ b/src/renderer/src/components/agent-pane/use-secret-request-card.ts
@@ -158,12 +158,6 @@ export function useSecretRequestCard(args: {
return () => window.clearTimeout(timer)
}, [environmentId, requestId, event.expiresAt, local])
- useEffect(() => {
- if (local === 'expired') {
- setValue('')
- }
- }, [local])
-
// D3 hold: touch now (if a minute has passed) and every minute while focused or holding a value.
const holding = (focused || value.length > 0) && local === null
useEffect(() => {
@@ -264,7 +258,8 @@ export function useSecretRequestCard(args: {
suggestedRepo: event.scope === 'repo',
scopes,
scope: effectiveScope,
- value,
+ // An expired request shows no field, and what was typed doesn't come back.
+ value: local === 'expired' ? '' : value,
phase,
fieldError,
more,
diff --git a/src/renderer/src/components/cloud-secrets-ask/CloudSecretsAskForm.tsx b/src/renderer/src/components/cloud-secrets-ask/CloudSecretsAskForm.tsx
index 500c60feae6..3eb672cbb96 100644
--- a/src/renderer/src/components/cloud-secrets-ask/CloudSecretsAskForm.tsx
+++ b/src/renderer/src/components/cloud-secrets-ask/CloudSecretsAskForm.tsx
@@ -4,7 +4,6 @@ import { useTranslate, type Translate } from '@/i18n/use-translate'
import { getIntlLocale } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import { InlineError } from '@/components/ui/inline-error'
-import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
@@ -215,14 +214,14 @@ export function CloudSecretsAskForm({
) : null}
{state.pasteOpen ? (
-
+
{t('cloudSecretsAsk.pasteLabel', 'Paste the contents of a .env file')}
-
+