From 4e4abc0c71ac98e3ad33634ce2352e3f309fa61d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 4 Oct 2026 21:37:47 +0200 Subject: [PATCH] fix(chat): scope interruptions to the conversation and its subagents Show pending questions and permissions only for the open session and its recursive descendants, as requested. Exclude parents, siblings and unrelated conversations from panel navigation and composer compaction. Keep off-scope editors mounted, hidden and inert so partial answers and rejection reasons survive navigation, and remember the selected request per conversation. Make the project badge explicitly navigate to a pending request's owner when none is in scope. Sessionless global Forms remain reachable on the no-session surface through that badge. Share hierarchy projection, retain native reply identities, and reject stale or out-of-scope focus intents without falling back to another request. Extend real-shell desktop and Android-touch coverage for family scope, late ancestry, stable selection, retained form and permission drafts, explicit badge navigation and global requests. Interruption, selection and mobile regressions plus the shell badge check and UI TypeScript pass. Document the new scope contract. --- AGENTS.md | 1 + dev-docs/NATIVE_INTERRUPTION_UX.md | 19 +- .../components/instance/instance-shell2.tsx | 19 +- .../ui/src/components/interruption-dock.tsx | 45 +++-- .../ui/src/stores/interruption-navigation.ts | 6 +- packages/ui/src/stores/interruption-scope.ts | 19 ++ .../styles/components/interruption-dock.css | 8 +- .../tests/browser/fixtures/header-windows.tsx | 15 +- .../browser/fixtures/interruption-dock.tsx | 6 +- .../tests/browser/interruption-dock.test.ts | 11 +- .../browser/interruption-selection.test.ts | 23 ++- .../tests/browser/mobile-interruption.test.ts | 170 +++++++++++++++++- 12 files changed, 285 insertions(+), 57 deletions(-) create mode 100644 packages/ui/src/stores/interruption-scope.ts 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}>