From a7eb4645880fbd6859c0b12ab69d221945bc7923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 4 Oct 2026 23:11:03 +0200 Subject: [PATCH] feat(chat): preview external requests with prominent conversation origin Retain the convenience of answering questions and permissions across conversations while making ownership explicit. External and sessionless project requests start compact and require deliberate expansion. Expanded requests show the source name prominently, distinguish subagents and their parent conversation, and offer explicit source navigation without moving the user when a request arrives or is answered. Extract keyed dock selection state, retain hidden drafts, keep the current request stable during arrivals, and leave foreign settlement fallbacks compact. Use one bounded external preview beside a local request. The red project badge expands the selected request in place, including recovery from the Info surface. Add localized provenance labels in all ten locales and preserve short-keyboard footer reachability. Validate 71 affected browser checks plus six targeted Info-badge regressions, UI TypeScript and diff checks. Inspect desktop and Android-touch compact, expanded, concurrent and short-keyboard captures. This implements the explicitly approved UX revision to PR847; it does not claim to fix the separately reported mobile reconnect behavior. --- AGENTS.md | 2 +- dev-docs/NATIVE_INTERRUPTION_UX.md | 33 +-- .../components/instance/instance-shell2.tsx | 21 +- .../src/components/interruption-dock-state.ts | 34 +++ .../ui/src/components/interruption-dock.tsx | 98 ++++---- .../ui/src/lib/i18n/messages/de/messaging.ts | 6 + .../ui/src/lib/i18n/messages/en/messaging.ts | 6 + .../ui/src/lib/i18n/messages/es/messaging.ts | 6 + .../ui/src/lib/i18n/messages/fr/messaging.ts | 6 + .../ui/src/lib/i18n/messages/he/messaging.ts | 6 + .../ui/src/lib/i18n/messages/ja/messaging.ts | 6 + .../ui/src/lib/i18n/messages/ne/messaging.ts | 6 + .../ui/src/lib/i18n/messages/ru/messaging.ts | 6 + .../ui/src/lib/i18n/messages/tr/messaging.ts | 6 + .../lib/i18n/messages/zh-Hans/messaging.ts | 6 + packages/ui/src/stores/interruption-scope.ts | 5 +- .../styles/components/interruption-dock.css | 15 +- .../tests/browser/fixtures/header-windows.tsx | 6 + .../tests/browser/interruption-dock.test.ts | 12 +- .../browser/interruption-selection.test.ts | 10 +- .../tests/browser/mobile-interruption.test.ts | 230 +++++++++++++++--- 21 files changed, 398 insertions(+), 128 deletions(-) create mode 100644 packages/ui/src/components/interruption-dock-state.ts diff --git a/AGENTS.md b/AGENTS.md index 96230d1c7..1695f58cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +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. +- Requests from the open conversation and its recursive descendants can expand normally. Other conversations first appear as a compact, explicitly expandable preview; answering stays in place. Keep persistent textual provenance prominent (other conversation, subagent with parent, or sessionless project request), with an explicit View conversation action for session-owned requests. Arrival never replaces the request being edited or navigates sessions. Newly selected external requests after settlement remain compact until explicitly opened. Hidden/inert keyed editors retain drafts, and only an expanded editor compacts the composer. Project badges open the dock in place rather than silently navigating to another conversation. - 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 40c91fb01..0a8197f32 100644 --- a/dev-docs/NATIVE_INTERRUPTION_UX.md +++ b/dev-docs/NATIVE_INTERRUPTION_UX.md @@ -23,24 +23,27 @@ instead of clipping controls; field scrolling can chain into that outer scroller - Native pending queues remain authoritative. No transcript scan decides whether 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 +- The open conversation and its recursive descendants are the local context. + Requests from elsewhere remain answerable in place, but first appear as compact + previews labeled “Other conversation” with their source conversation prominently + named. Explicit expansion reveals the editor without navigating away. An arrival + never replaces the request being edited, and settlement does not automatically + expand another external request. Hidden/inert keyed editors preserve drafts. +- Provenance remains visible when expanded: external conversation, subagent with + parent conversation, or sessionless project request. The label and strong title + carry the distinction independently of color. A “View conversation” action + explicitly navigates to a session-owned request's actual source. +- Global Forms have no owning conversation. Their compact preview uses a distinct + project-request label, supports in-place expansion and never invents a source link. +- The instance badge opens the dock in place. 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. + Compact external previews leave the composer height untouched; only an expanded + editor temporarily compacts it. 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. @@ -126,6 +129,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. +It also covers local and recursive-subagent provenance, compact external previews, +explicit in-place expansion, retained hidden drafts, explicit source 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 db1fd15ea..7ed6c86ee 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -27,7 +27,6 @@ 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" @@ -170,7 +169,8 @@ const InstanceShell2: Component = (props) => { const [interruptionExpanded, setInterruptionExpanded] = createSignal(false) const interruptionViewport = useInterruptionViewport(() => sessionCenterEl() ?? undefined, interruptionExpanded) const interruptionPanel = + sessionId={activeSessionIdForInstance()} active={props.isActiveInstance} onExpandedChange={setInterruptionExpanded} + onViewConversation={handleSessionSelect} /> const desktopQuery = useMediaQuery("(min-width: 1280px)") @@ -1041,19 +1041,10 @@ const InstanceShell2: Component = (props) => { } 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) + // Info has no dock mount. Restore the ordinary conversation surface first, + // without treating the pending request's owner as a navigation destination. + if (showingInfoView()) handleBackToConversation() + focusInterruption(props.instance.id) } const sessionLayout = (
()) + const pending = createMemo(() => getInterruptionQueue(props.instanceId)) + const scope = createMemo(() => getInterruptionScope(sessions().get(props.instanceId), props.sessionId)) + const external = (item: ReturnType[number]) => item.payload.sessionID === "global" || !scope().has(item.payload.sessionID) + const selection = () => selections().get(props.sessionId) + const current = createMemo(() => pending().find(item => item.key === selection()?.key) + ?? pending().find(item => item.payload.sessionID === props.sessionId) + ?? pending().find(item => !external(item)) ?? pending()[0]) + const expanded = createMemo(() => { + const item = current() + return Boolean(item && (selection()?.key === item.key ? selection()!.expanded : !external(item))) + }) + const select = (key: string, open: boolean) => setSelections(previous => new Map(previous).set(props.sessionId, { key, expanded: open })) + // Pin even an automatic selection. Queue reordering/arrivals must not replace + // the editor; each automatic foreign fallback starts closed, even if an older + // request from that conversation was deliberately opened. + createEffect(on([() => props.sessionId, () => current()?.key], () => { + const item = current() + if (item && selection()?.key !== item.key) select(item.key, !external(item)) + })) + const index = () => pending().findIndex(item => item.key === current()?.key) + const move = (delta: number) => { + const next = pending()[index() + delta] + if (next) select(next.key, !external(next)) + } + const previews = createMemo(() => current() && !external(current()!) ? pending().filter(external) : []) + return { pending, current, expanded, select, index, move, previews, scope } +} diff --git a/packages/ui/src/components/interruption-dock.tsx b/packages/ui/src/components/interruption-dock.tsx index 80c5a0aa3..e22e95248 100644 --- a/packages/ui/src/components/interruption-dock.tsx +++ b/packages/ui/src/components/interruption-dock.tsx @@ -1,92 +1,94 @@ -import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, untrack } from "solid-js" +import { For, Show, createEffect, createMemo, on, onCleanup, untrack } from "solid-js" import { ChevronLeft, ChevronRight, ChevronDown, ChevronUp, MessageCircleQuestion, ShieldCheck } from "lucide-solid" import { useI18n } from "../lib/i18n" import { getPermissionQueue, sendFormCancel, sendFormReply } from "../stores/instances" import { getFormQueue } from "../stores/forms" import { sessions } from "../stores/sessions" -import { interruptionFocus } from "../stores/interruption-navigation" -import { getInterruptionQueue, getInterruptionScope } from "../stores/interruption-scope" +import { focusInterruption, interruptionFocus } from "../stores/interruption-navigation" import FormRequest from "./form-request" import { InterruptionPermission } from "./interruption-permission" import { useInterruptionMinimumHeight } from "./interruption-dock-layout" +import { useInterruptionDockState } from "./interruption-dock-state" -export function InterruptionDock(props: { instanceId: string; sessionId?: string | null; active?: boolean; onExpandedChange?: (expanded: boolean) => void }) { +export function InterruptionDock(props: { instanceId: string; sessionId?: string | null; active?: boolean; onExpandedChange?: (expanded: boolean) => void; onViewConversation?: (sessionId: string) => void }) { const { t } = useI18n() - const [selections, setSelections] = createSignal(new Map()) - const select = (key: string) => setSelections(previous => new Map(previous).set(props.sessionId, key)) - const [collapsed, setCollapsed] = createSignal(false) + const { pending, current, expanded, select, index, move, previews, scope } = useInterruptionDockState(props) let root: HTMLElement | undefined - 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())) + createEffect(() => props.onExpandedChange?.(expanded())) onCleanup(() => props.onExpandedChange?.(false)) - 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 minimumHeight = useInterruptionMinimumHeight(() => root, () => expanded() ? current()?.key : undefined) 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, () => { - setCollapsed(false) - })) - createEffect(on([() => props.sessionId, currentKey], ([, key]) => { - // Pin the request being edited, even when a permission arrives ahead of it. - if (key) select(key) - setCollapsed(false) - })) + const sourceId = () => current()?.payload.sessionID ?? "" + const own = () => sourceId() === props.sessionId + const project = () => sourceId() === "global" + const descendant = () => !own() && !project() && scope().has(sourceId()) + const origin = (sessionId: string) => sessionId === "global" ? t("interruption.projectRequest") + : `${t(scope().has(sessionId) ? "interruption.subagent" : "interruption.otherConversation")} · ${title(sessionId)}` + const heading = () => own() ? t(current()?.kind === "permission" ? "interruption.permission" : "interruption.question") : origin(sourceId()) + const parentTitle = () => title(sessions().get(props.instanceId)?.get(sourceId())?.parentId ?? props.sessionId ?? "") + const canView = () => !own() && !project() && props.onViewConversation && sessions().get(props.instanceId)?.has(sourceId()) + const openLabel = (sessionId: string) => t("interruption.openExternal", { title: sessionId === "global" ? t("interruption.projectRequest") : title(sessionId) }) createEffect(on(interruptionFocus, intent => { if (intent?.instanceId !== props.instanceId) return - const item = untrack(queue).find(item => (!intent.kind || item.kind === intent.kind) + const item = untrack(pending).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 - select(item.key) - setCollapsed(false) + select(item.key, 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 - select(next.key) - setCollapsed(false) - } return 0}> -