diff --git a/AGENTS.md b/AGENTS.md index 676c650e8..96230d1c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ - Persistent command/search utility windows use `components/dismissible-window.tsx`: non-modal, no scrim, outside interactions keep them open, and explicit toggles use `.icon-toggle` with `aria-expanded`/`aria-controls`. Keep search state scoped to its instance/session and close it when that view becomes inactive. - The composer reserves `/btw` for native `session.generate`, outside ordinary prompt/command submission. Its ephemeral question/answer window uses `DismissibleWindow` and `styles/components/session-aside.css`; cancellation and inactive/session transitions fence late results without interrupting the main session. - Native questions and permissions are answered in the shell-owned `InterruptionDock`, slotted above the composer. Pending questions appear only in the dock; the transcript retains native completed question results. Badges target the dock via `interruptionFocus`; omit transcript-source and answer-in-dock links. Keep selection stable by request kind/id across queue refreshes, with bounded previous/next navigation hidden for a single request. Preserve request drafts across refreshes and session navigation. Keep its square, height-bounded chrome in `styles/components/interruption-dock.css`, with a token-based accent header and fixed action footer outside the scrolling request content. See `dev-docs/NATIVE_INTERRUPTION_UX.md`. +- Scope visible interruption requests to the open conversation and its recursive descendants, never its parents, siblings or unrelated conversations. Keep off-scope editors hidden and inert while requests remain pending so navigation preserves drafts. Project-wide badges may explicitly navigate to a request's owner; arrival alone never switches conversations. Sessionless global Forms remain reachable explicitly through the project badge on the no-session surface. - Durable permission decisions render through `components/permission-receipts.tsx` outside native tool visibility, with bounded mounted-row reads and a paginated session disclosure for unanchored receipts. Keep receipts out of native message/copy/search/speech projections; styles live in `styles/messaging/permission-receipts.css`. - An expanded interruption temporarily reduces the composer to its minimum without changing its saved height or draft; resizing resumes after collapse/settlement. `instance/shell/useInterruptionViewport.ts` bounds the request/composer stack to the visual viewport during keyboard resize/pan. Keep the dock non-shrinking and its footer reachable on short mobile layouts. - Completed question receipts share native answer decoding with text projection through `tool-call/renderers/question-data.ts`. Preserve selected-option descriptions and verbatim free text; keep remaining choices in a native disclosure. Receipt styles live in `styles/messaging/tool-call/question.css`. diff --git a/dev-docs/NATIVE_INTERRUPTION_UX.md b/dev-docs/NATIVE_INTERRUPTION_UX.md index 7c945493d..40c91fb01 100644 --- a/dev-docs/NATIVE_INTERRUPTION_UX.md +++ b/dev-docs/NATIVE_INTERRUPTION_UX.md @@ -22,15 +22,25 @@ instead of clipping controls; field scrolling can chain into that outer scroller ## Ownership and navigation - Native pending queues remain authoritative. No transcript scan decides whether - a request can be answered. Global Forms and requests without a tool source work - in the same panel. -- The instance badge opens the panel; session-row selection with pending requests + a request can be answered. Requests without a tool source use the same panel. +- The visible queue includes only the open conversation and its recursive + descendants. Parents, siblings and unrelated roots do not appear. Request + arrival never navigates away from the active conversation. Off-scope editors + remain hidden and inert while pending, preserving drafts when navigating back. +- Global Forms have no owning conversation. They remain explicitly reachable + through the project badge on the no-session surface, rather than appearing + inside unrelated conversations. +- The instance badge opens an in-scope request or explicitly navigates to a pending + request's owning conversation when none is in scope. Global Forms take priority + on this project-wide badge because they have no session row of their own. + Session-row selection with pending requests targets that session's request through the `interruptionFocus` UI intent. - The panel shows the request kind and source session. Previous/next controls and the position/count appear only when multiple requests are pending. Navigation is bounded: the first/last request disables the corresponding arrow without wrapping. - Selection stays pinned to the current request kind/id through queue refreshes and newly arriving requests, including a permission inserted ahead of a question. + Position, navigation and temporary composer compaction use the visible scope. Explicit navigation or settlement can change the selection. Editors stay keyed by request kind/id, preserving partial answers and rejection reasons through native object replacement, queue navigation and session navigation. @@ -116,3 +126,6 @@ Android touch emulation, session navigation, saved maximum composer height, portrait/landscape/short layouts, simulated keyboard resize/pan, global/background Forms and permission actions. Width-only component captures do not replace this shell-level geometry regression. +It also covers conversation scope and recursive descendants, excluding parents, +siblings and unrelated conversations, retaining hidden drafts, explicit project +badge navigation, global Forms and late-arriving ancestry on desktop and mobile. diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 0c0e9ee83..db1fd15ea 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -27,6 +27,7 @@ import CommandPalette from "../command-palette" import PermissionNotificationBanner from "../permission-notification-banner" import { InterruptionDock } from "../interruption-dock" import { focusInterruption } from "../../stores/interruption-navigation" +import { getInterruptionQueue, getInterruptionScope } from "../../stores/interruption-scope" import SessionView from "../session/session-view" import MessageSection from "../message-section" import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar" @@ -584,7 +585,7 @@ const InstanceShell2: Component = (props) => { { handleBackToConversation(); focusInterruption(props.instance.id) }} + onClick={handleProjectInterruptionClick} /> {renderYoloModePill()} @@ -1038,6 +1039,22 @@ const InstanceShell2: Component = (props) => { if (sessionId === "info" && showingInfoView()) handleBackToConversation() else handleSessionSelect(sessionId) } + + function handleProjectInterruptionClick() { + const pending = getInterruptionQueue(props.instance.id) + // Global Forms have no row of their own: the project badge is their explicit entry point. + const global = pending.find(item => item.kind === "form" && item.payload.sessionID === "global") + const scope = getInterruptionScope(allInstanceSessions(), activeSessionIdForInstance()) + if (!global && pending.some(item => scope.has(item.payload.sessionID))) { + focusInterruption(props.instance.id) + return + } + const target = global ?? pending.find(item => allInstanceSessions().has(item.payload.sessionID)) + if (!target) return + if (target.payload.sessionID === "global") clearActiveParentSession(props.instance.id) + else handleSessionSelect(target.payload.sessionID) + focusInterruption(props.instance.id, target.payload.sessionID, target.payload.id, target.kind) + } const sessionLayout = (
void }) { const { t } = useI18n() - const [selected, setSelected] = createSignal() + const [selections, setSelections] = createSignal(new Map()) + const select = (key: string) => setSelections(previous => new Map(previous).set(props.sessionId, key)) const [collapsed, setCollapsed] = createSignal(false) let root: HTMLElement | undefined - const queue = createMemo(() => [ - ...getPermissionQueue(props.instanceId).map(payload => ({ key: `permission:${payload.id}`, kind: "permission" as const, payload })), - ...getFormQueue(props.instanceId).map(payload => ({ key: `form:${payload.id}`, kind: "form" as const, payload })), - ]) - const byKey = createMemo(() => new Map(queue().map(item => [item.key, item]))) + const pending = createMemo(() => getInterruptionQueue(props.instanceId)) + const scope = createMemo(() => getInterruptionScope(sessions().get(props.instanceId), props.sessionId)) + const queue = createMemo(() => pending().filter(item => scope().has(item.payload.sessionID))) + const byKey = createMemo(() => new Map(pending().map(item => [item.key, item]))) createEffect(() => props.onExpandedChange?.(queue().length > 0 && !collapsed())) onCleanup(() => props.onExpandedChange?.(false)) - const current = createMemo(() => byKey().get(selected() ?? "") ?? queue().find(item => item.payload.sessionID === props.sessionId) ?? queue()[0]) + const current = createMemo(() => queue().find(item => item.key === selections().get(props.sessionId)) + ?? queue().find(item => item.payload.sessionID === props.sessionId) ?? queue()[0]) + const currentKey = createMemo(() => current()?.key) const minimumHeight = useInterruptionMinimumHeight(() => root, () => collapsed() ? undefined : current()?.key) const index = () => queue().findIndex(item => item.key === current()?.key) const title = (sessionId: string) => sessions().get(props.instanceId)?.get(sessionId)?.title || sessionId const heading = () => t(current()?.kind === "permission" ? "interruption.permission" : "interruption.question") const sessionTitle = () => current()?.payload.sessionID === "global" ? t("interruption.global") : title(current()?.payload.sessionID ?? "") createEffect(on(() => props.sessionId, () => { - setSelected(untrack(queue).find(item => item.payload.sessionID === props.sessionId)?.key ?? untrack(current)?.key) setCollapsed(false) })) - createEffect(on(() => current()?.key, key => { + createEffect(on([() => props.sessionId, currentKey], ([, key]) => { // Pin the request being edited, even when a permission arrives ahead of it. - setSelected(key) + if (key) select(key) setCollapsed(false) })) createEffect(on(interruptionFocus, intent => { if (intent?.instanceId !== props.instanceId) return - const item = untrack(queue).find(item => intent.requestId ? item.payload.id === intent.requestId - : intent.sessionId ? item.payload.sessionID === intent.sessionId : item.key === untrack(current)?.key) ?? untrack(queue)[0] + const item = untrack(queue).find(item => (!intent.kind || item.kind === intent.kind) + && (!intent.sessionId || item.payload.sessionID === intent.sessionId) + && (intent.requestId ? item.payload.id === intent.requestId + : intent.sessionId ? true : item.key === untrack(current)?.key)) if (!item) return - setSelected(item.key) + select(item.key) setCollapsed(false) - queueMicrotask(() => { if (props.active !== false && root?.isConnected) root.focus({ preventScroll: true }) }) + const sessionId = props.sessionId + queueMicrotask(() => { + if (interruptionFocus() === intent && props.sessionId === sessionId && current()?.key === item.key + && props.active !== false && root?.isConnected && !root.hidden) root.focus({ preventScroll: true }) + }) })) const move = (delta: number) => { const next = queue()[index() + delta] if (!next) return - setSelected(next.key) + select(next.key) setCollapsed(false) } - return 0}> + return 0}>