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}> -