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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
33 changes: 18 additions & 15 deletions dev-docs/NATIVE_INTERRUPTION_UX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
21 changes: 6 additions & 15 deletions packages/ui/src/components/instance/instance-shell2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -170,7 +169,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [interruptionExpanded, setInterruptionExpanded] = createSignal(false)
const interruptionViewport = useInterruptionViewport(() => sessionCenterEl() ?? undefined, interruptionExpanded)
const interruptionPanel = <InterruptionDock instanceId={props.instance.id}
sessionId={activeSessionIdForInstance()} active={props.isActiveInstance} onExpandedChange={setInterruptionExpanded} />
sessionId={activeSessionIdForInstance()} active={props.isActiveInstance} onExpandedChange={setInterruptionExpanded}
onViewConversation={handleSessionSelect} />

const desktopQuery = useMediaQuery("(min-width: 1280px)")

Expand Down Expand Up @@ -1041,19 +1041,10 @@ const InstanceShell2: Component<InstanceShellProps> = (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 = (
<div
Expand Down
34 changes: 34 additions & 0 deletions packages/ui/src/components/interruption-dock-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { createEffect, createMemo, createSignal, on } from "solid-js"
import { sessions } from "../stores/sessions"
import { getInterruptionQueue, getInterruptionScope } from "../stores/interruption-scope"

export function useInterruptionDockState(props: { instanceId: string; sessionId?: string | null }) {
type Selection = { key: string; expanded: boolean }
const [selections, setSelections] = createSignal(new Map<string | null | undefined, Selection>())
const pending = createMemo(() => getInterruptionQueue(props.instanceId))
const scope = createMemo(() => getInterruptionScope(sessions().get(props.instanceId), props.sessionId))
const external = (item: ReturnType<typeof pending>[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 }
}
Loading
Loading