diff --git a/.gitignore b/.gitignore index 5110581..63eeda4 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ test-*.js test/browser/bundle.js .work/ .work/ + +# The app's own runtime directory: attachments uploaded through a conversation, +# written into whatever project it is running against. A screenshot sent to the +# agent is data, not source, and one reached this repo before this line did. +.cc-web/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b475705..6ddd66c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,164 @@ # Changelog +## [5.2.0] - 2026-07-27 + +### Added +- **A tab you renamed stays renamed, and a reload brings you back to it.** The + name you gave a session used to live only in the page you typed it in: reload + the browser, or open the app in a second window, and every tab was back to the + name it was created with. A chosen name now belongs to the session. It comes + back after a reload, it is the same name in every window and on every device, + it reaches windows that are already open without them reloading, and it + survives the app being restarted and the session being recovered. Sessions + nobody renamed are unchanged — they still show their generated name, with the + folder name standing in for it. + + The same reload also used to lose your place, dropping you on the first tab + whichever one you had been working in. It now returns you to the tab you were + last on, remembered per window, so two windows can each sit on their own + session. If that session is gone by the time you come back, the app falls back + to the first tab rather than showing you nothing. + +- **The agent can ask you a question and wait for the answer.** Until now the + only thing it could put in front of you was an approval — allow or deny a tool + it was about to run. Anything else it needed to know, it had to ask in prose, + and you had to guess the wording it was hoping for. + + It can now ask a proper question with the answers already written out: which + of three approaches to take, which of the four candidate files you meant, which + of the problems it found to fix first. The question appears in the conversation + where it was asked, you answer by clicking, and the agent picks up from your + answer. Questions come in both kinds — pick exactly one, or tick several and + confirm. + + The card stays where it was after you answer, showing what was asked and what + you chose, so scrolling back past a decision shows the decision. If you close + the tab while one is waiting, it is still there — and still answerable — when + you come back. A question you would rather not answer can be skipped; the agent + is told so and carries on rather than sitting there blocked. + + Questions are asked even in sessions running with approvals bypassed: not being + asked before it acts has never meant having your questions answered for you. + + Works with Claude and with omp, each through its own handshake. kimi can reach + it too, but often prefers its own built-in question tool, which answers itself + without asking anyone — so questions there are hit and miss, and that is kimi's + behaviour rather than something this app can steer. Codex, pi and grok report + the capability as unavailable rather than offering a button that would do + nothing. +- **You can open what the agent handed off and watch it work.** A delegation + used to be one line in the agents list: a name, a status badge, a duration. + Whether it was a sub-agent reading three files or a workflow running a dozen + agents across four stages, you got the same single row and waited. + + Now any entry in that list opens. A sub-agent shows its own work — the step it + is on in its own words ("Reading hello.txt"), every tool it reached for, what + each one gave back, and how many tools and tokens it has spent. It fills in + live while the agent works and stays there afterwards for reading back. A + workflow opens the same way and shows the stages it narrates as it goes. + + Failures inside that work are now legible. When a step an agent took fails, + the popup shows the message it failed with, rather than the whole delegation + turning into a red badge that says only that something went wrong somewhere. + + Both popups behave like the file editor — movable, resizable, expandable to + fill the window — and both stay open while you look at another panel. + +### Fixed +- **A sub-agent's own steps are no longer thrown away.** The runtime has been + reporting them all along, tagged with the delegation they belong to. Nothing + read that tag, so every tool call an agent made inside its own work was filed + against an id no part of the conversation owned and silently dropped. They now + reach the delegation that made them. + +### Changed +- **The conversation only shows what was actually said.** When the agent spent a + step running commands without writing anything, the transcript still drew a + row for it: an icon, a clock and a small work pill with no sentence beside + them. A long task could put half a dozen of those between one reply and the + next, and skimming the chat meant stepping over rows that said nothing. + + Those steps no longer appear in the conversation. The moment a written reply + arrives, it carries the pill for everything that led up to it — "3 commands · + 1 reasoning · 8.1s" on the sentence that came out of them — and opening it + lands on the trace at the *start* of that stretch rather than at the reply's + own last command. Nothing is hidden: the trace holds every call exactly as + before, and a trace row or a search hit that points at a suppressed step now + scrolls to the reply that speaks for it. + +- **A phone gets a layout built around the conversation.** It used to be the + desktop layout at the same size: the figures you read mid-session — the cost, + the model, the state, whether approvals are bypassed — were set smaller than + the body text, the controls sat close enough together that hitting the + intended one was luck, and more than half the screen went to chrome. + + Now the chat surface is one slim strip above the conversation and one row + below it. The strip says what the session is doing and what it has cost; + tapping it opens the runtime, the folder, the branch, the tokens, the context + meter and the approvals state. The row below is the message field and send, + with the attachments, the pickers, the model and the approvals readout behind + a *More*. + + The bottom bar is now a set of destinations rather than a drawer of commands. + What this app does is run agent sessions, and inside one there are four places + worth being — the conversation, what the agent did about it, the files it did + that to, and a shell in the same directory — plus the other sessions. Those + are the five, it says which one you are on, and pressing one goes there. What + used to sit in those slots mixed a place to go, a thing to make, a panel to + toggle, a file to attach and a sheet of everything left over. + + The verbs moved to a square button floating in the bottom right: search this + conversation, jump to a turn, display settings, new session, attach an image, + rename, reconnect, and the rest behind *More*. A control that changes where + you are and a control that does something to where you are no longer share a + row. + + The conversation now has about seven tenths of a phone screen, where it had + under half, and the bar gets out of the way entirely while the on-screen + keyboard is up. Everything a finger is meant to hit is at least 44px with real + space around it, nothing carrying live information is smaller than the body + text, and every control says what it is rather than being a bare glyph with a + tooltip no touch screen can show. The same treatment reaches the trace rail, + the turn index, the model list, the more sheet, the tab switcher and the + dialogs. The desktop and tablet layouts are unchanged. + +### Fixed +- **A phone no longer opens a conversation onto a panel.** Which panel is open + is a desktop preference — there the rail sits beside the transcript — but on a + phone it replaces it, so the stored setting put every conversation behind a + panel. It is session state on a phone now, and the shared preference is left + alone rather than overwritten, which would have closed the rail on the desktop + that set it open. +- **The conversation no longer spills over the live ribbon and the composer on + a short screen.** The transcript kept a fixed minimum height it could not + give up, so on a phone in landscape — or any window short enough — it grew + past the space it had and was painted over the two things below it. + +### Internal +- A browser check covers the suppressed steps end to end — that they leave no + row, that the trace still holds them, and that clicking the reply's pill lands + on the first of them. The suite's virtual-time budget grew with it: a run that + outgrows the budget reports no results rather than a failure, so it now has + room over what the checks need. +- The automated browser checks run at phone viewports (portrait, keyboard-open + and landscape), with each of the phone's own disclosures and its menu open in + turn, and assert the geometry rather than the intent: target size, the space + between neighbours, type size, that the named live figures are legible and + reachable, that every control can be identified without pressing it, that no + region is drawn over another, and that the chrome takes no more than 170px + from the conversation — nothing else would have noticed that drifting back, + since every other rule is about the chrome being big enough. + + Three defects in the checks themselves came out of it. They ran in an 800x600 + window while mounting a 390x740 fixture, so a third of it was off-viewport + and anything that asks the viewport a question got the wrong answer. They + loaded none of the app's own stylesheets, without which every + `var(--text-2xs)` resolved to nothing and a type-size check read 16px for + text that ships at 10. And a panel animating in from `opacity: 0` was skipped + as invisible in headless Chrome, so a whole state was reported clean without + measuring anything — the menu rises without fading now, which also means it + is there under reduced motion and on a dropped first frame. + ## [5.1.2] - 2026-07-26 ### Added diff --git a/package-lock.json b/package-lock.json index 8132bf1..620a24d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-agents-webcli", - "version": "5.1.2", + "version": "5.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-agents-webcli", - "version": "5.1.2", + "version": "5.2.0", "license": "MIT", "dependencies": { "@lydell/node-pty": "1.2.0-beta.14", diff --git a/package.json b/package.json index 18f6b98..6603008 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "code-agents-webcli", - "version": "5.1.2", + "version": "5.2.0", "description": "Multiuser web CLI for Claude Code, Codex, and terminal sessions", "main": "dist/server/index.js", "bin": { diff --git a/src/client/app.ts b/src/client/app.ts index 2078c34..cc83b49 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -259,8 +259,11 @@ export class App { this.splitContainer.setupDropZones(); if (this.sessionTabManager.tabs.size > 0) { - const firstTabId = this.sessionTabManager.tabs.keys().next().value; - await this.sessionTabManager.switchToTab(firstTabId!); + // The tab this browser was last on, or the first one if that session is + // gone — not always the first one, which sent every reload back to the + // start of the strip. + const initialTabId = this.sessionTabManager.initialTabId(); + if (initialTabId) await this.sessionTabManager.switchToTab(initialTabId); hideOverlay(); } else { hideOverlay(); diff --git a/src/client/chat/controller.ts b/src/client/chat/controller.ts index ff6a231..9d4d3e3 100644 --- a/src/client/chat/controller.ts +++ b/src/client/chat/controller.ts @@ -309,6 +309,17 @@ export class ChatController { this.send({ type: 'chat_queue_cancel', queuedId }); } + /** + * Answer a multiple-choice question the model asked. + * + * `skipped` is explicit rather than inferred from an empty list: "I picked + * none of these" and "I do not want to answer" reach the model as different + * sentences, and the agent is blocked either way until one of them arrives. + */ + answerQuestion(requestId: string, optionIds: string[], skipped = false): void { + this.send({ type: 'chat_question_answer', requestId, optionIds, skipped }); + } + respondPermission(requestId: string, optionId: string): void { this.send({ type: 'chat_permission_response', requestId, optionId }); } @@ -415,6 +426,7 @@ export class ChatController { state: 'starting', capabilities: NO_CHAT_CAPABILITIES, pendingPermissions: [], + pendingQuestions: [], firstSeq: 0, replayFrom: 0, cursor: 0, diff --git a/src/client/chat/transcript.ts b/src/client/chat/transcript.ts index 59f98c1..07d01c1 100644 --- a/src/client/chat/transcript.ts +++ b/src/client/chat/transcript.ts @@ -28,6 +28,7 @@ import { PermissionRequest, PlanItem, QueuedTurn, + QuestionRequest, NO_CHAT_CAPABILITIES, } from '../../shared/chat-events.js'; import { @@ -161,6 +162,7 @@ export class ChatTranscript { usage: snapshot.usage || {}, plan: snapshot.plan || [], pendingPermissions: snapshot.pendingPermissions || [], + pendingQuestions: snapshot.pendingQuestions || [], firstSeq: snapshot.firstSeq, cursor: snapshot.cursor, }); @@ -371,6 +373,27 @@ export class ChatTranscript { return this.state.pendingPermissions; } + /** Questions the model asked that nobody has answered yet. */ + get pendingQuestions(): QuestionRequest[] { + return this.state.pendingQuestions; + } + + /** + * The question waiting on the given tool call, if that call is the one asking. + * + * How a card drawn from a tool block finds out it is still live: the block is + * in the transcript either way, and this is the difference between a set of + * buttons and a record of what was already decided. + */ + questionFor(toolId: string): QuestionRequest | undefined { + return this.state.pendingQuestions.find((pending) => pending.toolId === toolId); + } + + /** Which options were picked for the question that call asked, once answered. */ + answerFor(toolId: string): string[] | undefined { + return this.state.answeredQuestions[toolId]; + } + get cursor(): number { return this.state.cursor; } diff --git a/src/client/chat/turns.ts b/src/client/chat/turns.ts index 96e95ea..1fb561e 100644 --- a/src/client/chat/turns.ts +++ b/src/client/chat/turns.ts @@ -109,7 +109,8 @@ function summarise( } } - const status: TurnStatus = isLast && chatState === 'awaiting_permission' + const status: TurnStatus = isLast + && (chatState === 'awaiting_permission' || chatState === 'awaiting_answer') ? 'waiting' : isLast && (streaming || chatState === 'thinking' || chatState === 'running' || chatState === 'starting') ? 'running' diff --git a/src/client/sessions/tab-manager.ts b/src/client/sessions/tab-manager.ts index 3d2f601..14ae5be 100644 --- a/src/client/sessions/tab-manager.ts +++ b/src/client/sessions/tab-manager.ts @@ -19,6 +19,15 @@ interface TabRecord { id: string; /** The label shown on the tab, which is not always the session name. */ displayName: string; + /** + * The label the user chose, if they chose one. + * + * Kept apart from `displayName` so a rename that the server refuses can be + * put back the way it was, and so a session the user never renamed still goes + * through the generated-name rules rather than being pinned to whatever the + * strip happened to be showing. + */ + customName?: string; /** * Which surface the session runs on, as far as this client knows. * @@ -30,6 +39,56 @@ interface TabRecord { surface: 'terminal' | 'chat'; } +/** + * Where this browser remembers the tab it was last on. + * + * In the browser and not on the server, because which tab you are looking at is + * a property of the window you are looking at it in — a shared answer would have + * a second window dragging the first one around. + * + * Written to both stores and read from sessionStorage first. sessionStorage is + * per window, which is what lets two windows sit on different sessions and each + * come back to its own after a reload. localStorage is the fallback for a window + * that has no session storage to read yet — a newly opened one, or a browser + * started fresh — which would otherwise always land on the first tab. + */ +const ACTIVE_TAB_KEY = 'cc-web-active-tab'; + +function rememberActiveTab(sessionId: string): void { + for (const store of storages()) { + try { + store.setItem(ACTIVE_TAB_KEY, sessionId); + } catch { + // Private mode, a full quota, storage switched off. Losing the memory of + // which tab was open is not a reason to fail a tab switch. + } + } +} + +function recallActiveTab(): string | null { + for (const store of storages()) { + try { + const stored = store.getItem(ACTIVE_TAB_KEY); + if (stored) return stored; + } catch { + // Same as above, and the next store still gets its turn. + } + } + return null; +} + +/** This window's memory first, then the browser-wide one. */ +function storages(): Storage[] { + const found: Storage[] = []; + try { + if (typeof sessionStorage !== 'undefined') found.push(sessionStorage); + } catch { /* blocked */ } + try { + if (typeof localStorage !== 'undefined') found.push(localStorage); + } catch { /* blocked */ } + return found; +} + export class SessionTabManager { app: App; tabs: Map; @@ -242,7 +301,7 @@ export class SessionTabManager { sessions.forEach((raw, index: number) => { const session = raw as unknown as { id: string; name: string; active: boolean; workingDir: string | null; - surface?: 'terminal' | 'chat'; + surface?: 'terminal' | 'chat'; customName?: string; }; this.addTab( session.id, @@ -250,6 +309,7 @@ export class SessionTabManager { session.active ? 'active' : 'idle', session.workingDir, false, + session.customName, ); if (session.surface === 'chat') { this.setTabSurface(session.id, 'chat'); @@ -281,21 +341,27 @@ export class SessionTabManager { status: SessionInfo['status'] = 'idle', workingDir: string | null = null, autoSwitch = true, + customName?: string, ): void { if (this.tabs.has(sessionId)) return; const isDefaultSessionName = sessionName.startsWith('Session ') && sessionName.includes(':'); const folderName = workingDir ? workingDir.split('/').pop() || '/' : null; - const displayName = !isDefaultSessionName ? sessionName : (folderName || sessionName); + const generated = !isDefaultSessionName ? sessionName : (folderName || sessionName); + // A chosen name wins outright: it is the one thing about a tab the user said + // out loud, so it is not run through the generated-name rules. + const displayName = customName || generated; - this.tabs.set(sessionId, { id: sessionId, displayName, surface: 'terminal' }); + this.tabs.set(sessionId, { id: sessionId, displayName, customName, surface: 'terminal' }); if (!this.tabOrder.includes(sessionId)) { this.tabOrder.push(sessionId); } this.activeSessions.set(sessionId, { id: sessionId, - name: sessionName, + // What a notification calls this session, which is the user's name for it + // when they have given one. + name: customName || sessionName, status, workingDir, lastAccessed: Date.now(), @@ -352,6 +418,7 @@ export class SessionTabManager { } this.activeTabId = sessionId; + rememberActiveTab(sessionId); const session = this.activeSessions.get(sessionId); if (session) { @@ -446,9 +513,67 @@ export class SessionTabManager { const name = newName.trim(); if (!record || !name) return; - record.displayName = name; + const previous = { displayName: record.displayName, customName: record.customName }; + this.applyName(sessionId, name, name); + + // The label moves now and the server is told afterwards: a rename is the + // user restating something they already know, and making them watch a round + // trip for it would be the slowest part of the interaction. If the server + // refuses — a session that has since been deleted, a name it will not take — + // the old label goes back, so the strip never keeps a name that was not + // stored. + void fetch(`/api/sessions/${sessionId}/name`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }) + .then(async (response) => { + if (!response.ok) { + this.applyName(sessionId, previous.displayName, previous.customName); + showNotification('That name could not be saved'); + return; + } + // What was stored, which is not always what was typed: the server caps + // a very long name, and the strip showing one thing while every other + // window shows another is the disagreement this whole change is about. + const stored = await response.json().catch(() => null); + if (stored && typeof stored.name === 'string') { + this.applyRemoteName(sessionId, stored.name); + } + }) + .catch(() => { + // Offline or mid-reconnect. The tab keeps the new name for this page — + // reverting a rename because the network blinked is the more annoying + // failure — and a reload shows what was actually stored. + }); + } + + /** + * Take a rename that happened somewhere else: another window, another device. + * + * Same store the local rename writes to, so two windows on the same sessions + * cannot end up disagreeing about what a tab is called. + */ + applyRemoteName(sessionId: string, name: string): void { + const trimmed = name.trim(); + const record = this.tabs.get(sessionId); + if (!record || !trimmed || record.displayName === trimmed) return; + this.applyName(sessionId, trimmed, trimmed); + } + + /** Write a label into the tab, the session and the strip. */ + private applyName( + sessionId: string, + displayName: string, + customName: string | undefined, + ): void { + const record = this.tabs.get(sessionId); + if (!record) return; + + record.displayName = displayName; + record.customName = customName; const session = this.activeSessions.get(sessionId); - if (session) session.name = name; + if (session) session.name = displayName; this.syncShell(); } @@ -492,6 +617,21 @@ export class SessionTabManager { if (index < tabIds.length) this.switchToTab(tabIds[index]); } + /** + * The tab a freshly loaded page should open on. + * + * The one this browser was last on, if it is still there — coming back to a + * set of long-running sessions and having to find the one you were in again + * is the whole complaint. A remembered id that names a session which has since + * been closed, or ended on another device, falls back to the first tab, which + * is what the app has always done. + */ + initialTabId(): string | null { + const remembered = recallActiveTab(); + if (remembered && this.tabs.has(remembered)) return remembered; + return this.getOrderedTabIds()[0] ?? this.tabs.keys().next().value ?? null; + } + // --------------------------------------------------------------------------- // Status updates // --------------------------------------------------------------------------- diff --git a/src/client/shell/AppShell.tsx b/src/client/shell/AppShell.tsx index 599afc4..cc1bbd7 100644 --- a/src/client/shell/AppShell.tsx +++ b/src/client/shell/AppShell.tsx @@ -4,6 +4,7 @@ import type { AppSettings } from '../types'; import { Badge } from '../ui/relay/Badge'; import { CommandPalette, type CommandPaletteGroup } from '../ui/relay/CommandPalette'; import { Icon } from '../ui/relay/Icon'; +import { PhoneContext } from '../ui/touch'; import { IconButton } from '../ui/relay/IconButton'; import { StatusBar, type StatusBarSegment } from '../ui/relay/StatusBar'; import { TabBar, type TabItem } from '../ui/relay/TabBar'; @@ -18,7 +19,8 @@ import { SessionsDialog } from './dialogs/SessionsDialog'; import { ChatSettingsDialog } from './dialogs/ChatSettingsDialog'; import { SettingsDialog } from './dialogs/SettingsDialog'; import { TerminalOptionsDialog } from './dialogs/TerminalOptionsDialog'; -import { MobileBar, type MobileBarAction } from './MobileBar'; +import { BottomNav, type BottomNavDestination } from './BottomNav'; +import { FloatingMenu, type FloatingMenuAction } from './FloatingMenu'; import { TabSwitcherSheet } from './TabSwitcherSheet'; import { KeyStrip, KEY_STRIP_HEIGHT } from './KeyStrip'; import type { MobileKey } from '../ui/mobile'; @@ -33,7 +35,7 @@ import { TabContextMenu } from './TabContextMenu'; import { TerminalHost } from './TerminalHost'; import { ChatView } from './chat/ChatView'; import type { ChatController } from '../chat/controller'; -import type { ChatViewSettings } from '../chat/view-settings'; +import { CHAT_PANEL_ICONS, type ChatPanelId, type ChatViewSettings } from '../chat/view-settings'; import { Toasts } from './Toasts'; import { UpdateBannerView } from './UpdateBannerView'; import { shellStore, type ShellState, type ShellTab } from './store'; @@ -147,6 +149,7 @@ export function AppShell({ terminalNode, actions, launcher }: AppShellProps): Re // controller is carried as an opaque handle because its transcript mutates // per token and must not live in shell state. const chatActive = state.chat.active; + const keyboardUp = useKeyboardUp(state.isMobile); const chatController = state.chat.controller as ChatController | null; const [menu, setMenu] = React.useState<{ id: string; x: number; y: number } | null>(null); @@ -309,47 +312,119 @@ export function AppShell({ terminalNode, actions, launcher }: AppShellProps): Re { children: state.theme === 'dark' ? 'Dark' : 'Light' }, ]; - const mobileActions: MobileBarAction[] = [ - { - id: 'sessions', - label: 'Sessions', - icon: 'layout-list', - badge: state.tabs.some((t) => t.unread && t.id !== state.activeId), - active: state.dialogs.tabs, - expands: true, - onPress: () => closeDialogs({ tabs: true }), + /** + * Where a phone can be, and where it is. + * + * Inside a conversation the app has four places worth being — the + * conversation, what the agent did, the files it did it to, and a shell in + * the same directory — plus the other sessions. Outside one there is the + * terminal and its keys. Every one of these *replaces* what fills the screen, + * which is what makes them destinations rather than toggles. + */ + /** + * On a phone, which panel is open is where you are — not a preference. + * + * The rail replaces the conversation there rather than sitting beside it, so + * a stored `panelOpen: true` would open every conversation onto a panel with + * the conversation behind it. And writing the phone's answer back would + * clobber the desktop's: the setting is shared, so one session on a phone + * would silently close the rail on the machine it was set open on. + * + * So on a phone it lives here, for the life of the tab, and the persisted + * settings never hear about it. + */ + const [phonePanel, setPhonePanel] = React.useState<{ open: boolean; tab: ChatPanelId }>(() => ({ + open: false, + tab: state.chatView.panelTab, + })); + const view: ChatViewSettings = state.isMobile + ? { ...state.chatView, panelOpen: phonePanel.open, panelTab: phonePanel.tab } + : state.chatView; + const setView = React.useCallback( + (next: ChatViewSettings): void => { + if (!shellStore.getSnapshot().isMobile) { + actions.setChatView(next); + return; + } + setPhonePanel({ open: next.panelOpen, tab: next.panelTab }); + // Everything else is still a preference and still persists; only the two + // fields that mean "where am I" are held back. + const stored = shellStore.getSnapshot().chatView; + actions.setChatView({ ...next, panelOpen: stored.panelOpen, panelTab: stored.panelTab }); }, - { id: 'new', label: 'New', icon: 'plus', onPress: actions.newTab }, - // One slot, two jobs, decided by what is on screen. - // - // The key strip sends terminal control codes, which a conversation has no - // use for; the workspace panel is meaningless without one. Giving each its - // own slot would put six items on a 390px bar with one of them always - // inert. This shows whichever the current surface can actually use. - chatActive - ? { - id: 'workspace', - label: 'Panel', - icon: 'panel-left', - active: state.chatView.panelOpen, - toggle: true, - onPress: () => - actions.setChatView({ ...state.chatView, panelOpen: !state.chatView.panelOpen }), - } - : { - // The on-screen key strip carries Escape now; this toggles it for the - // moments the terminal needs the vertical room back. + [actions], + ); + + const goChat = (): void => setView({ ...view, panelOpen: false, terminalOpen: false }); + const goPanel = (panelTab: ChatPanelId): void => + setView({ ...view, panelOpen: true, panelTab, terminalOpen: false }); + + const destinations: BottomNavDestination[] = chatActive + ? [ + { + id: 'chat', + label: 'Chat', + icon: 'message-square', + current: !view.panelOpen && !view.terminalOpen, + onGo: goChat, + }, + { + id: 'trace', + label: 'Trace', + icon: CHAT_PANEL_ICONS.trace, + current: view.panelOpen && view.panelTab === 'trace', + onGo: () => goPanel('trace'), + }, + { + id: 'files', + label: 'Files', + icon: CHAT_PANEL_ICONS.files, + current: view.panelOpen && view.panelTab !== 'trace', + onGo: () => goPanel('files'), + }, + { + id: 'terminal', + label: 'Shell', + icon: 'terminal', + current: view.terminalOpen, + onGo: () => setView({ ...view, panelOpen: false, terminalOpen: true }), + }, + { + id: 'sessions', + label: 'Sessions', + icon: 'layout-list', + badge: state.tabs.some((t) => t.unread && t.id !== state.activeId), + onGo: () => closeDialogs({ tabs: true }), + }, + ] + : [ + { id: 'terminal', label: 'Shell', icon: 'terminal', current: true, onGo: () => {} }, + { id: 'keys', label: 'Keys', icon: 'keyboard', - active: state.keysVisible, - toggle: true, - onPress: actions.toggleKeys, + current: state.keysVisible, + onGo: actions.toggleKeys, }, - { id: 'image', label: 'Image', icon: 'image', onPress: actions.attachImage }, + { + id: 'sessions', + label: 'Sessions', + icon: 'layout-list', + badge: state.tabs.some((t) => t.unread && t.id !== state.activeId), + onGo: () => closeDialogs({ tabs: true }), + }, + ]; + + // Verbs, not places — see BottomNav. These are what the floating button is + // for, and what a destination bar has no business holding. + const sessionActions: FloatingMenuAction[] = [ + { id: 'new', label: 'New session', icon: 'plus', onPress: actions.newTab }, + { id: 'image', label: 'Attach an image', icon: 'image', onPress: actions.attachImage }, + { id: 'rename', label: 'Rename this session', icon: 'pencil', disabled: !active, onPress: () => active && closeDialogs({ rename: active.id }) }, + { id: 'reconnect', label: 'Reconnect', icon: 'rotate-cw', onPress: actions.reconnect }, { id: 'more', - label: 'More', + label: 'More…', icon: 'ellipsis', active: state.dialogs.more, expands: true, @@ -357,6 +432,8 @@ export function AppShell({ terminalNode, actions, launcher }: AppShellProps): Re }, ]; + + // No brand on a phone. It cost about a third of a 390px tab strip to say // something the user already knows, and the working directory that stood // there instead only repeated the tab title. The tabs get the room. @@ -414,6 +491,12 @@ export function AppShell({ terminalNode, actions, launcher }: AppShellProps): Re ); return ( + // The shell's phone answer, published to everything under it — including + // the dialogs, which render here rather than inside any conversation and + // would otherwise be the one part of a phone still drawn at desktop sizes. + // ChatView publishes its own for the same value; a conversation rendered + // outside this shell still has to know. See ui/touch.ts. +
closeDialogs({ chatSettings: true })} + menuActions={sessionActions} // The chat surface owns the whole viewport, so the tab strip's own // theme button is off-screen while a conversation is showing. theme={state.theme} @@ -528,7 +614,25 @@ export function AppShell({ terminalNode, actions, launcher }: AppShellProps): Re /> ) : null} - {state.isMobile ? : } + {/* The phone's only permanent chrome. A bar along the bottom edge cost + 56px plus the safe-area inset of a screen whose whole point is the + conversation; this costs the corner it covers. */} + {/* Over a conversation the menu belongs to the chat surface, which is + the only thing that knows where its composer ends — see ChatView. Out + here it covers the terminal, which has no bottom-right control of its + own to collide with. */} + {state.isMobile && !chatActive ? ( + + ) : null} + + {state.isMobile ? ( +
+
); } @@ -764,3 +869,37 @@ async function pickAndUpload(sessionId: string, directory: string): Promise { + const viewport = typeof window === 'undefined' ? null : window.visualViewport; + if (!isMobile || !viewport) { + setUp(false); + return; + } + const measure = (): void => setUp(window.innerHeight - viewport.height > 160); + measure(); + viewport.addEventListener('resize', measure); + return () => viewport.removeEventListener('resize', measure); + }, [isMobile]); + + return up; +} diff --git a/src/client/shell/BottomNav.tsx b/src/client/shell/BottomNav.tsx new file mode 100644 index 0000000..afb91de --- /dev/null +++ b/src/client/shell/BottomNav.tsx @@ -0,0 +1,142 @@ +import * as React from 'react'; + +import { Icon } from '../ui/relay/Icon'; +import { PHONE_TEXT, TOUCH_TARGET } from '../ui/touch'; + +/** + * The phone's bottom bar: where you are in the app, and how to be somewhere + * else. + * + * Destinations, not commands. The bar this replaced held whatever five things + * seemed useful — Sessions, New, Panel, Image, More — which mixed a place to + * go, a thing to make, a panel to toggle, a file to attach and a sheet of + * everything left over. Five slots of the most valuable chrome in the app, + * spent on a list with no idea in it. + * + * What the app actually does is run agent sessions, and inside one there are + * four places worth being: the conversation, what the agent did about it, the + * files it did that to, and a shell in the same directory. Plus the other + * sessions. Those are the destinations, and the bar is where you switch + * between them — so it paints which one you are on, the way a bar that says + * "you are here" is supposed to. + * + * Everything that is a *verb* — search, new session, rename, reconnect, the + * display settings — is on the floating button instead. A control that changes + * where you are and a control that does something to where you are do not + * belong in the same row. + * + * Height is published as `--mobile-bar-height` so the toast stack and the + * terminal padding can both clear it without either duplicating the number. + */ + +export interface BottomNavDestination { + id: string; + label: string; + icon: string; + /** You are here. Exactly one of these should be true. */ + current?: boolean; + onGo(): void; + /** Draws attention without a count, e.g. output arrived somewhere else. */ + badge?: boolean; + disabled?: boolean; +} + +export interface BottomNavProps { + destinations: BottomNavDestination[]; + /** + * Hidden while the on-screen keyboard is up. + * + * The keyboard takes half the screen, and the half it leaves is the half you + * are typing into — which is the one moment nobody is navigating. The bar + * comes straight back when the field is done with. + */ + hidden?: boolean; +} + +export function BottomNav({ destinations, hidden = false }: BottomNavProps): React.JSX.Element | null { + if (hidden || destinations.length === 0) return null; + + return ( + + ); +} diff --git a/src/client/shell/FloatingMenu.tsx b/src/client/shell/FloatingMenu.tsx new file mode 100644 index 0000000..c9a5a11 --- /dev/null +++ b/src/client/shell/FloatingMenu.tsx @@ -0,0 +1,275 @@ +import * as React from 'react'; + +import { Icon } from '../ui/relay/Icon'; +import { PHONE_TEXT, TOUCH_GAP, TOUCH_TARGET } from '../ui/touch'; + +/** + * The phone's one piece of permanent chrome: a square button in the bottom + * right, and everything else behind it. + * + * It replaced the bottom bar. A five-slot bar is 56px plus the safe-area inset + * of screen that the conversation never gets back, on a surface whose whole + * purpose is the conversation — and it could only ever show five things, so + * every other control had to live in a sheet reached *through* it anyway. A + * button that is 56px square and floats over the transcript costs the corner it + * covers and nothing else. + * + * Open, it is a column of labelled rows rising from the button. Rows rather + * than a grid because the labels are what make the icons legible, and a phone + * has height to give a list and no width to give a grid. + */ + +export interface FloatingMenuAction { + id: string; + label: string; + icon: string; + onPress(): void; + /** Paints the row as the current state of something, e.g. an open panel. */ + active?: boolean; + /** Opens a panel or sheet rather than navigating. */ + expands?: boolean; + /** Toggles something visible. Announced as pressed/not-pressed. */ + toggle?: boolean; + /** Draws attention without a count, e.g. background output arrived. */ + badge?: boolean; + disabled?: boolean; + /** Rows are drawn grouped by this, newest group nearest the button. */ + group?: string; +} + +export interface FloatingMenuProps { + actions: FloatingMenuAction[]; + /** Sits above whatever owns the bottom edge, e.g. the on-screen key strip. */ + bottomOffset?: number; +} + +const SIDE = 56; + +export function FloatingMenu({ actions, bottomOffset = 0 }: FloatingMenuProps): React.JSX.Element { + const [open, setOpen] = React.useState(false); + const alerting = actions.some((action) => action.badge); + + // Escape closes it, and so does the surface underneath being replaced: a menu + // still standing over a conversation that has been switched away from is a + // list of controls for something that is no longer on screen. + React.useEffect(() => { + if (!open) return; + const onKey = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') return; + event.stopPropagation(); + setOpen(false); + }; + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); + }, [open]); + + const groups: Array<[string, FloatingMenuAction[]]> = []; + for (const action of actions) { + const key = action.group ?? ''; + const last = groups[groups.length - 1]; + if (last && last[0] === key) last[1].push(action); + else groups.push([key, [action]]); + } + + return ( + <> + {/* Tapping anywhere else closes it. Not a `Dialog`: this must not take + focus away from the composer, because the commonest reason to open it + is to reach for something mid-sentence. */} + {open ? ( + + + + ); +} + +function MenuRow({ + action, + onDone, +}: { + action: FloatingMenuAction; + onDone: () => void; +}): React.JSX.Element { + return ( + + ); +} diff --git a/src/client/shell/MobileBar.tsx b/src/client/shell/MobileBar.tsx deleted file mode 100644 index 0a44f9a..0000000 --- a/src/client/shell/MobileBar.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import * as React from 'react'; - -import { Icon } from '../ui/relay/Icon'; - -export interface MobileBarAction { - id: string; - label: string; - icon: string; - onPress(): void; - /** Paints the item as current, the way a native tab bar marks its section. */ - active?: boolean; - /** - * Set when the item opens a panel rather than navigating. `active` alone - * announced "current item in a set", which is not what a button that opens a - * sheet does. - */ - expands?: boolean; - /** - * Set when the item toggles something visible rather than opening a panel - * (e.g. the key strip). It is announced as pressed/not-pressed — neither a - * dialog disclosure nor a current section. - */ - toggle?: boolean; - /** Draws attention without a count, e.g. background output arrived. */ - badge?: boolean; - disabled?: boolean; -} - -export interface MobileBarProps { - actions: MobileBarAction[]; -} - -/** - * The mobile bottom bar. - * - * Fixed to the bottom edge with a safe-area inset, five items wide, icon over - * label — the shape a phone user already knows. It replaces the hamburger - * drawer and the two floating buttons that used to sit over the terminal: on a - * phone the agent's own output is the thing you are reading, and controls - * floating on top of it hid the last lines exactly when they mattered. - * - * Height is published as `--mobile-bar-height` so the toast stack and the - * terminal padding can both clear it without either duplicating the number. - */ -export function MobileBar({ actions }: MobileBarProps): React.JSX.Element { - return ( - - ); -} diff --git a/src/client/shell/TabSwitcherSheet.tsx b/src/client/shell/TabSwitcherSheet.tsx index 8b78ffe..e598575 100644 --- a/src/client/shell/TabSwitcherSheet.tsx +++ b/src/client/shell/TabSwitcherSheet.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { Button } from '../ui/relay/Button'; import { Dialog } from '../ui/relay/Dialog'; import { Icon } from '../ui/relay/Icon'; +import { TOUCH_TARGET } from '../ui/touch'; import { IconButton } from '../ui/relay/IconButton'; import type { ShellTab } from './store'; @@ -80,6 +81,9 @@ function TabRow({ flexDirection: 'column', justifyContent: 'center', gap: 3, + // Stretching to the row was not enough on its own: a row of one short + // line is 38px tall, which is under what a thumb reliably hits. + minHeight: TOUCH_TARGET, border: 'none', background: 'transparent', color: 'var(--foreground)', @@ -134,8 +138,12 @@ function TabRow({ ) : null} - - + + ); @@ -171,10 +179,24 @@ export function TabSwitcherSheet({ onClose={onClose} footer={
- -
diff --git a/src/client/shell/chat/ActivityTimeline.tsx b/src/client/shell/chat/ActivityTimeline.tsx index 5f83660..db36eb3 100644 --- a/src/client/shell/chat/ActivityTimeline.tsx +++ b/src/client/shell/chat/ActivityTimeline.tsx @@ -9,6 +9,7 @@ import { import type { ActivityFilterId } from '../../chat/view-settings.js'; import { KIND_ICON, TOOL_STATUS } from '../../chat/tool-meta.js'; import { Icon } from '../../ui/relay/Icon.js'; +import { PHONE_TEXT, TOUCH_GAP, TOUCH_TARGET, usePhone } from '../../ui/touch.js'; import { Markdown } from './Markdown.js'; import { ToolCallCard } from './ToolCallCard.js'; @@ -59,6 +60,7 @@ export function ActivityTimeline({ const [expanded, setExpanded] = React.useState>(() => new Set()); const scroller = React.useRef(null); const rows = React.useRef(new Map()); + const isPhone = usePhone(); const filtered = React.useMemo(() => filterActivity(events, filter), [events, filter]); @@ -122,9 +124,11 @@ export function ActivityTimeline({ flex: '0 0 auto', display: 'flex', alignItems: 'center', - gap: 4, - height: 28, - padding: '0 12px', + flexWrap: isPhone ? 'wrap' : 'nowrap', + gap: isPhone ? TOUCH_GAP : 4, + height: isPhone ? undefined : 28, + minHeight: isPhone ? TOUCH_TARGET + 8 : undefined, + padding: isPhone ? '4px 12px' : '0 12px', borderBottom: '1px solid var(--border)', }} > @@ -141,7 +145,7 @@ export function ActivityTimeline({ marginLeft: 'auto', flex: '0 0 auto', fontFamily: 'var(--font-mono)', - fontSize: 10, + fontSize: isPhone ? PHONE_TEXT.meta : 10, color: 'var(--muted-foreground)', whiteSpace: 'nowrap', }} @@ -197,12 +201,13 @@ export function ActivityTimeline({ style={{ alignSelf: 'flex-start', marginBottom: 4, - padding: '2px 6px', + minHeight: isPhone ? TOUCH_TARGET : undefined, + padding: isPhone ? '0 12px' : '2px 6px', background: 'transparent', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontFamily: 'var(--font-mono)', - fontSize: 10, + fontSize: isPhone ? PHONE_TEXT.body : 10, color: 'var(--muted-foreground)', cursor: 'pointer', }} @@ -246,6 +251,7 @@ function FilterChip({ onClick: () => void; }): React.JSX.Element { const [hover, setHover] = React.useState(false); + const isPhone = usePhone(); return ( ) : null} diff --git a/src/client/shell/chat/AgentPopup.tsx b/src/client/shell/chat/AgentPopup.tsx new file mode 100644 index 0000000..4881fc2 --- /dev/null +++ b/src/client/shell/chat/AgentPopup.tsx @@ -0,0 +1,354 @@ +import * as React from 'react'; +import { findToolBlock } from '../../../shared/agent-activity.js'; +import type { AgentRun, AgentStep, ToolBlock } from '../../../shared/chat-events.js'; +import type { ChatTranscript } from '../../chat/transcript.js'; +import { KIND_ICON, TOOL_STATUS, compactCount, formatDuration, summarize } from '../../chat/tool-meta.js'; +import { Badge } from '../../ui/relay/Badge.js'; +import { Dialog } from '../../ui/relay/Dialog.js'; +import { Icon } from '../../ui/relay/Icon.js'; +import { STATUS_META } from './AgentsPanel.js'; + +/** + * What a delegated agent is doing inside its own work. + * + * The agents list can only ever say "running" or "done" — the run's own steps + * arrive out of band from the tool call that started it (see `AgentRun`), and + * before this they were dropped on the floor. This is where they are shown: + * the step it is on, the tools it reached for, what each one returned, and any + * failure inside the run that the delegation's own status would hide. + * + * Shares the file editor's dialog chrome — movable, resizable, fill-the-window + * — because a run worth watching is a run worth putting beside the + * conversation, which is the same argument the file popup already won. + */ + +export interface AgentPopupProps { + open: boolean; + transcript: ChatTranscript; + /** Ignored while `open` is false, so closing never has to be a valid id. */ + toolId: string; + onClose: () => void; + isMobile?: boolean; +} + +const TERMINAL = new Set(['completed', 'failed', 'denied', 'canceled']); + +export function AgentPopup({ + open, + transcript, + toolId, + onClose, + isMobile = false, +}: AgentPopupProps): React.JSX.Element | null { + // The live tier. A step lands as an `agent_step`, which patches a block in + // place and never grows the message list, so the panel's own `subscribe` + // tier never hears about it — this popup would freeze at whatever the run + // had done the moment it was opened. + const version = React.useSyncExternalStore( + transcript.subscribeContent, + transcript.getContentVersion, + ZERO, + ); + + const block: ToolBlock | null = React.useMemo( + () => (open ? findToolBlock(transcript.messages, toolId) : null), + // eslint-disable-next-line react-hooks/exhaustive-deps + [transcript, toolId, open, version], + ); + + const run: AgentRun | null = block?.agent ?? null; + const running = block ? !TERMINAL.has(block.status) : false; + + const bodyRef = React.useRef(null); + const stickToBottom = React.useRef(true); + const stepCount = run?.steps.length ?? 0; + + React.useEffect(() => { + const el = bodyRef.current; + if (!el || !running || !stickToBottom.current) return; + el.scrollTop = el.scrollHeight; + }, [stepCount, run?.activity, running]); + + if (!open) return null; + + const meta = block ? STATUS_META[block.status] || STATUS_META.pending : null; + const name = describeAgent(block); + + return ( + + {name} + {meta ? ( + + {meta.label} + + ) : null} + + } + onClose={onClose} + movable + placement={isMobile ? 'bottom' : 'center'} + height={isMobile ? undefined : 'min(78dvh, 860px)'} + bodyFill + width={720} + > +
{ + const el = event.currentTarget; + // Once the user scrolls up to read an earlier step, a new one must + // not yank the view back down mid-sentence. + stickToBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24; + }} + style={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }} + > + {!block ? ( + + ) : ( + <> +
+ {run && run.steps.length > 0 ? ( + run.steps.map((step) => ) + ) : ( + + )} + {block.output ? : null} + + )} +
+
+ ); +} + +const ZERO = (): number => 0; + +/** The delegation's own name: the sub-agent type, else its description. */ +function describeAgent(block: ToolBlock | null): string { + if (!block) return 'Agent'; + const input = block.input && typeof block.input === 'object' && !Array.isArray(block.input) + ? (block.input as Record) + : null; + const type = block.agent?.subagentType; + const described = input + ? ['subagent_type', 'agentType', 'description', 'name'].reduce( + (found, key) => found || (typeof input[key] === 'string' ? (input[key] as string) : ''), + '', + ) + : ''; + return described || type || block.title || block.name || 'Agent'; +} + +/** + * What the agent is doing right now, and what it has cost so far. + * + * Kept above the steps rather than folded into them: the run reports its own + * progress ("Reading hello.txt") separately from the calls it makes, and while + * it is thinking between two tools that line is the only thing that moves. + */ +function Header({ + run, + block, + running, +}: { + run: AgentRun | null; + block: ToolBlock; + running: boolean; +}): React.JSX.Element { + const stats = [ + run?.toolUses !== undefined ? `${run.toolUses} tool${run.toolUses === 1 ? '' : 's'}` : '', + run?.totalTokens ? `${compactCount(run.totalTokens)} tokens` : '', + run?.durationMs ? formatDuration(run.durationMs) : '', + ].filter(Boolean); + + // The run can fail on its own terms while every step it managed succeeded, + // so its error is shown here rather than left to a badge on the row. + const failure = run?.error || (block.status === 'failed' ? block.error : undefined); + + return ( +
+ {run?.activity ? ( +
+ {running ? ( + + + + ) : null} + {run.activity} +
+ ) : null} + {run?.prompt ? ( +
+ {run.prompt} +
+ ) : null} + {stats.length > 0 ? ( +
+ {stats.join(' · ')} +
+ ) : null} + {failure ? : null} +
+ ); +} + +function Step({ step }: { step: AgentStep }): React.JSX.Element { + const status = TOOL_STATUS[step.status] || TOOL_STATUS.pending; + // `summarize` reads a ToolBlock; a step carries the same fields it looks at, + // so the step list describes a call exactly the way the trace does. + const detail = summarize({ kind: 'tool', toolId: step.id, ...step } as ToolBlock); + + return ( +
+ + + +
+
+ + {step.name} + + {status.badge ? {status.label} : null} +
+ {detail ? ( +
+ {detail} +
+ ) : null} + {step.error ? : null} +
+
+ ); +} + +/** + * A failure, spelled out. + * + * The whole complaint behind this view is that a failure inside an agent's own + * work showed up as a generic badge, so the message itself is rendered rather + * than a colour and a word. + */ +function Failure({ text }: { text: string }): React.JSX.Element { + return ( +
+      {text}
+    
+ ); +} + +function Result({ text, failed }: { text: string; failed: boolean }): React.JSX.Element { + return ( +
+
+ {failed ? 'Failed with' : 'Reported back'} +
+
+        {text}
+      
+
+ ); +} + +function Empty({ text }: { text: string }): React.JSX.Element { + return ( +
+ {text} +
+ ); +} diff --git a/src/client/shell/chat/AgentsPanel.tsx b/src/client/shell/chat/AgentsPanel.tsx index ceb5118..5ec2b0d 100644 --- a/src/client/shell/chat/AgentsPanel.tsx +++ b/src/client/shell/chat/AgentsPanel.tsx @@ -1,6 +1,10 @@ import * as React from 'react'; import type { ToolStatus } from '../../../shared/chat-events.js'; -import { collectAgentActivity, type AgentActivity } from '../../../shared/agent-activity.js'; +import { + collectAgentActivity, + type AgentActivity, + type AgentActivityKind, +} from '../../../shared/agent-activity.js'; import type { ChatTranscript } from '../../chat/transcript.js'; import { Badge, type BadgeVariant } from '../../ui/relay/Badge.js'; import { Icon } from '../../ui/relay/Icon.js'; @@ -19,7 +23,7 @@ import { PanelBody, PanelHeader, PanelNote } from './PanelShell.js'; * exists to avoid. */ -const STATUS_META: Record = { +export const STATUS_META: Record = { pending: { label: 'queued', variant: 'outline' }, running: { label: 'running', variant: 'warning', pulse: true }, completed: { label: 'done', variant: 'success' }, @@ -30,9 +34,18 @@ const STATUS_META: Record void; } -export function AgentsPanel({ transcript }: AgentsPanelProps): React.JSX.Element { +export function AgentsPanel({ transcript, onOpenDelegation }: AgentsPanelProps): React.JSX.Element { const version = React.useSyncExternalStore( transcript.subscribe, transcript.getVersion, @@ -65,7 +78,7 @@ export function AgentsPanel({ transcript }: AgentsPanelProps): React.JSX.Element ) : null} {running.map((entry) => ( - + ))} {finished.length > 0 && running.length > 0 ? (
) : null} {finished.map((entry) => ( - + ))} @@ -91,11 +104,31 @@ export function AgentsPanel({ transcript }: AgentsPanelProps): React.JSX.Element const ZERO = (): number => 0; -function ActivityRow({ entry }: { entry: AgentActivity }): React.JSX.Element { +function ActivityRow({ + entry, + onOpen, +}: { + entry: AgentActivity; + onOpen: (toolId: string, kind: AgentActivityKind) => void; +}): React.JSX.Element { const meta = STATUS_META[entry.status] || STATUS_META.pending; + // Every delegation opens, workflows and sub-agents alike. The steps a + // sub-agent reports come from events the runtime already sends for all of + // them, so there is nothing to save by withholding the view from short ones + // — which was issue #44's open question. + const open = (): void => onOpen(entry.toolId, entry.kind); return (
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + open(); + } + }} style={{ display: 'flex', alignItems: 'flex-start', @@ -103,6 +136,7 @@ function ActivityRow({ entry }: { entry: AgentActivity }): React.JSX.Element { padding: '7px 10px', borderBottom: '1px solid var(--border)', opacity: entry.running ? 1 : 0.75, + cursor: 'pointer', }} > = new Set(['starting', 'thinking', 'running', 'awaiting_permission']); +const BUSY_STATES: ReadonlySet = new Set([ + 'starting', + 'thinking', + 'running', + 'awaiting_permission', + 'awaiting_answer', +]); function stateDotColor(state: ChatState | undefined): string { if (state === 'error') return 'var(--destructive)'; if (state === 'awaiting_permission') return 'var(--warning)'; + if (state === 'awaiting_answer') return 'var(--info)'; if (state && BUSY_STATES.has(state)) return 'var(--ansi-green)'; return 'var(--muted-foreground)'; } @@ -64,6 +71,7 @@ function stateDotColor(state: ChatState | undefined): string { function stateLabel(state: ChatState | undefined): string | undefined { switch (state) { case 'awaiting_permission': return 'needs approval'; + case 'awaiting_answer': return 'asked you something'; case 'error': return 'error'; case 'running': return 'running'; case 'thinking': return 'thinking'; @@ -321,7 +329,13 @@ function SessionRow({ {label ? ( {label} diff --git a/src/client/shell/chat/ChatView.tsx b/src/client/shell/chat/ChatView.tsx index 77f4afd..8486697 100644 --- a/src/client/shell/chat/ChatView.tsx +++ b/src/client/shell/chat/ChatView.tsx @@ -4,6 +4,7 @@ import { uploadAttachment } from '../../chat/attachments-api.js'; import { ChatController, type ChatUnavailable } from '../../chat/controller.js'; import { fetchStatus, findFiles } from '../../chat/workspace-api.js'; import { activityEvents } from '../../chat/activity.js'; +import type { ChatTranscript } from '../../chat/transcript.js'; import { groupTurns, isTurnOpen, turnOf, type TurnSummary } from '../../chat/turns.js'; import type { ChatPanelId, ChatViewSettings } from '../../chat/view-settings.js'; import { @@ -16,10 +17,13 @@ import { Button } from '../../ui/relay/Button.js'; import { Icon } from '../../ui/relay/Icon.js'; import { IconButton } from '../../ui/relay/IconButton.js'; import { showNotification } from '../../ui/notifications.js'; +import { PhoneContext } from '../../ui/touch.js'; +import { FloatingMenu } from '../FloatingMenu.js'; import { Composer } from './Composer.js'; import { MessageList, type MessageListHandle } from './MessageList.js'; -import { messageText } from './MessageBubble.js'; +import { hasVisibleContent, messageText } from './MessageBubble.js'; import { PermissionCard } from './PermissionCard.js'; +import { QuestionCard } from './QuestionCard.js'; import { PlanPanel } from './PlanPanel.js'; import { SessionHeader } from './SessionHeader.js'; import { LiveStreamRibbon } from './StreamRibbon.js'; @@ -72,6 +76,29 @@ export interface ChatViewProps { onViewChange?: (next: ChatViewSettings) => void; theme?: 'dark' | 'light'; onToggleTheme?: () => void; + /** + * The shell's own menu entries — sessions, new, more. + * + * Passed down rather than this surface's being passed up, because the menu + * has to be positioned by whoever knows where the composer is. Anchored to + * the shell it sat exactly on top of the send button; anchored to the + * transcript it floats over the conversation and clears everything. + */ + menuActions?: SurfaceAction[]; +} + +/** One control on the phone's floating menu. */ +export interface SurfaceAction { + id: string; + label: string; + icon: string; + onPress(): void; + active?: boolean; + expands?: boolean; + toggle?: boolean; + badge?: boolean; + disabled?: boolean; + group?: string; } /** @@ -102,6 +129,7 @@ export function ChatView({ onViewChange, theme, onToggleTheme, + menuActions, }: ChatViewProps) { const transcript = controller.transcript; @@ -125,6 +153,19 @@ export function ChatView({ const bypassPermissions = transcript.bypassing; const plan = transcript.plan; const pending = transcript.pendingPermissions; + // Only the questions that have nowhere else to be drawn. A question that + // names the call that asked it renders inside the conversation at that call, + // which is where it was asked; this is the safety net for one that could not + // be correlated, and without it that question would have no button anywhere + // and the turn behind it would never move. + const strayQuestions = React.useMemo( + () => + transcript.pendingQuestions.filter( + (request) => !request.toolId || !transcript.message(messageIdOfTool(transcript, request.toolId)), + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [transcript, version], + ); const exited = chatState === 'exited'; const unavailable = controller.unavailableReason; const busy = transcript.busy; @@ -204,11 +245,6 @@ export function ChatView({ // leave the timeline's effect with nothing to react to. const [focus, setFocus] = React.useState<{ id?: string; nonce: number }>({ nonce: 0 }); const [indexSheet, setIndexSheet] = React.useState(false); - // The rail's persisted flag is a desktop preference. On a phone the rail is - // the whole screen, so opening it is a gesture that happens *now* — a stored - // `true` would mean every conversation opened onto a panel with the - // conversation hidden behind it. - const [mobileRail, setMobileRail] = React.useState(false); const [planSheet, setPlanSheet] = React.useState(false); // A draft seed, not a controlled value: making the composer controlled would // re-render this component — and re-derive the turns and the whole activity @@ -234,22 +270,24 @@ export function ChatView({ // ------------------------------------------------------------------ zones const panelsAvailable = enabledPanels(view).length > 0; - const railOpen = panelsAvailable && (isMobile ? mobileRail : view.panelOpen); + // One answer for both, so the phone's bottom bar can drive it. It used to be + // separate local state on a phone, which meant the rail was open according to + // this component and shut according to everything outside it — including the + // bar that now has to paint which destination you are on. + const railOpen = panelsAvailable && view.panelOpen; // On a phone the rail replaces the conversation rather than sitting beside // it: 344px of panel next to a 390px viewport leaves neither usable. const railTakesOver = isMobile && railOpen; const toggleRail = React.useCallback(() => { - if (isMobile) setMobileRail((open) => !open); - else setView({ panelOpen: !view.panelOpen }); - }, [isMobile, setView, view.panelOpen]); + setView({ panelOpen: !view.panelOpen }); + }, [setView, view.panelOpen]); const openRail = React.useCallback( (panelTab: ChatPanelId) => { - if (isMobile) setMobileRail(true); if (!view.panelOpen || view.panelTab !== panelTab) setView({ panelOpen: true, panelTab }); }, - [isMobile, setView, view.panelOpen, view.panelTab], + [setView, view.panelOpen, view.panelTab], ); const narrow = width > 0 && width < HIDE_INDEX_AT; @@ -280,6 +318,11 @@ export function ChatView({ (requestId: string, optionId: string) => controller.respondPermission(requestId, optionId), [controller], ); + const answerQuestion = React.useCallback( + (requestId: string, optionIds: string[], skipped: boolean) => + controller.answerQuestion(requestId, optionIds, skipped), + [controller], + ); const cancelQueued = React.useCallback( (queuedId: string) => controller.cancelQueued(queuedId), [controller], @@ -344,13 +387,18 @@ export function ChatView({ (messageId: string) => { const turn = turnOf(messageId, turns); if (turn) openTurn(turn.id); + // A step that only ran commands has no row of its own (issue #46), and a + // trace row or a search hit pointing at one would scroll to an element + // that is not in the document — a click that does nothing at all. The + // reply that speaks for it is the row to land on instead. + const target = rowFor(messageId, turn, transcript); // A turn that was just opened has not painted yet — its bubbles have no // layout box to scroll to until the next tick. A turn already open // scrolls one tick later than it strictly needs to, which nothing on // screen can tell apart from immediate. - window.setTimeout(() => list.current?.scrollToMessage(messageId), 0); + window.setTimeout(() => list.current?.scrollToMessage(target), 0); }, - [turns, openTurn], + [turns, openTurn, transcript], ); const copyTurn = React.useCallback( @@ -493,7 +541,36 @@ export function ChatView({ /> ); + const openIndex = React.useCallback(() => { + // One control, two jobs, decided by whether the index has room to be a rail + // at all. Below 1024px — and on every phone — there is no rail to toggle, + // so the same control opens the sheet instead of writing a setting whose + // effect nothing on screen would show. + if (indexVisible || !(isMobile || narrow)) setView({ indexOpen: !view.indexOpen }); + else setIndexSheet(true); + }, [indexVisible, isMobile, narrow, setView, view.indexOpen]); + + // This surface's own controls, then the shell's. The ones somebody opened the + // menu mid-conversation for come first, nearest the button they pressed. + const phoneMenu: SurfaceAction[] = React.useMemo( + () => [ + // Things you do to this conversation. Where you can *go* from it — the + // trace, the files, the shell, the other sessions — is the bottom bar's + // job, and repeating those here would be two answers to one question. + { id: 'chat-search', label: 'Search this conversation', icon: 'search', expands: true, group: 'surface', onPress: () => setSearchOpen(true) }, + { id: 'chat-turns', label: 'Jump to a turn', icon: 'panel-left', active: indexVisible || indexSheet, expands: true, group: 'surface', onPress: openIndex }, + { id: 'chat-display', label: 'Display settings', icon: 'settings', expands: true, group: 'surface', onPress: () => onOpenSettings?.() }, + ...(menuActions ?? []).map((action) => ({ ...action, group: 'session' })), + ], + [indexVisible, indexSheet, openIndex, onOpenSettings, menuActions], + ); + return ( + // The phone answer, published once for the whole surface. Everything below + // — down to a copy button inside a tool call — reads it from here rather + // than from a prop threaded through five components, any one of which could + // drop it and leave that corner at desktop sizes. See ui/touch.ts. +
} aria-label={`${runtimeLabel} chat`} @@ -537,11 +614,7 @@ export function ChatView({ // rail at all. Below 1024px — and on every phone — there is no rail to // toggle, so the same button opens the sheet instead of writing a // setting whose effect nothing on screen would show. - onToggleIndex={ - indexVisible || !(isMobile || narrow) - ? () => setView({ indexOpen: !view.indexOpen }) - : () => setIndexSheet(true) - } + onToggleIndex={openIndex} onOpenSearch={() => setSearchOpen(true)} onOpenSettings={() => onOpenSettings?.()} onToggleTheme={onToggleTheme} @@ -591,6 +664,11 @@ export function ChatView({ position: 'relative', }} > + {/* Over the conversation, clear of everything else. Anchored to the + transcript rather than to the shell: at the shell's bottom right + it sat exactly on the send button. */} + {isMobile ? : null} + {searchOpen ? ( {terminalOpen ? ( @@ -642,12 +721,12 @@ export function ChatView({ settings={view} trace={trace} onSelectTab={(panelTab: ChatPanelId) => setView({ panelTab })} - onClose={() => setMobileRail(false)} + onClose={() => setView({ panelOpen: false })} isMobile /> ) : null} - {busy || chatState === 'awaiting_permission' || chatState === 'error' ? ( + {busy || chatState === 'awaiting_permission' || chatState === 'awaiting_answer' || chatState === 'error' ? ( ) : null} + {strayQuestions.length > 0 ? ( +
+ {strayQuestions.map((request) => ( + + ))} +
+ ) : null} + {pending.length > 0 ? ( // assertive, not polite: nothing else the user does will move the // session forward until one of these is answered. @@ -767,7 +867,7 @@ export function ChatView({ // want to type the follow-up — while the agent waits on you — was // the one moment you could not. It queues instead. disabled={exited || Boolean(unavailable)} - placeholder={placeholderFor(chatState, runtimeLabel)} + placeholder={placeholderFor(chatState, runtimeLabel, isMobile)} queued={transcript.queuedTurns} onCancelQueued={cancelQueued} onFindFiles={findProjectFiles} @@ -845,24 +945,64 @@ export function ChatView({
+ ); } const ZERO = (): number => 0; +/** + * The row that stands for a message on screen. + * + * Itself, unless it is a step that said nothing — then the next message in its + * turn that did, because that is the one carrying its work pill. Failing that, + * the last one before it, so a turn whose tail is all machinery still lands + * somewhere. Falls back to the id given, which scrolls nowhere and is exactly + * what happened before this existed. + * + * Exported so the rule can be asserted directly: everything it guards happens + * inside a `setTimeout` around a scroll, which a rendered tree cannot show. + */ +export function rowFor( + messageId: string, + turn: TurnSummary | undefined, + transcript: ChatTranscript, +): string { + const message = transcript.message(messageId); + if (!turn || !message || hasVisibleContent(message)) return messageId; + + const ids = turn.messageIds; + const at = ids.indexOf(messageId); + if (at < 0) return messageId; + + const visible = (id: string): boolean => { + const other = transcript.message(id); + return Boolean(other && hasVisibleContent(other)); + }; + const after = ids.slice(at + 1).find(visible); + if (after) return after; + const before = ids.slice(0, at).filter(visible).pop(); + return before || messageId; +} + function viewportHeight(): number { return typeof window === 'undefined' ? 900 : window.innerHeight; } -function placeholderFor(state: ChatState, runtimeLabel: string): string { +function placeholderFor(state: ChatState, runtimeLabel: string, isPhone = false): string { if (state === 'exited') return 'This session has ended'; // No longer 'answer this before you can type': the composer stays live and // queues, so the sentence has to describe what happens rather than forbid it. - if (state === 'awaiting_permission') return 'Answer above — or type the next message'; + if (state === 'awaiting_permission' || state === 'awaiting_answer') { + return isPhone ? 'Answer above, or type' : 'Answer above — or type the next message'; + } if (state === 'thinking' || state === 'running') { - return `Queue a follow-up while ${runtimeLabel} works…`; + // Short enough for the one line a phone's field is. The long form wrapped + // to two, which grew the composer by 26px for a sentence saying what the + // ribbon directly above it already says. + return isPhone ? 'Queue a follow-up…' : `Queue a follow-up while ${runtimeLabel} works…`; } - return `Message ${runtimeLabel}…`; + return isPhone ? 'Message…' : `Message ${runtimeLabel}…`; } /** @@ -1164,3 +1304,19 @@ function mobileBarHeight(): number { return Number.isFinite(parsed) ? parsed : MOBILE_BAR_FALLBACK_PX; } +/** + * Which message holds a given tool call, or '' when the transcript has none. + * + * Used only to decide whether a pending question already has a card inside the + * conversation. A question whose call is above the loaded window — or whose call + * never arrived — needs the pinned card instead, and asking the transcript is + * the only way to tell those apart from one that is on screen. + */ +function messageIdOfTool(transcript: ChatTranscript, toolId: string): string { + for (const message of transcript.messages) { + for (const block of message.blocks) { + if (block.kind === 'tool' && block.toolId === toolId) return message.id; + } + } + return ''; +} diff --git a/src/client/shell/chat/Composer.tsx b/src/client/shell/chat/Composer.tsx index 30f4c73..d736b89 100644 --- a/src/client/shell/chat/Composer.tsx +++ b/src/client/shell/chat/Composer.tsx @@ -12,6 +12,7 @@ import { mentionAtCaret } from '../../../shared/file-match.js'; import { classifyPaste, MAX_IMAGES_PER_PASTE, PasteCandidate } from '../../../shared/paste-classify.js'; import { MAX_IMAGE_BYTES } from '../../terminal/paste.js'; import { detectMobile } from '../../ui/mobile.js'; +import { PHONE_TEXT, PhoneContext, TOUCH_GAP, TOUCH_TARGET, usePhone } from '../../ui/touch.js'; import { showNotification } from '../../ui/notifications.js'; import { Icon } from '../../ui/relay/Icon.js'; import { IconButton } from '../../ui/relay/IconButton.js'; @@ -185,7 +186,22 @@ export function Composer({ /** Where to put the caret after a completion rewrites the draft. */ const pendingCaret = React.useRef(null); - const [isMobile, setIsMobile] = React.useState(safeDetectMobile); + /** + * The surface's answer wins over this component's own. + * + * The composer used to decide "am I on a phone?" by calling `detectMobile()` + * itself, which made it the app's second source for that answer and put it + * out of reach of anything that renders the surface deliberately — a check at + * a phone viewport in headless Chrome has no touch points, so the real + * composer could never be examined at the size it actually ships at. + * + * The local detection stays as the fallback: `Composer` is also rendered on + * its own, outside any `PhoneContext`, and Enter's two jobs (send versus + * newline) still have to be decided correctly there. + */ + const surfaceIsPhone = usePhone(); + const [detectedMobile, setIsMobile] = React.useState(safeDetectMobile); + const isMobile = surfaceIsPhone || detectedMobile; const [uncontrolledText, setUncontrolledText] = React.useState(''); const [entries, setEntries] = React.useState([]); const [dragActive, setDragActive] = React.useState(false); @@ -204,6 +220,15 @@ export function Composer({ * draft — it opens the list and lets the completion do the rewriting. */ const [commandsForced, setCommandsForced] = React.useState(false); + /** + * Whether the phone's secondary controls are showing. + * + * Attach, the two pickers, the model and the approvals readout are five + * controls that a phone has no room for beside the field and no reason to + * show while you are typing — the two that matter mid-sentence are send and + * stop. Shut by default, and the room goes to the conversation. + */ + const [toolsOpen, setToolsOpen] = React.useState(false); // A tablet rotated into portrait, or a touch laptop window resized, changes // which of Enter's two jobs (send vs newline) is correct — see mobile.ts. @@ -610,8 +635,21 @@ export function Composer({ }; const sendLabel = busy ? 'Queue this message' : 'Send message'; + /** + * The phone's resting shape: the field and its buttons on one line. + * + * Only while the extra controls are shut. Open, they are five more chips that + * have nowhere to go on a shared line, so the column comes back. + */ + const phoneCollapsed = isMobile && !toolsOpen; return ( + // Re-published rather than merely consumed: `isMobile` here is the surface's + // answer *or* this component's own detection, and every control below — + // the chips, the model picker, send and stop — has to size itself from the + // same one. Composer mounted on its own on a real phone would otherwise + // send with Enter like a phone and draw its buttons like a desktop. +
@@ -655,6 +693,19 @@ export function Composer({
) : null} + {/* One line, not two, while the extra controls are shut. + `display: contents` everywhere else, so the desktop keeps the column + it has: the field over its own row of actions, which is what stops the + buttons floating in the middle of a twelve-line draft. On a phone + collapsed there is no twelve-line draft to float in and the second row + was 52px the conversation could have had. */} +