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 @@ -14,6 +14,7 @@
- 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`.
- 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`.
- Pending-request recovery uses the authenticated broker in `packages/server/src/server/routes/pending-requests.ts` when the daemon supports loaded-only snapshots. Validate directory ownership before querying and every returned placement before publishing; retain execution-host authority even for cold/empty coverage, including WSL and path aliases. Preserve idle/global Forms, per-kind mutation fences and settled-request tombstones. Errors or incomplete coverage never clear queues. The capability is negotiated per connection, not inferred from a version label; older daemons retain their existing discovery behavior and do not gain the native performance fix merely by updating CodeNomad.
- OpenCode settings keep executable selection first and runtime status, install/update and service actions directly inline. Only version details and troubleshooting are collapsed disclosures at the bottom of the runtime panel; log levels remain the final settings card. Share controls with the startup recovery dialog rather than routing settings through a separate management modal. Disclosure styles live in `styles/components/opencode-setup.css`.
Expand Down
16 changes: 16 additions & 0 deletions dev-docs/NATIVE_INTERRUPTION_UX.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ independently; reply, cancel and permission actions remain in a fixed footer
outside that scrolling content. Its square shared window chrome uses the existing
accent/surface tokens for the header, icon and leading border.

An expanded request takes priority over a manually enlarged composer. The composer
temporarily uses its minimum field height and disables resizing without overwriting
the saved height or draft; collapse or settlement restores the preference. The dock
does not flex-shrink into an unusable header. The shell bounds the conversation to
the visual viewport while a request is expanded, including Android keyboard resize
and pan. Request fields scroll within the dock while its actions remain reachable.
For keyboard heights too short to fit the whole stack, a measured minimum reserves
the header, footer and one usable input. The session stack then scrolls as a whole
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
Expand Down Expand Up @@ -97,3 +107,9 @@ pagination, SSE refresh, stale reads and long receipts in light/dark mobile layo
`node scripts/test-permission-receipts-native.mjs` runs isolated native recovery
against project/worktree roots and descendant Locations, with external replies
and disk reload. It never uses a shared daemon or user database.

`tests/browser/mobile-interruption.test.ts` exercises the real instance shell with
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.
8 changes: 7 additions & 1 deletion packages/ui/src/components/instance/instance-shell2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import { useInstanceSessionContext } from "./shell/useInstanceSessionContext"
import { isPermissionAutoAcceptEnabled } from "../../stores/permission-auto-accept"
import { readClientLayoutValue, writeClientLayoutValue } from "../../stores/client-state"
import { runtimeEnv } from "../../lib/runtime-env"
import { useInterruptionViewport } from "./shell/useInterruptionViewport"

const log = getLogger("session")
const NO_SESSION_DRAFT_SESSION_ID = "__no_session_draft__"
Expand Down Expand Up @@ -165,8 +166,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
})

// Shell ownership keeps partially answered requests alive as session panes change.
const [interruptionExpanded, setInterruptionExpanded] = createSignal(false)
const interruptionViewport = useInterruptionViewport(() => sessionCenterEl() ?? undefined, interruptionExpanded)
const interruptionPanel = <InterruptionDock instanceId={props.instance.id}
sessionId={activeSessionIdForInstance()} active={props.isActiveInstance} />
sessionId={activeSessionIdForInstance()} active={props.isActiveInstance} onExpandedChange={setInterruptionExpanded} />

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

Expand Down Expand Up @@ -1048,6 +1051,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<Box
class="session-center-column"
ref={setSessionCenterEl}
style={interruptionViewport()}
data-session-center-width={sessionCenterWidthStep()}
data-session-header-density={String(headerDensity())}
data-session-header-actions-forced={narrowHeaderActions() ? "true" : "false"}
Expand Down Expand Up @@ -1214,6 +1218,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {

{interruptionPanel}
<PromptInput
interruptionExpanded={interruptionExpanded()}
instanceId={props.instance.id}
instanceFolder={props.instance.folder}
sessionId={NO_SESSION_DRAFT_SESSION_ID}
Expand Down Expand Up @@ -1253,6 +1258,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<Show when={isActive()}>
<SessionView
interruptionPanel={interruptionPanel}
interruptionExpanded={interruptionExpanded()}
sessionId={sessionId}
activeSessions={activeSessions()}
instanceId={props.instance.id}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { createEffect, createSignal, onCleanup, onMount, type Accessor, type JSX } from "solid-js"

// Android's keyboard may resize only visualViewport. Bound the request/composer
// stack to its bottom edge without changing the saved composer height or normal
// conversation layout. Measure the uncapped top, not the capped element's height,
// so keyboard panning and ResizeObserver cannot progressively shrink the pane.
export function useInterruptionViewport(container: Accessor<HTMLElement | undefined>, expanded: Accessor<boolean>) {
const [bottomSpace, setBottomSpace] = createSignal<number>()
let schedule = () => {}
createEffect(() => { if (expanded()) schedule() })
onMount(() => {
const element = container()
if (!element) return
const viewport = window.visualViewport
let frame = 0
const measure = () => {
if (!expanded()) return
const bottom = (viewport?.offsetTop ?? 0) + (viewport?.height ?? window.innerHeight)
setBottomSpace(Math.max(0, bottom - element.getBoundingClientRect().top))
}
schedule = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(measure) }
const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(schedule)
observer?.observe(element)
viewport?.addEventListener("resize", schedule)
viewport?.addEventListener("scroll", schedule)
window.addEventListener("resize", schedule)
measure()
onCleanup(() => {
cancelAnimationFrame(frame)
observer?.disconnect()
viewport?.removeEventListener("resize", schedule)
viewport?.removeEventListener("scroll", schedule)
window.removeEventListener("resize", schedule)
})
})
return (): JSX.CSSProperties | undefined => expanded() && bottomSpace() !== undefined
? { "max-height": `${bottomSpace()}px` } : undefined
}
36 changes: 36 additions & 0 deletions packages/ui/src/components/interruption-dock-layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"

// A percentage cap cannot fit even the fixed chrome under a very short keyboard
// viewport. Preserve one usable control between the measured header/footer;
// the session's outer scroller then makes each part of the stack reachable.
export function useInterruptionMinimumHeight(root: Accessor<HTMLElement | undefined>, request: Accessor<string | undefined>) {
const [minimum, setMinimum] = createSignal<number>()
createEffect(() => {
if (!request()) return
let disposed = false
let observer: ResizeObserver | undefined
queueMicrotask(() => {
if (disposed) return
const section = root()
const header = section?.querySelector<HTMLElement>(".window-header")
const editor = section?.querySelector<HTMLElement>(".interruption-editor:not([hidden])")
const footer = editor?.querySelector<HTMLElement>(".window-footer")
const fields = editor?.querySelector<HTMLElement>(".form-request-fields, .interruption-permission-content")
if (!section || !header || !editor || !footer || !fields) return
const control = fields.querySelector<HTMLElement>("input, textarea, select, [role=radio]")
const measure = () => {
const style = getComputedStyle(section), fieldStyle = getComputedStyle(fields)
const controlHeight = Math.max(parseFloat(style.getPropertyValue("--touch-target-size")) || 0, control?.getBoundingClientRect().height ?? 0)
const fieldHeight = controlHeight + parseFloat(fieldStyle.paddingTop) + parseFloat(fieldStyle.paddingBottom)
const border = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth)
const error = editor.querySelector<HTMLElement>(".form-request-error, .tool-call-permission-error")?.getBoundingClientRect().height ?? 0
setMinimum(Math.ceil(header.getBoundingClientRect().height + footer.getBoundingClientRect().height + fieldHeight + border + error))
}
observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(measure)
for (const element of [section, header, footer, fields, ...(control ? [control] : [])]) observer?.observe(element)
measure()
})
onCleanup(() => { disposed = true; observer?.disconnect() })
})
return minimum
}
9 changes: 7 additions & 2 deletions packages/ui/src/components/interruption-dock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import { sessions } from "../stores/sessions"
import { interruptionFocus } from "../stores/interruption-navigation"
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 }) {
export function InterruptionDock(props: { instanceId: string; sessionId?: string | null; active?: boolean; onExpandedChange?: (expanded: boolean) => void }) {
const { t } = useI18n()
const [selected, setSelected] = createSignal<string>()
const [collapsed, setCollapsed] = createSignal(false)
Expand All @@ -18,7 +19,10 @@ export function InterruptionDock(props: { instanceId: string; sessionId?: string
...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])))
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 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")
Expand Down Expand Up @@ -49,7 +53,8 @@ export function InterruptionDock(props: { instanceId: string; sessionId?: string
}

return <Show when={queue().length > 0}>
<section ref={root} class="interruption-dock window-shell" classList={{ "is-collapsed": collapsed() }} tabIndex={-1} aria-label={t("permissionApproval.title")}>
<section ref={root} class="interruption-dock window-shell" classList={{ "is-collapsed": collapsed() }}
style={{ "min-height": !collapsed() && minimumHeight() !== undefined ? `${minimumHeight()}px` : undefined }} tabIndex={-1} aria-label={t("permissionApproval.title")}>
<header class="window-header">
<div class="interruption-heading">
<Show when={current()?.kind === "permission"} fallback={<MessageCircleQuestion size={18} aria-hidden="true" />}><ShieldCheck size={18} aria-hidden="true" /></Show>
Expand Down
18 changes: 16 additions & 2 deletions packages/ui/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,12 @@ export default function PromptInput(props: PromptInputProps) {
})
createEffect(() => {
const saved = inputHeight()
if (typeof saved === "number" && viewport().height > 0) {
if (!props.interruptionExpanded && typeof saved === "number" && viewport().height > 0) {
persistPromptInputHeight(heightPreference(saved))
}
})
const effectiveInputHeight = () => {
if (props.interruptionExpanded) return minimumFieldHeight()
const saved = inputHeight()
const desired = saved === null ? defaultFieldHeight()
: typeof saved === "number" ? saved
Expand Down Expand Up @@ -407,6 +408,7 @@ export default function PromptInput(props: PromptInputProps) {

function handleResizeStart(event: PointerEvent) {
event.preventDefault()
if (props.interruptionExpanded) return
const target = event.currentTarget as HTMLElement

resizeDragState = {
Expand All @@ -427,6 +429,7 @@ export default function PromptInput(props: PromptInputProps) {
}

function handleResizeMove(event: PointerEvent) {
if (props.interruptionExpanded) return
if (!resizeDragState || resizeDragState.pointerId !== event.pointerId) return

event.preventDefault()
Expand All @@ -449,6 +452,10 @@ export default function PromptInput(props: PromptInputProps) {
}

function handleResizeKeyDown(event: KeyboardEvent) {
if (props.interruptionExpanded) {
if (["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) event.preventDefault()
return
}
const currentHeight = effectiveInputHeight() ?? fieldContainerRef?.getBoundingClientRect().height ?? defaultFieldHeight()
const minimum = minimumFieldHeight()
const current = Math.max(minimum, currentHeight)
Expand All @@ -470,6 +477,11 @@ export default function PromptInput(props: PromptInputProps) {
onCleanup(() => {
resizeDragState = undefined
})
createEffect(() => {
if (!props.interruptionExpanded) return
resizeDragState = undefined
setIsResizing(false)
})

const promptDelivery = (alternate = false) => resolvePromptDelivery(
Boolean(props.isSessionBusy || submissionsInFlight > 0),
Expand Down Expand Up @@ -624,6 +636,7 @@ export default function PromptInput(props: PromptInputProps) {

function handleResizeMaximize(event: MouseEvent) {
event.preventDefault()
if (props.interruptionExpanded) return
persistPromptInputHeight(heightPreference(computeMaxFieldHeight()))
textareaRef?.focus()
}
Expand Down Expand Up @@ -897,7 +910,8 @@ export default function PromptInput(props: PromptInputProps) {
onPointerCancel={handleResizeEnd}
onDblClick={handleResizeMaximize}
onKeyDown={handleResizeKeyDown}
tabIndex={0}
tabIndex={props.interruptionExpanded ? -1 : 0}
aria-disabled={props.interruptionExpanded || undefined}
role="separator"
aria-orientation="horizontal"
aria-valuemin={Math.round(minimumFieldHeight())}
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/components/prompt-input/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface PromptInputProps {

// Used to scope global "type-to-focus" behavior.
isActive?: boolean
// Expanded shell-owned requests temporarily take priority over saved composer height.
interruptionExpanded?: boolean

// Phone/tablet layouts should keep the expanded prompt more compact.
onSend: (prompt: string, attachments: Attachment[], delivery: PromptDelivery, restoredPayload?: SessionInboxUserPayload) => Promise<void>
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/components/session/session-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ function isTextPart(part: ClientPart): part is ClientPart & { type: "text"; text

interface SessionViewProps {
interruptionPanel?: JSXElement
interruptionExpanded?: boolean
sessionId: string
activeSessions: Map<string, Session>
instanceId: string
Expand Down Expand Up @@ -676,6 +677,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {

{props.interruptionPanel}
<PromptInput
interruptionExpanded={props.interruptionExpanded}
instanceId={props.instanceId}
instanceFolder={session()?.location.directory ?? props.instanceFolder}
sessionId={props.sessionId}
Expand Down
14 changes: 12 additions & 2 deletions packages/ui/src/styles/components/interruption-dock.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
.interruption-dock {
flex: 0 1 auto;
flex: 0 0 auto;
min-height: 0;
max-height: 45%;
/* The measured usable minimum takes precedence on extreme keyboard heights;
the session stack scrolls rather than clipping the footer or composer. */
max-height: min(60%, 45vh);
display: flex;
flex-direction: column;
border: 1px solid color-mix(in srgb, var(--accent-primary) 38%, var(--border-base));
Expand All @@ -14,6 +16,14 @@
gap: var(--space-sm);
background: color-mix(in srgb, var(--accent-primary) 9%, var(--surface-chrome));
}
.session-view:has(> .interruption-dock:not(.is-collapsed)) {
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) {
overscroll-behavior-y: auto;
}
.interruption-heading { display: flex; align-items: center; gap: var(--space-sm); min-width: 0; }
.interruption-heading > svg { flex-shrink: 0; color: var(--accent-primary); }
.interruption-heading-copy { min-width: 0; }
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/styles/messaging/prompt-input.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
padding: 0;
}

.prompt-resize-handle[aria-disabled="true"] { cursor: default; pointer-events: none; }

.prompt-resize-handle::after {
content: "";
position: absolute;
Expand Down
Loading
Loading