Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
19 changes: 16 additions & 3 deletions dev-docs/NATIVE_INTERRUPTION_UX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
19 changes: 18 additions & 1 deletion packages/ui/src/components/instance/instance-shell2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -584,7 +585,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => { handleBackToConversation(); focusInterruption(props.instance.id) }}
onClick={handleProjectInterruptionClick}
/>
</Show>
{renderYoloModePill()}
Expand Down Expand Up @@ -1038,6 +1039,22 @@ const InstanceShell2: Component<InstanceShellProps> = (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 = (
<div
class="session-shell-panels relative flex flex-1 min-h-0 overflow-x-hidden"
Expand Down
45 changes: 27 additions & 18 deletions packages/ui/src/components/interruption-dock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,55 +5,64 @@ import { getPermissionQueue, sendFormCancel, sendFormReply } from "../stores/ins
import { getFormQueue } from "../stores/forms"
import { sessions } from "../stores/sessions"
import { interruptionFocus } from "../stores/interruption-navigation"
import { getInterruptionQueue, getInterruptionScope } from "../stores/interruption-scope"
import FormRequest from "./form-request"
import { InterruptionPermission } from "./interruption-permission"
import { useInterruptionMinimumHeight } from "./interruption-dock-layout"

export function InterruptionDock(props: { instanceId: string; sessionId?: string | null; active?: boolean; onExpandedChange?: (expanded: boolean) => void }) {
const { t } = useI18n()
const [selected, setSelected] = createSignal<string>()
const [selections, setSelections] = createSignal(new Map<string | null | undefined, string>())
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 <Show when={queue().length > 0}>
return <Show when={pending().length > 0}>
<section ref={root} class="interruption-dock window-shell" classList={{ "is-collapsed": collapsed() }}
hidden={queue().length === 0} inert={queue().length === 0}
style={{ "min-height": !collapsed() && minimumHeight() !== undefined ? `${minimumHeight()}px` : undefined }} tabIndex={-1} aria-label={t("permissionApproval.title")}>
<header class="window-header">
<div class="interruption-heading">
Expand All @@ -79,15 +88,15 @@ export function InterruptionDock(props: { instanceId: string; sessionId?: string
</header>
<div id={`interruption-body-${props.instanceId}`} class="window-body interruption-body" hidden={collapsed()}>
{/* Stable request keys preserve partial answers across native reconciliation and queue navigation. */}
<For each={queue().map(item => item.key)}>{key => {
<For each={pending().map(item => item.key)}>{key => {
const item = () => byKey().get(key)!
let editor: HTMLDivElement | undefined
onCleanup(() => {
if (!editor?.contains(document.activeElement)) return
const composer = root?.closest(".session-view")?.querySelector<HTMLTextAreaElement>(".prompt-input")
queueMicrotask(() => {
if (props.active === false || (document.activeElement !== document.body && document.activeElement?.isConnected)) return
if (root?.isConnected) root.focus({ preventScroll: true })
if (root?.isConnected && !root.hidden) root.focus({ preventScroll: true })
else if (composer?.isConnected) composer.focus({ preventScroll: true })
})
})
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/stores/interruption-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { createSignal } from "solid-js"

// UI intents only. Native pending queues and tool results remain authoritative.
export const [interruptionFocus, setInterruptionFocus] = createSignal<{
instanceId: string; sessionId?: string; requestId?: string
instanceId: string; sessionId?: string; requestId?: string; kind?: "permission" | "form"
}>()
export function focusInterruption(instanceId: string, sessionId?: string, requestId?: string) {
setInterruptionFocus({ instanceId, sessionId, requestId })
export function focusInterruption(instanceId: string, sessionId?: string, requestId?: string, kind?: "permission" | "form") {
setInterruptionFocus({ instanceId, sessionId, requestId, kind })
}
19 changes: 19 additions & 0 deletions packages/ui/src/stores/interruption-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Session } from "../types/session"
import { getDescendantSessionsFromMap } from "./session-tree"
import { getPermissionQueue } from "./instances"
import { getFormQueue } from "./forms"

export function getInterruptionQueue(instanceId: string) {
return [
...getPermissionQueue(instanceId).map(payload => ({ key: `permission:${payload.id}`, kind: "permission" as const, payload })),
...getFormQueue(instanceId).map(payload => ({ key: `form:${payload.id}`, kind: "form" as const, payload })),
]
}

// A missing/unknown conversation is not authority for the rest of the project.
// Only the explicit no-session surface owns genuinely sessionless Forms.
export function getInterruptionScope(instanceSessions: Map<string, Session> | undefined, sessionId: string | null | undefined) {
if (sessionId === null) return new Set(["global"])
if (!sessionId || sessionId === "info" || !instanceSessions?.has(sessionId)) return new Set<string>()
return new Set([sessionId, ...getDescendantSessionsFromMap(instanceSessions, sessionId).map(session => session.id)])
}
8 changes: 4 additions & 4 deletions packages/ui/src/styles/components/interruption-dock.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@
gap: var(--space-sm);
background: color-mix(in srgb, var(--accent-primary) 9%, var(--surface-chrome));
}
.session-view:has(> .interruption-dock:not(.is-collapsed)) {
.session-view:has(> .interruption-dock:not(.is-collapsed):not([hidden])) {
overflow-y: auto;
overscroll-behavior-y: contain;
}
.session-view:has(> .interruption-dock:not(.is-collapsed)) > .prompt-input-container { flex-shrink: 0; }
.session-view:has(> .interruption-dock:not(.is-collapsed)) :is(.form-request-fields, .interruption-permission-content) {
.session-view:has(> .interruption-dock:not(.is-collapsed):not([hidden])) > .prompt-input-container { flex-shrink: 0; }
.session-view:has(> .interruption-dock:not(.is-collapsed):not([hidden])) :is(.form-request-fields, .interruption-permission-content) {
overscroll-behavior-y: auto;
}
.interruption-heading { display: flex; align-items: center; gap: var(--space-sm); min-width: 0; }
Expand All @@ -38,7 +38,7 @@
.interruption-toggle { border-inline-start: 1px solid var(--border-divider); }
.interruption-body { display: flex; padding: 0; overflow: hidden; min-height: 0; }
.interruption-editor { display: flex; flex: 1; min-width: 0; min-height: 0; }
.interruption-dock [hidden] { display: none; }
.interruption-dock[hidden], .interruption-dock [hidden] { display: none; }
.interruption-dock.is-collapsed .window-header { border-bottom: 0; }
.interruption-dock .form-request,
.interruption-dock .tool-call-permission { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; border: 0; margin: 0; padding: 0; gap: 0; background: var(--surface-base); }
Expand Down
Loading
Loading