From b63183d70ebeb720db4e70389eb3d76b75df272f Mon Sep 17 00:00:00 2001 From: Ashish Kumar Date: Fri, 25 Sep 2026 17:40:27 -0700 Subject: [PATCH 1/2] feat: polish mobile session controls Recover recent mobile UX improvements, prevent heartbeat scroll jumps, add direct Discover navigation, centralize tool presentation, and deepen slash-command arguments with curated model selection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 10 +- docs/index.html | 2 + extension/src/relay.mjs | 20 +- extension/test/relay.test.mjs | 41 ++++ mobile/src/app/App.tsx | 42 +++- mobile/src/app/__tests__/App.test.tsx | 30 ++- mobile/src/ui/composer/Composer.tsx | 102 +++++++--- .../ui/composer/__tests__/Composer.test.tsx | 82 ++++++++ .../composer/__tests__/commandPalette.test.ts | 76 ++++++++ mobile/src/ui/composer/commandPalette.ts | 67 +++++++ mobile/src/ui/explore/ExploreScreen.tsx | 159 ++++++++------- .../explore/__tests__/ExploreScreen.test.tsx | 98 +++++++++- mobile/src/ui/screens/SessionScreen.tsx | 184 ++++++++++++++++-- mobile/src/ui/screens/StartSessionScreen.tsx | 50 ++++- .../screens/__tests__/SessionScreen.test.tsx | 123 +++++++++++- .../__tests__/StartSessionScreen.test.tsx | 47 +++++ mobile/src/ui/styles/chat.css | 166 +++++++++++++++- mobile/src/ui/styles/explore.css | 143 ++++++++++---- mobile/src/ui/thread/ChatThread.tsx | 74 ++++++- mobile/src/ui/thread/Markdown.tsx | 10 +- mobile/src/ui/thread/ToolCard.tsx | 141 +++++++++----- .../ui/thread/__tests__/ChatThread.test.tsx | 31 ++- .../src/ui/thread/__tests__/Markdown.test.tsx | 36 ++++ .../src/ui/thread/__tests__/ToolCard.test.tsx | 40 ++++ .../tools/__tests__/toolPresentation.test.ts | 42 ++++ mobile/src/ui/tools/toolPresentation.ts | 76 ++++++++ .../.openspec.yaml | 2 + .../design.md | 91 +++++++++ .../proposal.md | 30 +++ .../mobile-session-command-experience/spec.md | 87 +++++++++ .../tasks.md | 47 +++++ shared/commands.d.ts | 40 +++- shared/commands.mjs | 128 ++++++++++-- shared/test/commands.test.mjs | 74 ++++++- 34 files changed, 2126 insertions(+), 265 deletions(-) create mode 100644 mobile/src/ui/composer/__tests__/commandPalette.test.ts create mode 100644 mobile/src/ui/composer/commandPalette.ts create mode 100644 mobile/src/ui/thread/__tests__/Markdown.test.tsx create mode 100644 mobile/src/ui/tools/__tests__/toolPresentation.test.ts create mode 100644 mobile/src/ui/tools/toolPresentation.ts create mode 100644 openspec/changes/polish-session-command-experience/.openspec.yaml create mode 100644 openspec/changes/polish-session-command-experience/design.md create mode 100644 openspec/changes/polish-session-command-experience/proposal.md create mode 100644 openspec/changes/polish-session-command-experience/specs/mobile-session-command-experience/spec.md create mode 100644 openspec/changes/polish-session-command-experience/tasks.md diff --git a/README.md b/README.md index 9604d98..5655d0f 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,10 @@ while you use the phone. six images, and use Vox for hands-free prompting. - **Native approval relay** — allow once, allow for the session, or deny the exact action Copilot requested. -- **Session control** — stop an active turn, queue the next instruction, use supported slash - commands, and switch between interactive, plan, and autopilot modes. +- **Session control** — stop an active turn, queue the next instruction, and use supported slash + commands through one searchable command flow. Commands can run directly, request text, or offer + a curated option list; `/model` exposes supported model choices without leaking internal IDs. + Weft reports command failures from Copilot instead of claiming a model change succeeded. - **Multi-device workspace** — use one phone to move among paired laptops, registered projects, and active or historical sessions. - **Safe launch recovery** — reconnect to slow Start and Resume operations instead of @@ -112,7 +114,9 @@ while you use the phone. - **Shared terminal** — open and resume one real shell on supported Windows laptops. See [terminal controls and access boundaries](docs/terminal.md). - **Explore between turns** — use compact header tiles for Discover, Watch, Play, and - Unwind while real assistant and tool activity rolls through the bottom Copilot dock. + Unwind, or swipe left from the right edge of Chat to open Discover and return with one Back. + A concise Copilot presence tile shows the current activity and useful live response context + without turning Explore into a second debug log. Discover provides a balanced animated swipe deck of 50 offline cards, alongside two lightweight games, guided rest activities, and an optional explicitly loaded third-party short-video widget. diff --git a/docs/index.html b/docs/index.html index dce80c2..c868c34 100644 --- a/docs/index.html +++ b/docs/index.html @@ -139,6 +139,8 @@

Work
Approvals
Read and respond to the permission requests Copilot surfaces. A pending request can block progress until it is answered or the session ends; do not assume unattended requests expire automatically.
Interrupt
Use the session's stop control to request that the current turn stop. Keep the laptop connection available while it processes the request.
Modes
Choose interactive, plan, or autopilot when supported by the Copilot host. A mode change is not a replacement for the session's permission policy.
+
Slash commands
Choose a supported command from the composer. Commands can run immediately, request text, or offer a curated option list. For /model, Weft shows friendly model names while Copilot remains authoritative about whether the selection succeeds.
+
Explore
Open Discover, Watch, Play, or Unwind from the session header. On a phone, swipe left from the right edge of Chat to enter Discover directly; one Back returns to the same conversation. The Copilot tile keeps current session activity visible while you explore.
Weft session transcript with an inline Copilot permission request. diff --git a/extension/src/relay.mjs b/extension/src/relay.mjs index 828f086..f2f6b95 100644 --- a/extension/src/relay.mjs +++ b/extension/src/relay.mjs @@ -25,6 +25,7 @@ import { recentTurns, stateSnapshot, getPhoneCommand, + validatePhoneCommandInput, } from "@aasis21/weft-shared"; import { readSummary, readHistory, readLatestTurnIndex } from "./store.mjs"; import { createRecentTurns } from "./recentTurns.mjs"; @@ -905,7 +906,6 @@ async function applyInterrupt(session, logger, sendSafe) { // line so the phone sees the outcome even when the command emits no further session events. async function applyCommand(session, body, logger, sendSafe) { const command = getPhoneCommand(body?.name); - const rawInput = typeof body?.input === "string" ? body.input.trim() : ""; if (!command) { logger(`Weft: ignored non-whitelisted command "/${body?.name ?? ""}" from phone.`, { level: "warning", @@ -914,9 +914,10 @@ async function applyCommand(session, body, logger, sendSafe) { await sendSafe(logLine("warning", `Command /${body?.name ?? ""} isn't allowed from the phone.`)); return; } - if (command.arg === "required" && !rawInput) { - logger(`Weft: /${command.name} needs an argument; ignored.`, { level: "warning", ephemeral: false }); - await sendSafe(logLine("warning", `/${command.name} needs an argument.`)); + const validated = validatePhoneCommandInput(command, body?.input); + if (!validated.valid) { + logger(`Weft: ${validated.error} Ignored phone request.`, { level: "warning", ephemeral: false }); + await sendSafe(logLine("warning", validated.error)); return; } if (typeof session.rpc?.commands?.invoke !== "function") { @@ -927,9 +928,16 @@ async function applyCommand(session, body, logger, sendSafe) { await sendSafe(logLine("warning", `This CLI build can't run /${command.name} remotely.`)); return; } - const shown = rawInput ? `/${command.name} ${rawInput}` : `/${command.name}`; + const input = validated.input; + const shownInput = validated.option?.label ?? input; + const shown = shownInput ? `/${command.name} ${shownInput}` : `/${command.name}`; try { - await session.rpc.commands.invoke(rawInput ? { name: command.name, input: rawInput } : { name: command.name }); + const result = await session.rpc.commands.invoke( + input ? { name: command.name, input } : { name: command.name }, + ); + if (result?.success === false) { + throw new Error(result.error?.message ?? result.error ?? result.message ?? "Command was rejected by the CLI."); + } logger(`Weft: ran ${shown} from phone.`, { level: "info", ephemeral: false }); await sendSafe(logLine("info", `▷ Ran ${shown} from your phone.`)); } catch (err) { diff --git a/extension/test/relay.test.mjs b/extension/test/relay.test.mjs index 13e7ca7..7348103 100644 --- a/extension/test/relay.test.mjs +++ b/extension/test/relay.test.mjs @@ -653,6 +653,47 @@ test("refuses a required-arg command with no argument", async () => { }); }); +test("canonicalizes an approved model label before invoking the SDK", async () => { + await withRelay(async ({ channel, session }) => { + channel.emit(EVENT_TYPE.CONTROL, invokeCommand("model", "Claude Sonnet 5")); + await flush(); + assert.deepEqual(session.invokedCommands, [{ name: "model", input: "claude-sonnet-5" }]); + const ok = channel.sent.find( + (m) => m.eventSubtype === SUBTYPE.STREAM.LOG && /Ran \/model Claude Sonnet 5/.test(m.msg.message ?? "") + ); + assert.ok(ok, "expected the friendly model label in the success notice"); + assert.equal(channel.sent.some((m) => /claude-sonnet-5/.test(m.msg.message ?? "")), false); + }); +}); + +test("refuses an unlisted model before invoking the SDK", async () => { + await withRelay(async ({ channel, session }) => { + channel.emit(EVENT_TYPE.CONTROL, invokeCommand("model", "unlisted-model")); + await flush(); + assert.deepEqual(session.invokedCommands, []); + const warn = channel.sent.find( + (m) => m.eventSubtype === SUBTYPE.STREAM.LOG && /isn't available for \/model/i.test(m.msg.message ?? "") + ); + assert.ok(warn, "expected an unavailable-option warning relayed to the phone"); + }); +}); + +test("relays a resolved SDK command failure without a success-shaped fallback", async () => { + await withRelay(async ({ channel, session }) => { + session.rpc.commands.invoke = async (params) => { + session.invokedCommands.push(params); + return { success: false, error: { message: "Model unavailable for this account" } }; + }; + + channel.emit(EVENT_TYPE.CONTROL, invokeCommand("model", "auto")); + await flush(); + + const logs = channel.sent.filter((m) => m.eventSubtype === SUBTYPE.STREAM.LOG); + assert.ok(logs.some((m) => /\/model Auto failed: Model unavailable/i.test(m.msg.message ?? ""))); + assert.equal(logs.some((m) => /Ran \/model Auto/i.test(m.msg.message ?? "")), false); + }); +}); + test("forwards turn lifecycle as activity busy=true on message_start, false on idle", async () => { await withRelay(async ({ channel, session }) => { // A turn begins with the assistant streaming text (no tool yet) — Stop must show here. diff --git a/mobile/src/app/App.tsx b/mobile/src/app/App.tsx index 4f0ff78..161234f 100644 --- a/mobile/src/app/App.tsx +++ b/mobile/src/app/App.tsx @@ -29,7 +29,7 @@ const TerminalScreen = lazy(() => type ModalHistoryState = { weftView: 'devices' } | { weftView: 'device-details' | 'terminal'; channelId: string } | - { weftView: 'explore'; exploreView?: string } | null; + { weftView: 'explore'; exploreView?: string; entry?: 'direct-discover' } | null; function loadingScreen(label: string): JSX.Element { return ( @@ -52,6 +52,7 @@ export default function App(): JSX.Element { const [deviceDetailsChannelId, setDeviceDetailsChannelId] = useState(undefined); const [terminalChannelId, setTerminalChannelId] = useState(); const [exploreOpen, setExploreOpen] = useState(false); + const [exploreEntry, setExploreEntry] = useState<'home' | 'direct-discover'>('home'); const [addManual, setAddManual] = useState(false); const [showLanding, setShowLanding] = useState(false); const [error, setError] = useState(null); @@ -73,6 +74,11 @@ export default function App(): JSX.Element { const onPopState = (event: PopStateEvent): void => { const state = event.state as ModalHistoryState; setExploreOpen(state?.weftView === 'explore'); + setExploreEntry( + state?.weftView === 'explore' && state.entry === 'direct-discover' + ? 'direct-discover' + : 'home', + ); setTerminalChannelId(state?.weftView === 'terminal' ? state.channelId : undefined); if (state?.weftView === 'devices') { setDevicesOpen(true); @@ -196,10 +202,33 @@ export default function App(): JSX.Element { setDevicesOpen(false); setDeviceDetailsChannelId(undefined); setTerminalChannelId(undefined); + setExploreEntry('home'); setExploreOpen(true); window.history.pushState({ weftView: 'explore' } satisfies ModalHistoryState, ''); }, []); + const openDiscoverFromChat = useCallback((): void => { + setError(null); + setExploreEntry('direct-discover'); + setExploreOpen(true); + window.history.pushState({ + weftView: 'explore', + exploreView: 'discover', + entry: 'direct-discover', + } satisfies ModalHistoryState, ''); + }, []); + + const closeExplore = useCallback((): void => { + const state = window.history.state as ModalHistoryState; + window.history.go( + state?.weftView === 'explore' && + state.exploreView && + state.entry !== 'direct-discover' + ? -2 + : -1, + ); + }, []); + const handleVoiceModeChange = useCallback((channelId: string, active: boolean): void => { void sessionRuntime.setVoiceMode(active, channelId); }, []); @@ -533,11 +562,11 @@ export default function App(): JSX.Element { setAdding(true); }} onOpenExplore={openExplore} + onOpenDiscover={openDiscoverFromChat} exploreOpen={exploreOpen} - onCloseExplore={() => { - const state = window.history.state as ModalHistoryState; - window.history.go(state?.weftView === 'explore' && state.exploreView ? -2 : -1); - }} + exploreInitialView={exploreEntry === 'direct-discover' ? 'discover' : undefined} + exploreDirectFromChat={exploreEntry === 'direct-discover'} + onCloseExplore={closeExplore} onStartSession={() => openStart()} onOpenDevices={openDevices} devices={snapshot.devices} @@ -557,8 +586,7 @@ export default function App(): JSX.Element { }} onGoHome={() => { if (exploreOpen) { - const state = window.history.state as ModalHistoryState; - window.history.go(state?.weftView === 'explore' && state.exploreView ? -2 : -1); + closeExplore(); setExploreOpen(false); } setError(null); diff --git a/mobile/src/app/__tests__/App.test.tsx b/mobile/src/app/__tests__/App.test.tsx index 7e0b362..8a9fd53 100644 --- a/mobile/src/app/__tests__/App.test.tsx +++ b/mobile/src/app/__tests__/App.test.tsx @@ -84,11 +84,19 @@ vi.mock('@/ui/screens/LandingScreen', () => ({ vi.mock('@/ui/screens/SessionScreen', () => ({ SessionScreen: ({ onOpenExplore, + onOpenDiscover, exploreOpen, + exploreInitialView, + exploreDirectFromChat, + onCloseExplore, onGoHome, }: { onOpenExplore(): void; + onOpenDiscover(): void; exploreOpen: boolean; + exploreInitialView?: string; + exploreDirectFromChat?: boolean; + onCloseExplore(): void; onGoHome(): void; }) => { const [count, setCount] = useState(0); @@ -102,10 +110,12 @@ vi.mock('@/ui/screens/SessionScreen', () => ({ Local state {count} + {exploreOpen ? (
- Explore overlay + Explore overlay {exploreInitialView ?? 'home'} {exploreDirectFromChat ? 'from chat' : ''} +
) : null} @@ -156,4 +166,22 @@ describe('App Explore layering', () => { expect(go).toHaveBeenCalledWith(-2); }); + + it('opens Discover directly with one history entry and returns to Chat in one Back action', async () => { + const go = vi.spyOn(window.history, 'go').mockImplementation(() => {}); + render(); + await screen.findByTestId('session-screen'); + + fireEvent.click(screen.getByRole('button', { name: 'Open Discover directly' })); + + expect(await screen.findByTestId('explore-screen')).toHaveTextContent('discover from chat'); + expect(window.history.state).toEqual({ + weftView: 'explore', + exploreView: 'discover', + entry: 'direct-discover', + }); + + fireEvent.click(screen.getByRole('button', { name: 'Back to chat' })); + expect(go).toHaveBeenCalledWith(-1); + }); }); diff --git a/mobile/src/ui/composer/Composer.tsx b/mobile/src/ui/composer/Composer.tsx index ff5a266..139440f 100644 --- a/mobile/src/ui/composer/Composer.tsx +++ b/mobile/src/ui/composer/Composer.tsx @@ -3,14 +3,18 @@ import '@/ui/styles/composer.css'; import { useEffect, useRef, useState } from 'react'; import type { ChangeEvent, ClipboardEvent, DragEvent, JSX, KeyboardEvent } from 'react'; import { MODES } from '@aasis21/weft-shared'; -import type { PromptAttachment, PromptDelivery, SessionMode } from '@aasis21/weft-shared'; -import { PHONE_COMMANDS, getPhoneCommand } from '@aasis21/weft-shared'; +import type { PhoneCommand, PromptAttachment, PromptDelivery, SessionMode } from '@aasis21/weft-shared'; +import { PHONE_COMMANDS, getPhoneCommand, validatePhoneCommandInput } from '@aasis21/weft-shared'; import { useSpeechInput } from '@/ui/hooks/useSpeechInput'; import { VoxDock } from '@/ui/voice/VoxDock'; import type { VoiceState } from '@/ui/voice/useVoxEngine'; import type { AssistantItem } from '@/lib/timeline'; import { ACCEPTED_IMAGE_TYPES, attachmentSrc, fileToAttachment } from '@/lib/imageAttachments'; import { isDesktopInput } from '@/lib/platform'; +import { + getCommandArgumentStage, + getCommandArgumentSuggestions, +} from '@/ui/composer/commandPalette'; /** Inline Vox (voice in / voice out) rendered in the composer where the keyboard would be. */ export interface ComposerVox { @@ -74,6 +78,18 @@ interface SlashItem { template?: string; } +interface ArgumentItem { + key: string; + kind: 'argument'; + command: PhoneCommand; + label: string; + hint: string; + input: string; + disabled?: boolean; +} + +type PaletteItem = SlashItem | ArgumentItem; + function appendSpeechText(committed: string, fresh: string): string { const base = committed.trimEnd(); const tail = fresh.trim(); @@ -186,7 +202,7 @@ function basename(path: string | null): string | null { return parts[parts.length - 1] || path; } -function slashQuery(value: string): string | null { +function commandQuery(value: string): string | null { if (!value.startsWith('/')) return null; const firstToken = value.split(/\s/, 1)[0] ?? ''; if (value !== firstToken || !/^\/[a-z-]*$/i.test(firstToken)) return null; @@ -317,15 +333,29 @@ export function Composer({ }; }, [attachMenuOpen]); - const commandQuery = busy ? null : slashQuery(text); - const slashOptions = commandQuery === null + const parsedDraft = parseCommand(text.trimEnd()); + const argumentStage = busy ? null : getCommandArgumentStage(text, parsedDraft ? getPhoneCommand(parsedDraft.name) : null); + const query = busy ? null : commandQuery(text); + const commandOptions: PaletteItem[] = query === null ? [] - : SLASH_ITEMS.filter((item) => item.command.slice(1).startsWith(commandQuery)); - const slashOpen = commandQuery !== null && slashOptions.length > 0 && !slashDismissed; + : SLASH_ITEMS.filter((item) => { + const normalized = query.toLocaleLowerCase(); + return item.command.slice(1).toLocaleLowerCase().includes(normalized) + || item.hint.toLocaleLowerCase().includes(normalized); + }); + const argumentOptions: PaletteItem[] = argumentStage + ? getCommandArgumentSuggestions(argumentStage.command, argumentStage.query).map((item) => ({ + ...item, + kind: 'argument', + command: argumentStage.command, + })) + : []; + const slashOptions = argumentStage ? argumentOptions : commandOptions; + const slashOpen = !busy && slashOptions.length > 0 && !slashDismissed; useEffect(() => { setSlashIndex(0); - }, [commandQuery]); + }, [query, argumentStage?.command.name]); useEffect(() => { if (!slashOpen) return; @@ -343,9 +373,21 @@ export function Composer({ saveAttachments(sessionId, []); }; - const runCommand = (name: string, input: string): void => { + const runCommand = (name: string, input?: string): void => { clearDraft(); - onCommand(name, input || undefined); + onCommand(name, input); + }; + + const submitCommand = (command: PhoneCommand, rawInput: string): boolean => { + const validated = validatePhoneCommandInput(command, rawInput); + if (!validated.valid) return false; + const pending = { name: command.name, input: validated.input ?? '' }; + if (command.confirm) { + setPendingCommand(pending); + return true; + } + runCommand(pending.name, pending.input || undefined); + return true; }; const send = async (delivery: PromptDelivery = 'immediate'): Promise => { @@ -361,12 +403,7 @@ export function Composer({ const parsed = parseCommand(trimmed); if (parsed) { const meta = getPhoneCommand(parsed.name); - if (meta?.arg === 'required' && !parsed.input) return; // wait for the required argument - if (meta?.confirm) { - setPendingCommand(parsed); - return; - } - runCommand(parsed.name, parsed.input); + if (!meta || !submitCommand(meta, parsed.input)) return; return; } } @@ -519,14 +556,18 @@ export function Composer({ setAttachError(null); }; - const selectSlashCommand = (item: SlashItem): void => { + const selectPaletteItem = (item: PaletteItem): void => { + if (item.kind === 'argument') { + if (item.disabled) return; + submitCommand(item.command, item.input); + return; + } if (item.kind === 'command') { const meta = getPhoneCommand(item.command.slice(1)); - // No-arg commands are ready to run (Enter sends); arg commands get a trailing space to type into. - const suffix = meta && meta.arg !== 'none' ? ' ' : ''; + const suffix = meta && meta.input.kind !== 'none' ? ' ' : ''; const next = text.replace(/^\/[a-z-]*/i, `${item.command}${suffix}`); onTextChange(next); - setSlashDismissed(true); + setSlashDismissed(meta?.input.kind === 'none'); window.requestAnimationFrame(() => areaRef.current?.focus()); return; } @@ -547,7 +588,7 @@ export function Composer({ if (event.key === 'Enter') { event.preventDefault(); const item = slashOptions[slashIndex]; - if (item) selectSlashCommand(item); + if (item) selectPaletteItem(item); return; } if (event.key === 'Escape') { @@ -664,23 +705,30 @@ export function Composer({ }} > {slashOpen ? ( -
+
{slashOptions.map((item, index) => ( ))}
@@ -702,7 +750,7 @@ export function Composer({ onClick={() => { const cmd = pendingCommand; setPendingCommand(null); - runCommand(cmd.name, cmd.input); + runCommand(cmd.name, cmd.input || undefined); }} > Run /{pendingCommand.name} diff --git a/mobile/src/ui/composer/__tests__/Composer.test.tsx b/mobile/src/ui/composer/__tests__/Composer.test.tsx index e9716da..200744a 100644 --- a/mobile/src/ui/composer/__tests__/Composer.test.tsx +++ b/mobile/src/ui/composer/__tests__/Composer.test.tsx @@ -499,6 +499,88 @@ describe('Composer', () => { expect(textbox).toHaveValue(''); }); + it('uses the same keyboard flow for command selection and required text input', async () => { + const user = userEvent.setup(); + const onCommand = vi.fn(); + renderComposer({ onCommand }); + const textbox = screen.getByRole('textbox', { name: 'Message your Copilot session' }); + + await user.type(textbox, '/ren'); + fireEvent.keyDown(textbox, { key: 'Enter' }); + expect(textbox).toHaveValue('/rename '); + expect(screen.getByRole('listbox', { name: 'Arguments for /rename' })).toBeInTheDocument(); + + await user.type(textbox, 'My Session'); + fireEvent.keyDown(textbox, { key: 'Enter' }); + + expect(onCommand).toHaveBeenCalledWith('rename', 'My Session'); + expect(textbox).toHaveValue(''); + }); + + it('filters model choices by friendly metadata and invokes the hidden value on touch selection', async () => { + const user = userEvent.setup(); + const onCommand = vi.fn(); + const { container } = renderComposer({ onCommand }); + const textbox = screen.getByRole('textbox', { name: 'Message your Copilot session' }); + + await user.type(textbox, '/mod'); + await user.click(screen.getByRole('option', { name: /\/model/i })); + expect(textbox).toHaveValue('/model '); + + const modelMenu = screen.getByRole('listbox', { name: 'Arguments for /model' }); + expect(within(modelMenu).getByText('Auto')).toBeInTheDocument(); + expect(within(modelMenu).getByText('Recommended')).toBeInTheDocument(); + expect(container).not.toHaveTextContent('gpt-5.6-sol'); + expect(container).not.toHaveTextContent('claude-sonnet-5'); + expect(container).not.toHaveTextContent('gemini-3.8-flash'); + + await user.type(textbox, 'recommended'); + expect(within(modelMenu).getAllByRole('option')).toHaveLength(1); + await user.click(within(modelMenu).getByRole('option', { name: /AutoRecommended/i })); + + expect(onCommand).toHaveBeenCalledWith('model', 'auto'); + expect(textbox).toHaveValue(''); + }); + + it('canonicalizes a friendly model label when the command is submitted directly', async () => { + const user = userEvent.setup(); + const onCommand = vi.fn(); + renderComposer({ onCommand }); + const textbox = screen.getByRole('textbox', { name: 'Message your Copilot session' }); + + await user.type(textbox, '/model Claude Sonnet 5'); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + + expect(onCommand).toHaveBeenCalledWith('model', 'claude-sonnet-5'); + }); + + it('does not submit a curated option command with an unknown value', async () => { + const user = userEvent.setup(); + const onCommand = vi.fn(); + const onPrompt = vi.fn(); + renderComposer({ onCommand, onPrompt }); + const textbox = screen.getByRole('textbox', { name: 'Message your Copilot session' }); + + await user.type(textbox, '/model unavailable'); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + + expect(onCommand).not.toHaveBeenCalled(); + expect(onPrompt).not.toHaveBeenCalled(); + expect(textbox).toHaveValue('/model unavailable'); + }); + + it('allows optional text commands to run from the generic argument stage without text', async () => { + const user = userEvent.setup(); + const onCommand = vi.fn(); + renderComposer({ onCommand }); + const textbox = screen.getByRole('textbox', { name: 'Message your Copilot session' }); + + await user.type(textbox, '/compact '); + await user.click(screen.getByRole('option', { name: /Optional focusOptional text/i })); + + expect(onCommand).toHaveBeenCalledWith('compact', undefined); + }); + it('invokes a no-arg command with undefined input', async () => { const user = userEvent.setup(); const onCommand = vi.fn(); diff --git a/mobile/src/ui/composer/__tests__/commandPalette.test.ts b/mobile/src/ui/composer/__tests__/commandPalette.test.ts new file mode 100644 index 0000000..a1b0091 --- /dev/null +++ b/mobile/src/ui/composer/__tests__/commandPalette.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import type { PhoneCommand } from '@aasis21/weft-shared'; +import { + filterCommandOptions, + getCommandArgumentStage, + getCommandArgumentSuggestions, +} from '@/ui/composer/commandPalette'; + +const optionCommand: PhoneCommand = { + name: 'target', + label: '/target', + hint: 'Choose a target', + input: { + kind: 'options', + required: true, + allowCustom: true, + placeholder: 'Choose a target', + options: [ + { value: 'hidden-one', label: 'Friendly One', hint: 'Recommended', aliases: ['default'] }, + { value: 'hidden-two', label: 'Friendly Two', hint: 'Fast choice', aliases: ['quick'] }, + ], + }, +}; + +describe('commandPalette', () => { + it('opens the argument stage only after a command and separator', () => { + expect(getCommandArgumentStage('/target', optionCommand)).toBeNull(); + expect(getCommandArgumentStage('/target ', optionCommand)).toEqual({ + command: optionCommand, + query: '', + }); + }); + + it('filters options by friendly labels, hints, and aliases without requiring internal values', () => { + if (optionCommand.input.kind !== 'options') throw new Error('test definition must use options'); + expect(filterCommandOptions(optionCommand.input.options, 'friendly two').map((option) => option.value)) + .toEqual(['hidden-two']); + expect(filterCommandOptions(optionCommand.input.options, 'recommended').map((option) => option.value)) + .toEqual(['hidden-one']); + expect(filterCommandOptions(optionCommand.input.options, 'quick').map((option) => option.value)) + .toEqual(['hidden-two']); + }); + + it('offers custom input only when the option definition allows it', () => { + if (optionCommand.input.kind !== 'options') throw new Error('test definition must use options'); + expect(getCommandArgumentSuggestions(optionCommand, 'other').at(-1)).toMatchObject({ + label: 'Use “other”', + hint: 'Custom value', + input: 'other', + }); + + const curatedOnly: PhoneCommand = { + ...optionCommand, + input: { ...optionCommand.input, allowCustom: false }, + }; + expect(getCommandArgumentSuggestions(curatedOnly, 'other')).toEqual([]); + }); + + it('represents required and optional text through the same argument stage', () => { + const required: PhoneCommand = { + name: 'rename', + label: '/rename', + hint: 'Rename', + input: { kind: 'text', required: true, placeholder: 'Session name' }, + }; + expect(getCommandArgumentSuggestions(required, '')[0]).toMatchObject({ + label: 'Session name', + disabled: true, + }); + expect(getCommandArgumentSuggestions(required, 'My Session')[0]).toMatchObject({ + label: 'Use “My Session”', + input: 'My Session', + disabled: false, + }); + }); +}); diff --git a/mobile/src/ui/composer/commandPalette.ts b/mobile/src/ui/composer/commandPalette.ts new file mode 100644 index 0000000..156477b --- /dev/null +++ b/mobile/src/ui/composer/commandPalette.ts @@ -0,0 +1,67 @@ +import type { PhoneCommand, PhoneCommandOption } from '@aasis21/weft-shared'; + +export interface CommandArgumentStage { + command: PhoneCommand; + query: string; +} + +export interface CommandArgumentSuggestion { + key: string; + label: string; + hint: string; + input: string; + disabled?: boolean; +} + +export function getCommandArgumentStage(value: string, command: PhoneCommand | null): CommandArgumentStage | null { + if (!command || command.input.kind === 'none') return null; + const match = value.match(/^\/[a-z][a-z-]*\s([\s\S]*)$/i); + if (!match) return null; + return { command, query: match[1] ?? '' }; +} + +export function filterCommandOptions( + options: ReadonlyArray, + query: string, +): ReadonlyArray { + const normalized = query.trim().toLocaleLowerCase(); + if (!normalized) return options; + return options.filter((option) => + [option.label, option.hint ?? '', ...(option.aliases ?? [])].some((candidate) => + candidate.toLocaleLowerCase().includes(normalized), + ), + ); +} + +export function getCommandArgumentSuggestions( + command: PhoneCommand, + query: string, +): CommandArgumentSuggestion[] { + if (command.input.kind === 'none') return []; + const trimmed = query.trim(); + if (command.input.kind === 'text') { + return [{ + key: 'text', + label: trimmed ? `Use “${trimmed}”` : command.input.placeholder, + hint: command.input.required ? 'Required text' : 'Optional text', + input: trimmed, + disabled: command.input.required && !trimmed, + }]; + } + + const suggestions = filterCommandOptions(command.input.options, query).map((option) => ({ + key: option.value, + label: option.label, + hint: option.hint ?? `Use ${option.label}`, + input: option.value, + })); + if (command.input.allowCustom && trimmed) { + suggestions.push({ + key: `custom:${trimmed}`, + label: `Use “${trimmed}”`, + hint: 'Custom value', + input: trimmed, + }); + } + return suggestions; +} diff --git a/mobile/src/ui/explore/ExploreScreen.tsx b/mobile/src/ui/explore/ExploreScreen.tsx index 2592a54..23da1cb 100644 --- a/mobile/src/ui/explore/ExploreScreen.tsx +++ b/mobile/src/ui/explore/ExploreScreen.tsx @@ -12,8 +12,9 @@ import type { PointerEvent as ReactPointerEvent, } from 'react'; import type { SessionView } from '@/session/view'; -import type { AssistantItem, TimelineItem, ToolItem } from '@/lib/timeline'; +import type { TimelineItem, ToolItem } from '@/lib/timeline'; import { isWorking } from '@/ui/sessions/sessionStatus'; +import { compactToolDetail, toolDisplayName } from '@/ui/tools/toolPresentation'; import { DISCOVER_CARDS, DISCOVER_TOPIC_LABELS, @@ -26,6 +27,7 @@ type ExploreView = ExploreCategory | null; type ExploreHistoryState = { weftView: 'explore'; exploreView?: ExploreCategory; + entry?: 'direct-discover'; }; function historyExploreView(state: ExploreHistoryState | null): ExploreView { @@ -44,6 +46,8 @@ interface ExploreScreenProps { onOpenChat(): void; onGoHome(): void; desktopDocked?: boolean; + initialView?: ExploreCategory; + directFromChat?: boolean; } export type LiveDockTone = 'attention' | 'error' | 'working' | 'ready' | 'idle'; @@ -61,6 +65,7 @@ export interface LiveDockActivity { text: string; kind: 'assistant' | 'tool' | 'context'; state: 'live' | 'complete' | 'error' | 'context'; + count?: number; } const CATEGORY_META: Record = { @@ -171,11 +176,9 @@ function latestRunningTool(active: SessionView): ToolItem | null { } function toolLabel(tool: ToolItem): string { - const record = - tool.args && typeof tool.args === 'object' ? (tool.args as Record) : undefined; - const description = typeof record?.description === 'string' ? record.description.trim() : ''; - if (description) return description; - return `${tool.name.replace(/[_-]+/g, ' ')} in progress`; + const name = toolDisplayName(tool.name, tool.args); + const detail = compactToolDetail(tool.name, tool.args); + return detail ? `${name}: ${detail}` : name; } function latestAssistant(active: SessionView, onlyStreaming = false) { @@ -195,10 +198,6 @@ function dockExcerpt(value: string, max = 220): string { return `…${clean.slice(clean.length - max + 1)}`; } -function plainToolName(name: string): string { - return name.replace(/[_-]+/g, ' ').trim(); -} - export function deriveLiveDockActivity( active: SessionView, dock: LiveDockState, @@ -222,48 +221,21 @@ export function deriveLiveDockActivity( ]; } - const activity = active.timeline.items - .filter((item): item is AssistantItem | ToolItem => - (item.kind === 'assistant' && Boolean(item.text.trim())) || item.kind === 'tool', - ) - .slice(-6) - .map((item): LiveDockActivity => { - if (item.kind === 'assistant') { - return { - id: `assistant-${item.id}`, - text: dockExcerpt(item.text, 150), - kind: 'assistant', - state: item.final ? 'complete' : 'live', - }; - } - const activityLabel = toolLabel(item).replace(/ in progress$/, ''); - return { - id: `tool-${item.id}`, - text: - item.status === 'running' - ? toolLabel(item) - : item.status === 'success' - ? `${activityLabel} completed` - : `${activityLabel} failed`, - kind: 'tool', - state: - item.status === 'running' - ? 'live' - : item.status === 'success' - ? 'complete' - : 'error', - }; - }); - - const intent = active.intent?.trim(); - if (intent && activity.length < 3 && !activity.some((item) => item.text === intent)) { - activity.unshift({ - id: 'current-intent', - text: dockExcerpt(intent, 150), - kind: 'context', - state: 'context', - }); + const tools: LiveDockActivity[] = []; + for (const item of active.timeline.items.filter((entry): entry is ToolItem => entry.kind === 'tool').slice(-8)) { + const text = toolLabel(item); + const state = item.status === 'running' ? 'live' : item.status === 'success' ? 'complete' : 'error'; + const previous = tools.at(-1); + if (previous?.text === text) { + previous.id = `tool-${item.id}`; + previous.state = state; + previous.count = (previous.count ?? 1) + 1; + } else { + tools.push({ id: `tool-${item.id}`, text, kind: 'tool', state, count: 1 }); + } } + + const activity = tools.slice(-2); if (activity.length === 0) { activity.push({ id: 'working-context', @@ -272,7 +244,17 @@ export function deriveLiveDockActivity( state: 'live', }); } - return activity.slice(-3); + + const streaming = latestAssistant(active, true); + if (streaming?.kind === 'assistant') { + activity.push({ + id: `assistant-${streaming.id}`, + text: dockExcerpt(streaming.text, 220), + kind: 'assistant', + state: 'live', + }); + } + return activity; } export function deriveLiveDock(active: SessionView, replyCompletedInExplore = false): LiveDockState { @@ -323,7 +305,7 @@ export function deriveLiveDock(active: SessionView, replyCompletedInExplore = fa text: streamed ? dockExcerpt(streamed) : active.intent?.trim() || (running ? toolLabel(running) : 'Working in the active session'), - detail: running ? `Using ${plainToolName(running.name)}` : active.intent?.trim() || null, + detail: running ? `Using ${toolDisplayName(running.name, running.args)}` : active.intent?.trim() || null, startedAt: active.thinkingSince ?? running?.startedAt ?? active.timeline.busyFrom, }; } @@ -352,11 +334,12 @@ function recentActivityLabel(items: TimelineItem[]): string { if (!latest) return 'No recent agent activity'; if (latest.kind === 'assistant') return 'Last response is available in chat'; if (latest.kind === 'tool') { + const name = toolDisplayName(latest.name, latest.args); return latest.status === 'success' - ? `${latest.name} completed` + ? `${name} completed` : latest.status === 'error' - ? `${latest.name} reported an error` - : `${latest.name} is running`; + ? `${name} reported an error` + : `${name} is running`; } return latest.text; } @@ -364,6 +347,7 @@ function recentActivityLabel(items: TimelineItem[]): string { function formatElapsed(startedAt: number | null, now: number): string | null { if (startedAt == null) return null; const seconds = Math.max(0, Math.floor((now - startedAt) / 1000)); + if (seconds < 5) return null; const minutes = Math.floor(seconds / 60); const remainder = seconds % 60; return minutes > 0 ? `${minutes}:${remainder.toString().padStart(2, '0')}` : `${seconds}s`; @@ -408,9 +392,13 @@ export function ExploreScreen({ onOpenChat, onGoHome, desktopDocked = false, + initialView, + directFromChat = false, }: ExploreScreenProps): JSX.Element { const [stored, setStored] = useState>(() => parseStoredState()); - const [view, setView] = useState(null); + const [view, setView] = useState( + () => initialView ?? historyExploreView(window.history.state as ExploreHistoryState | null), + ); const [now, setNow] = useState(Date.now()); const latestFinal = latestAssistant(active); const latestFinalId = @@ -475,7 +463,11 @@ export function ExploreScreen({ }, []); const navigate = (next: ExploreCategory): void => { - const state = { weftView: 'explore', exploreView: next } satisfies ExploreHistoryState; + const state = { + weftView: 'explore', + exploreView: next, + ...(directFromChat ? { entry: 'direct-discover' as const } : {}), + } satisfies ExploreHistoryState; if (view === null) window.history.pushState(state, ''); else window.history.replaceState(state, ''); setView(next); @@ -490,7 +482,12 @@ export function ExploreScreen({
- {desktopDocked ? ( + {directFromChat ? ( + + ) : desktopDocked ? ( @@ -602,6 +599,11 @@ function LiveCopilotDock({ }): JSX.Element { const elapsed = formatElapsed(dock.startedAt, now); const activityText = activity.map((item) => item.text).join('. '); + const streaming = activity.find((item) => item.kind === 'assistant' && item.state === 'live') ?? null; + const steps = activity.filter((item) => item !== streaming); + const current = steps.at(-1) ?? null; + const previous = steps.length > 1 ? steps.at(-2) ?? null : null; + const needsAttention = dock.tone === 'attention' || dock.tone === 'error'; return ( ); } diff --git a/mobile/src/ui/explore/__tests__/ExploreScreen.test.tsx b/mobile/src/ui/explore/__tests__/ExploreScreen.test.tsx index 0231d28..c4786bc 100644 --- a/mobile/src/ui/explore/__tests__/ExploreScreen.test.tsx +++ b/mobile/src/ui/explore/__tests__/ExploreScreen.test.tsx @@ -177,12 +177,14 @@ describe('Live Copilot Dock', () => { const activity = deriveLiveDockActivity(active, dock); expect(activity.map((item) => item.text)).toEqual([ - 'Read ExploreScreen.tsx completed', + 'View: Read ExploreScreen.tsx', + 'Run Command: Run focused Explore tests', 'I am tightening the card layout.', - 'Run focused Explore tests', ]); renderExplore(active); - expect(document.querySelectorAll('.live-copilot-line')).toHaveLength(3); + expect(document.querySelectorAll('.live-copilot-current')).toHaveLength(1); + expect(document.querySelectorAll('.live-copilot-previous')).toHaveLength(1); + expect(document.querySelectorAll('.live-copilot-stream')).toHaveLength(1); expect(document.querySelector('.live-copilot-feed')).toHaveTextContent( 'Run focused Explore tests', ); @@ -303,7 +305,7 @@ describe('Live Copilot Dock', () => { ts: 100, }], }, - }))).toMatchObject({ tone: 'working', text: 'Run mobile tests' }); + }))).toMatchObject({ tone: 'working', text: 'Run Command: Run mobile tests' }); expect(deriveLiveDock(session({ unread: true, @@ -321,9 +323,97 @@ describe('Live Copilot Dock', () => { expect(deriveLiveDock(session())).toMatchObject({ tone: 'idle', label: 'Copilot ready' }); }); + + it('collapses adjacent duplicate tools and never exposes raw compact arguments', () => { + const active = session({ + timeline: { + ...emptyTimeline(), + busy: true, + items: [ + { + kind: 'tool', + id: 'search-1', + name: 'rg', + args: { pattern: 'private first query', paths: 'C:\\private\\repo' }, + status: 'success', + startedAt: 10, + finishedAt: 11, + ts: 10, + }, + { + kind: 'tool', + id: 'search-2', + name: 'rg', + args: { pattern: 'private second query', paths: 'C:\\private\\repo' }, + status: 'running', + startedAt: 12, + ts: 12, + }, + ], + }, + }); + + expect(deriveLiveDockActivity(active, deriveLiveDock(active))).toEqual([ + expect.objectContaining({ text: 'Search', count: 2, state: 'live' }), + ]); + renderExplore(active); + expect(screen.getByRole('button', { name: /Search/ })).toHaveTextContent('Search ×2'); + expect(document.body).not.toHaveTextContent('private first query'); + expect(document.body).not.toHaveTextContent('C:\\private\\repo'); + }); + + it('hides short elapsed times and presents attention as one chat action', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-26T00:00:03Z')); + const short = session({ + timeline: { ...emptyTimeline(), busy: true, busyFrom: Date.parse('2026-09-26T00:00:00Z') }, + }); + const { unmount } = renderExplore(short); + expect(document.querySelector('.live-copilot-status time')).not.toBeInTheDocument(); + unmount(); + + vi.setSystemTime(new Date('2026-09-26T00:00:08Z')); + const elapsed = renderExplore(short); + expect(document.querySelector('.live-copilot-status time')).toHaveTextContent('8s'); + elapsed.unmount(); + + const attention = session({ + timeline: { + ...emptyTimeline(), + approvals: [{ + requestId: 'approval-1', + toolName: 'powershell', + toolArgs: {}, + options: [{ id: 'approve', label: 'Approve' }], + }], + }, + }); + renderExplore(attention); + expect(screen.getByRole('button', { name: /Approval needed/ })).toHaveTextContent('Review in chat'); + expect(document.querySelector('.live-copilot-feed')).not.toBeInTheDocument(); + }); }); describe('Explore navigation and Discover deck', () => { + it('opens direct Discover with one Back to chat affordance', async () => { + const onOpenChat = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + expect(screen.getByRole('region', { name: 'Discover card deck' })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Back to chat' })); + expect(onOpenChat).toHaveBeenCalledOnce(); + }); + it('uses the shared navigation affordance and compact four-choice home', async () => { const onOpenSessions = vi.fn(); const user = userEvent.setup(); diff --git a/mobile/src/ui/screens/SessionScreen.tsx b/mobile/src/ui/screens/SessionScreen.tsx index 0d7ca2e..6c3ee7e 100644 --- a/mobile/src/ui/screens/SessionScreen.tsx +++ b/mobile/src/ui/screens/SessionScreen.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'; -import type { JSX } from 'react'; +import type { CSSProperties, JSX, TouchEvent as ReactTouchEvent } from 'react'; import type { PromptAttachment, PromptDelivery, SessionMode } from '@aasis21/weft-shared'; import type { SessionView } from '@/session/view'; import type { ListenerDeviceState } from '@/session/model'; @@ -116,6 +116,17 @@ function readRecord(value: unknown): Record { return value && typeof value === 'object' ? (value as Record) : {}; } +function isTextEntryElement(value: Element | null): boolean { + if (!(value instanceof HTMLElement)) return false; + if (value.isContentEditable) return true; + const tag = value.tagName.toLowerCase(); + if (tag === 'textarea') return true; + if (tag !== 'input') return false; + return !['button', 'checkbox', 'file', 'hidden', 'radio', 'range', 'reset', 'submit'].includes( + (value as HTMLInputElement).type, + ); +} + /** Persisted across sessions: whether the user collapsed the desktop docked sidebar (#183). */ const SIDEBAR_COLLAPSED_KEY = 'weft.desktop-sidebar-collapsed'; @@ -209,7 +220,10 @@ interface SessionScreenProps { onSelectSession(channelId: string): void; onAddSession(): void; onOpenExplore?(): void; + onOpenDiscover?(): void; exploreOpen?: boolean; + exploreInitialView?: 'discover'; + exploreDirectFromChat?: boolean; onCloseExplore?(): void; onStartSession?(): void; onOpenDevices?(): void; @@ -246,7 +260,10 @@ export function SessionScreen({ onSelectSession, onAddSession, onOpenExplore, + onOpenDiscover, exploreOpen = false, + exploreInitialView, + exploreDirectFromChat = false, onCloseExplore, onStartSession, onOpenDevices, @@ -270,6 +287,11 @@ export function SessionScreen({ const [voiceOpen, setVoiceOpen] = useState(false); const [voxOpen, setVoxOpen] = useState(false); const [confirmRemoveId, setConfirmRemoveId] = useState(null); + const [keyboardOpen, setKeyboardOpen] = useState(false); + const [discoverPreview, setDiscoverPreview] = useState<{ + progress: number; + settling: boolean; + } | null>(null); const [now, setNow] = useState(() => Date.now()); const [approvalMountTimes, setApprovalMountTimes] = useState>({}); // Desktop (wide viewport): dock the session list as a persistent, collapsible sidebar @@ -286,6 +308,14 @@ export function SessionScreen({ }; const confirmDialogRef = useRef(null); const rootRef = useRef(null); + const discoverGestureRef = useRef<{ + startX: number; + startY: number; + startedAt: number; + locked: boolean; + cancelled: boolean; + } | null>(null); + const discoverPreviewTimerRef = useRef(null); const composerDockRef = useRef(null); const approvalStackRef = useRef(null); const prevApprovalCount = useRef(0); @@ -540,24 +570,14 @@ export function SessionScreen({ // Only lift the fixed shell while a text-entry control is focused, so browser // chrome animation never moves the composer. const MIN_KEYBOARD_INSET = 160; - const isTextEntryFocused = (): boolean => { - const active = document.activeElement; - if (!(active instanceof HTMLElement)) return false; - if (active.isContentEditable) return true; - const tag = active.tagName.toLowerCase(); - if (tag === 'textarea') return true; - if (tag !== 'input') return false; - return !['button', 'checkbox', 'file', 'hidden', 'radio', 'range', 'reset', 'submit'].includes( - (active as HTMLInputElement).type, - ); - }; const apply = (): void => { const el = rootRef.current; if (!el) return; const raw = Math.max(0, window.innerHeight - vv.height - vv.offsetTop); const keyboardThreshold = Math.max(MIN_KEYBOARD_INSET, Math.round(window.innerHeight * 0.18)); - const inset = isTextEntryFocused() && raw >= keyboardThreshold ? raw : 0; + const inset = isTextEntryElement(document.activeElement) && raw >= keyboardThreshold ? raw : 0; el.style.setProperty('--weft-kb', `${inset}px`); + setKeyboardOpen(inset > 0); }; apply(); vv.addEventListener('resize', apply); @@ -572,6 +592,122 @@ export function SessionScreen({ }; }, []); + useEffect(() => () => { + if (discoverPreviewTimerRef.current !== null) { + window.clearTimeout(discoverPreviewTimerRef.current); + } + }, []); + + useEffect(() => { + discoverGestureRef.current = null; + setDiscoverPreview(null); + }, [activeId, exploreOpen]); + + const reducedMotion = (): boolean => + globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; + + const conflictingOverlayOpen = + drawerOpen || + debugOpen || + settingsOpen || + voiceOpen || + voxOpen || + confirmRemoveId !== null || + timeline.approvals.length > 0 || + timeline.elicitations.length > 0; + + const cancelDiscoverPreview = (): void => { + discoverGestureRef.current = null; + if (!discoverPreview || reducedMotion()) { + setDiscoverPreview(null); + return; + } + setDiscoverPreview({ progress: 0, settling: true }); + if (discoverPreviewTimerRef.current !== null) { + window.clearTimeout(discoverPreviewTimerRef.current); + } + discoverPreviewTimerRef.current = window.setTimeout(() => { + setDiscoverPreview(null); + discoverPreviewTimerRef.current = null; + }, 180); + }; + + const onDiscoverTouchStart = (event: ReactTouchEvent): void => { + if ( + !onOpenDiscover || + exploreOpen || + isDesktopWide || + isDesktopInput() || + keyboardOpen || + isTextEntryElement(document.activeElement) || + conflictingOverlayOpen || + event.touches.length !== 1 || + rootRef.current?.querySelector('.slash-menu, [role="dialog"], .drawer:not(.docked)') + ) { + discoverGestureRef.current = null; + return; + } + const touch = event.touches[0]; + const width = Math.max(window.innerWidth, document.documentElement.clientWidth); + if (touch.clientX < width - 28) return; + discoverGestureRef.current = { + startX: touch.clientX, + startY: touch.clientY, + startedAt: performance.now(), + locked: false, + cancelled: false, + }; + }; + + const onDiscoverTouchMove = (event: ReactTouchEvent): void => { + const gesture = discoverGestureRef.current; + const touch = event.touches[0]; + if (!gesture || !touch || gesture.cancelled) return; + const deltaX = touch.clientX - gesture.startX; + const deltaY = touch.clientY - gesture.startY; + const horizontalDistance = Math.abs(deltaX); + const verticalDistance = Math.abs(deltaY); + + if (!gesture.locked) { + if (horizontalDistance < 10 && verticalDistance < 10) return; + if (deltaX >= 0 || horizontalDistance <= verticalDistance * 1.2) { + gesture.cancelled = true; + cancelDiscoverPreview(); + return; + } + gesture.locked = true; + } + + event.preventDefault(); + const width = Math.max(window.innerWidth, 1); + const progress = Math.min(1, Math.max(0, -deltaX / width)); + if (!reducedMotion()) setDiscoverPreview({ progress, settling: false }); + }; + + const onDiscoverTouchEnd = (event: ReactTouchEvent): void => { + const gesture = discoverGestureRef.current; + discoverGestureRef.current = null; + if (!gesture || !gesture.locked || gesture.cancelled) { + cancelDiscoverPreview(); + return; + } + const touch = event.changedTouches[0]; + if (!touch) { + cancelDiscoverPreview(); + return; + } + const distance = gesture.startX - touch.clientX; + const elapsedMs = Math.max(1, performance.now() - gesture.startedAt); + const velocity = distance / elapsedMs; + const threshold = Math.max(72, window.innerWidth * 0.22); + if (distance >= threshold || velocity >= 0.55) { + setDiscoverPreview(null); + onOpenDiscover?.(); + return; + } + cancelDiscoverPreview(); + }; + // The "jump to latest" pill floats above the composer. Track the composer dock's // live height in --composer-h so the pill always clears it (even when the composer // grows with multi-line drafts or queued messages), instead of overlapping it. @@ -589,7 +725,14 @@ export function SessionScreen({ }, []); return ( -
+
+ {discoverPreview ? ( + + ) : null} + {exploreOpen && onCloseExplore ? ( Opening Explore…
}> ) : null} diff --git a/mobile/src/ui/screens/StartSessionScreen.tsx b/mobile/src/ui/screens/StartSessionScreen.tsx index cd796a7..b47ef0e 100644 --- a/mobile/src/ui/screens/StartSessionScreen.tsx +++ b/mobile/src/ui/screens/StartSessionScreen.tsx @@ -75,6 +75,8 @@ export function StartSessionScreen({ const modeTouchedRef = useRef(false); const modeDeviceRef = useRef(null); const [name, setName] = useState(''); + const [nameEditing, setNameEditing] = useState(false); + const nameInputRef = useRef(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [blocked, setBlocked] = useState(null); @@ -170,6 +172,19 @@ export function StartSessionScreen({ setSessionFolder(defaultFolder); }, [defaultFolder]); + useEffect(() => { + if (!nameEditing) return undefined; + const keepNameVisible = (): void => { + nameInputRef.current?.scrollIntoView({ block: 'center' }); + }; + const frame = window.requestAnimationFrame(keepNameVisible); + window.visualViewport?.addEventListener('resize', keepNameVisible); + return () => { + window.cancelAnimationFrame(frame); + window.visualViewport?.removeEventListener('resize', keepNameVisible); + }; + }, [nameEditing]); + // Opening the Resume tab is itself the request to see what is resumable, so pull the list rather // than parking behind a Load button — the empty and loading states already cover the wait. Only // while the device is connected: asking an offline laptop just produces a timeout. Re-pulls on @@ -306,7 +321,7 @@ export function StartSessionScreen({ : `Start on ${selected ? deviceLabel(selected) : 'device'}`; return ( -
+
@@ -565,6 +582,27 @@ export function StartSessionScreen({

3. Options

+ {resuming ? null : ( + + )} Permissions
{error ?

{error}

: null} diff --git a/mobile/src/ui/screens/__tests__/SessionScreen.test.tsx b/mobile/src/ui/screens/__tests__/SessionScreen.test.tsx index 17eae51..2d2e50c 100644 --- a/mobile/src/ui/screens/__tests__/SessionScreen.test.tsx +++ b/mobile/src/ui/screens/__tests__/SessionScreen.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { SessionScreen } from '@/ui/screens/SessionScreen'; @@ -256,6 +256,127 @@ describe('SessionScreen desktop keyboard shortcuts', () => { }); }); +describe('SessionScreen direct Discover gesture', () => { + it('commits a horizontally dominant left swipe from the right edge', () => { + const onOpenDiscover = vi.fn(); + const now = vi.spyOn(performance, 'now'); + now.mockReturnValueOnce(0).mockReturnValueOnce(500); + const { container } = renderActive(makeSession('live'), { onOpenDiscover }); + const root = container.querySelector('.weft-session') as HTMLElement; + + fireEvent.touchStart(root, { touches: [{ clientX: window.innerWidth - 4, clientY: 200 }] }); + fireEvent.touchMove(root, { touches: [{ clientX: window.innerWidth - 280, clientY: 205 }] }); + expect(container.querySelector('.discover-edge-preview')).toBeInTheDocument(); + fireEvent.touchEnd(root, { changedTouches: [{ clientX: window.innerWidth - 280, clientY: 205 }] }); + + expect(onOpenDiscover).toHaveBeenCalledOnce(); + }); + + it('cancels short or vertically dominant gestures and springs the preview away', () => { + vi.useFakeTimers(); + const onOpenDiscover = vi.fn(); + const now = vi.spyOn(performance, 'now'); + now.mockReturnValueOnce(0).mockReturnValueOnce(1_000); + const { container } = renderActive(makeSession('live'), { onOpenDiscover }); + const root = container.querySelector('.weft-session') as HTMLElement; + + fireEvent.touchStart(root, { touches: [{ clientX: window.innerWidth - 4, clientY: 200 }] }); + fireEvent.touchMove(root, { touches: [{ clientX: window.innerWidth - 36, clientY: 201 }] }); + fireEvent.touchEnd(root, { changedTouches: [{ clientX: window.innerWidth - 36, clientY: 201 }] }); + expect(container.querySelector('.discover-edge-preview')).toHaveClass('settling'); + expect(onOpenDiscover).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(190)); + expect(container.querySelector('.discover-edge-preview')).not.toBeInTheDocument(); + + fireEvent.touchStart(root, { touches: [{ clientX: window.innerWidth - 4, clientY: 200 }] }); + fireEvent.touchMove(root, { touches: [{ clientX: window.innerWidth - 44, clientY: 100 }] }); + fireEvent.touchEnd(root, { changedTouches: [{ clientX: window.innerWidth - 200, clientY: 100 }] }); + expect(onOpenDiscover).not.toHaveBeenCalled(); + + fireEvent.touchStart(root, { touches: [{ clientX: window.innerWidth - 20, clientY: 200 }] }); + fireEvent.touchMove(root, { touches: [{ clientX: window.innerWidth - 2, clientY: 200 }] }); + fireEvent.touchEnd(root, { changedTouches: [{ clientX: window.innerWidth - 2, clientY: 200 }] }); + expect(onOpenDiscover).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('does not start outside the edge or while an approval overlay needs attention', () => { + const onOpenDiscover = vi.fn(); + const { container, rerender } = renderActive(makeSession('live'), { onOpenDiscover }); + const root = container.querySelector('.weft-session') as HTMLElement; + + fireEvent.touchStart(root, { touches: [{ clientX: 100, clientY: 200 }] }); + fireEvent.touchMove(root, { touches: [{ clientX: 0, clientY: 200 }] }); + fireEvent.touchEnd(root, { changedTouches: [{ clientX: 0, clientY: 200 }] }); + expect(onOpenDiscover).not.toHaveBeenCalled(); + + const blocked = makeSession('live'); + blocked.timeline.approvals = [{ + requestId: 'approval-1', + toolName: 'powershell', + toolArgs: {}, + options: [{ id: 'approve', label: 'Approve' }], + }] as never; + rerender( + , + ); + const blockedRoot = container.querySelector('.weft-session') as HTMLElement; + fireEvent.touchStart(blockedRoot, { touches: [{ clientX: window.innerWidth - 4, clientY: 200 }] }); + fireEvent.touchMove(blockedRoot, { touches: [{ clientX: 20, clientY: 200 }] }); + fireEvent.touchEnd(blockedRoot, { changedTouches: [{ clientX: 20, clientY: 200 }] }); + expect(onOpenDiscover).not.toHaveBeenCalled(); + }); + + it('stays disabled while the keyboard is active and skips spatial preview for reduced motion', () => { + const onOpenDiscover = vi.fn(); + const matchMedia = vi.spyOn(window, 'matchMedia').mockImplementation((query: string) => ({ + matches: query === '(prefers-reduced-motion: reduce)', + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + const { container } = renderActive(makeSession('live'), { onOpenDiscover }); + const root = container.querySelector('.weft-session') as HTMLElement; + screen.getByRole('textbox', { name: 'mock composer' }).focus(); + + fireEvent.touchStart(root, { touches: [{ clientX: window.innerWidth - 4, clientY: 200 }] }); + fireEvent.touchMove(root, { touches: [{ clientX: 20, clientY: 200 }] }); + fireEvent.touchEnd(root, { changedTouches: [{ clientX: 20, clientY: 200 }] }); + expect(onOpenDiscover).not.toHaveBeenCalled(); + + screen.getByRole('textbox', { name: 'mock composer' }).blur(); + fireEvent.touchStart(root, { touches: [{ clientX: window.innerWidth - 4, clientY: 200 }] }); + fireEvent.touchMove(root, { touches: [{ clientX: 20, clientY: 200 }] }); + expect(container.querySelector('.discover-edge-preview')).not.toBeInTheDocument(); + fireEvent.touchEnd(root, { changedTouches: [{ clientX: 20, clientY: 200 }] }); + expect(onOpenDiscover).toHaveBeenCalledOnce(); + matchMedia.mockRestore(); + }); +}); + describe('SessionScreen leave-session confirmation (#one-click-delete)', () => { it('deletes directly from the drawer inline ✓ without popping a second "Leave this session?" dialog', () => { // The drawer row already shows its own inline "Delete?" ✓/✕ confirm, so its ✓ must remove diff --git a/mobile/src/ui/screens/__tests__/StartSessionScreen.test.tsx b/mobile/src/ui/screens/__tests__/StartSessionScreen.test.tsx index ca92c88..2fcf8b0 100644 --- a/mobile/src/ui/screens/__tests__/StartSessionScreen.test.tsx +++ b/mobile/src/ui/screens/__tests__/StartSessionScreen.test.tsx @@ -147,6 +147,40 @@ describe('StartSessionScreen — new and resume are two shapes of one flow', () expect(screen.getByRole('radio', { name: /^default$/i })).toHaveAttribute('aria-checked', 'true'); }); + it('places the optional session name before permissions', () => { + renderScreen(); + + const name = screen.getByLabelText(/session name/i); + const permissions = screen.getByRole('radiogroup', { name: /permissions/i }); + expect(name.compareDocumentPosition(permissions) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('keeps the name field visible while editing and starts from the keyboard action', async () => { + const scrollIntoView = vi.fn(); + const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; + HTMLElement.prototype.scrollIntoView = scrollIntoView; + const onOpen = vi.fn().mockResolvedValue(lifecycleStatus()); + + try { + const { container } = renderScreen({ onOpen }); + const name = screen.getByLabelText(/session name/i); + + fireEvent.focus(name); + expect(container.querySelector('.start-session-v2')).toHaveClass('name-editing'); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalled()); + + fireEvent.change(name, { target: { value: 'Inbox cleanup' } }); + fireEvent.keyDown(name, { key: 'Enter' }); + + await waitFor(() => + expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ name: 'Inbox cleanup' })), + ); + expect(container.querySelector('.start-session-v2')).not.toHaveClass('name-editing'); + } finally { + HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + } + }); + it('uses the selected laptop permission default while preserving a one-launch override', async () => { const device = resumeDevice('/home/me/weft'); device.defaultPermissionMode = 'allow-all'; @@ -303,6 +337,19 @@ describe('StartSessionScreen — the resumable list', () => { expect(screen.queryByText(/showing/i)).toBeNull(); }); + it('uses compact resume rows with age separated from repository metadata', () => { + const sessions = [ + storedSession({ sessionId: 'a', title: 'Fix the auth bug', updatedAt: Date.now() - 60_000 }), + ]; + renderScreen({ devices: [makeDevice({ sessions, projects: [] })], initialMode: 'resume' }); + + const firstRow = screen.getByRole('radio', { name: /fix the auth bug/i }); + expect(firstRow.querySelector('.device-session-main .device-card-name')).toHaveTextContent('Fix the auth bug'); + expect(firstRow.querySelector('.device-session-age')).toBeInTheDocument(); + expect(firstRow.querySelector('.device-session-status')).toHaveTextContent(/resumable.*ModernOrder.*main/i); + expect(firstRow.querySelector('.device-session-status')).not.toHaveTextContent(/ago/i); + }); + it('offers registered projects above folders the store merely happens to know about', () => { // The two sets diverge: the store knows every cwd a session ever ran in, registered or not. // Resume needs the union, or the folder you are most likely to want is missing whenever it has diff --git a/mobile/src/ui/styles/chat.css b/mobile/src/ui/styles/chat.css index 46d9fec..4aef7ae 100644 --- a/mobile/src/ui/styles/chat.css +++ b/mobile/src/ui/styles/chat.css @@ -358,6 +358,44 @@ .thread-scroll::-webkit-scrollbar { width: 7px; } .thread-scroll::-webkit-scrollbar-thumb { background: var(--bg-rule); border-radius: 8px; } +.discover-edge-preview { + display: flex; + position: fixed; + z-index: 35; + inset: 0; + align-items: center; + justify-content: center; + gap: 10px; + pointer-events: none; + border-left: 1px solid color-mix(in srgb, var(--accent) 42%, var(--bg-rule)); + color: var(--fg); + background: + radial-gradient(420px 260px at 100% 30%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 72%), + var(--bg); + transform: translateX(calc((1 - var(--discover-preview-progress)) * 100%)); + will-change: transform; +} + +.discover-edge-preview.settling { + transition: transform 180ms cubic-bezier(0.22, 0.75, 0.28, 1); +} + +.discover-edge-preview span { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border-radius: 14px; + color: var(--accent); + background: var(--accent-soft); + font-size: 22px; +} + +.discover-edge-preview strong { + font-family: var(--display); + font-size: 24px; +} + .chat-thread { display: flex; flex-direction: column; @@ -605,6 +643,65 @@ white-space: pre-wrap; word-break: break-word; } +.tc-command { + margin: 0; + padding: 10px 12px; + overflow-x: auto; + border: 1px solid var(--bg-rule); + border-radius: 9px; + background: var(--code-bg); + color: var(--fg); + font-family: var(--mono); + font-size: 12.5px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.tc-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} +.tc-meta span { + padding: 3px 8px; + border-radius: 999px; + background: var(--bg-elev); + color: var(--fg-dim); + font-size: 11px; +} +.tc-raw { + margin-top: 8px; + color: var(--fg-faint); + font-size: 11.5px; +} +.tc-raw summary { + min-height: 32px; + cursor: pointer; + color: var(--fg-dim); +} +.tc-output { + padding: 10px 12px; + border: 1px solid var(--bg-rule); + border-radius: 9px; + background: var(--code-bg); + font-size: 12.5px; + line-height: 1.55; + white-space: pre; + word-break: normal; +} +.tc-status { + display: flex; + justify-content: space-between; + gap: 12px; + margin-top: 8px; + color: var(--fg-faint); + font-size: 11.5px; + font-variant-numeric: tabular-nums; +} +.tc-status.success span:first-child { color: var(--success); } +.tc-status.error span:first-child { color: var(--error); } +.tc-status.running span:first-child { color: var(--accent); } .tc-diff { max-height: min(280px, 44vh); overflow: auto; @@ -2295,13 +2392,71 @@ } .weft-session .start-resume-section .device-sessions-list .device-session-open.selected { - outline: 2px solid var(--accent); - outline-offset: -2px; - border-radius: 10px; + outline: none; + border-radius: 8px; + background: var(--accent-soft); + box-shadow: inset 3px 0 var(--accent); +} + +.weft-session .start-resume-section .device-sessions-list { + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.weft-session .start-resume-section .device-session-row { + gap: 0; + border: 0; + border-top: 1px solid var(--bg-rule); + border-radius: 0; + background: transparent; +} + +.weft-session .start-resume-section .device-session-row:first-child { + border-top: 0; +} + +.weft-session .start-resume-section .device-session-open { + gap: 3px; + padding: 10px 8px; + border-radius: 8px; +} + +.weft-session .device-session-main { + display: flex; + align-items: baseline; + gap: 8px; + width: 100%; + min-width: 0; +} + +.weft-session .start-resume-section .device-card-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.weft-session .device-session-age { + flex: 0 0 auto; + color: var(--fg-faint); + font-size: 11.5px; + font-variant-numeric: tabular-nums; +} + +.weft-session .start-resume-section .device-session-status { + max-width: 100%; + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; } .weft-session .start-name-field { margin-top: 2px; + scroll-margin-block: 24px; } /* The footer floats over a scrolling list, so it needs its own ground: without a background the @@ -2330,6 +2485,10 @@ background: linear-gradient(to top, var(--bg), transparent); } +.weft-session.start-session-v2.name-editing .start-footer { + display: none; +} + .weft-session .start-footer-links { display: flex; flex-wrap: wrap; @@ -3422,6 +3581,7 @@ .thinking-dots span, .tool-card.running .tc-icon, .voice-orb-ring { animation: none; } + .discover-edge-preview { display: none; } } /* ---- Concurrent CSS additions: chat controls, tool affordances, drawer status ---- */ diff --git a/mobile/src/ui/styles/explore.css b/mobile/src/ui/styles/explore.css index 578a798..2d576c8 100644 --- a/mobile/src/ui/styles/explore.css +++ b/mobile/src/ui/styles/explore.css @@ -1044,6 +1044,25 @@ font-size: 16px; } +.explore-chat-back { + display: inline-flex; + flex: 0 0 auto; + min-height: 38px; + padding: 0 8px; + align-items: center; + gap: 4px; + border: 0; + color: var(--accent); + background: transparent; + font-size: 12px; + font-weight: 800; +} + +.explore-chat-back span { + font-size: 22px; + line-height: 1; +} + .explore-header-nav { display: flex; min-width: 0; @@ -1158,7 +1177,7 @@ } .live-copilot-dock { - display: flex; + display: grid; position: relative; flex: 0 0 auto; width: calc(100% - 20px); @@ -1166,7 +1185,8 @@ max-height: 140px; margin: 8px 10px max(8px, env(safe-area-inset-bottom)); padding: 10px 12px; - align-items: stretch; + grid-template-columns: 42px minmax(0, 1fr); + align-items: start; gap: 10px; overflow: hidden; border: 1px solid var(--bg-rule); @@ -1179,6 +1199,21 @@ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18); } +.live-copilot-glyph { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--bg-rule)); + border-radius: 14px; + color: var(--accent); + background: var(--accent-soft); +} + +.live-copilot-glyph span { + font-size: 21px; +} + .live-copilot-dot { display: inline-block; flex: 0 0 auto; @@ -1219,7 +1254,7 @@ min-width: 0; flex: 1; flex-direction: column; - gap: 7px; + gap: 5px; } .live-copilot-status { @@ -1237,10 +1272,9 @@ } .live-copilot-status strong { - color: var(--accent); - font-size: 10px; - letter-spacing: 0.04em; - text-transform: uppercase; + color: var(--fg); + font-size: 12px; + letter-spacing: 0; } .live-copilot-status time { @@ -1253,69 +1287,99 @@ min-height: 0; flex: 1; flex-direction: column; - justify-content: flex-end; - gap: 4px; + justify-content: flex-start; + gap: 3px; overflow: hidden; } -.live-copilot-line { - display: grid; +.live-copilot-current { + display: flex; min-width: 0; - min-height: 20px; - padding: 3px 6px; - grid-template-columns: 10px minmax(0, 1fr) 5px; align-items: center; gap: 5px; - border-radius: 7px; - color: var(--fg-dim); - background: color-mix(in srgb, var(--bg) 35%, transparent); - font-size: 11px; - line-height: 1.25; - animation: live-copilot-line-in 180ms ease-out; + color: var(--fg); + font-size: 12px; + font-weight: 750; } -.live-copilot-line > i { +.live-copilot-current > i { + flex: 0 0 auto; color: var(--fg-faint); font-style: normal; - text-align: center; } -.live-copilot-line > span { +.live-copilot-current > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.live-copilot-line-live { - color: var(--fg); - background: color-mix(in srgb, var(--accent) 10%, transparent); - font-weight: 700; -} - .live-copilot-line-complete > i { color: var(--success); } .live-copilot-line-error > i { color: var(--error); } -.live-copilot-line-context { color: var(--fg-faint); } -.live-copilot-line > b { +.live-copilot-stream { + display: -webkit-box; + overflow: hidden; + color: var(--fg-dim); + font-size: 11px; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.live-copilot-stream > b { + display: inline-block; width: 4px; height: 12px; + margin-left: 3px; border-radius: 2px; background: var(--accent); animation: live-copilot-cursor 900ms steps(1, end) infinite; } -@keyframes live-copilot-line-in { - from { opacity: 0; transform: translateY(7px); } +.live-copilot-previous { + overflow: hidden; + color: var(--fg-faint); + font-size: 10.5px; + text-overflow: ellipsis; + white-space: nowrap; } -@keyframes live-copilot-cursor { - 50% { opacity: 0; } +.live-copilot-attention { + display: flex; + min-width: 0; + flex-direction: column; } -.live-copilot-chevron { - flex: 0 0 auto; +.live-copilot-attention strong, +.live-copilot-attention small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.live-copilot-attention strong { + color: var(--fg); + font-size: 12px; +} + +.live-copilot-attention small { color: var(--fg-faint); - font-size: 22px; + font-size: 10.5px; +} + +.live-copilot-action { + display: inline-flex; + margin-top: auto; + align-items: center; + gap: 4px; + color: var(--accent); + font-size: 11px; + font-weight: 800; +} + +@keyframes live-copilot-cursor { + 50% { opacity: 0; } } .discover-view { @@ -1575,8 +1639,7 @@ @media (prefers-reduced-motion: reduce) { .live-copilot-dock-working .live-copilot-dot, - .live-copilot-line, - .live-copilot-line > b { + .live-copilot-stream > b { animation: none; } .explore-header-nav strong, diff --git a/mobile/src/ui/thread/ChatThread.tsx b/mobile/src/ui/thread/ChatThread.tsx index 802b6a8..b65c68b 100644 --- a/mobile/src/ui/thread/ChatThread.tsx +++ b/mobile/src/ui/thread/ChatThread.tsx @@ -126,6 +126,8 @@ interface MenuState { /** How close to the bottom still counts as "following along". */ const PIN_SLACK_PX = 80; +const TOUCH_DETACH_PX = 8; +const MOMENTUM_GRACE_MS = 220; /** Distance in pixels from the bottom, used when a real reader scroll settles. */ function bottomGap(scroller: HTMLElement | null): number { @@ -160,11 +162,14 @@ export function ChatThread({ items, streaming = false, busy = false, intent = nu // scrolled up to read history we must not yank them back down — we only stick to // the bottom if they were already there (or just sent a prompt themselves). const pinnedRef = useRef(true); + const readerDetachedRef = useRef(false); const [isPinned, setIsPinned] = useState(true); // True from the moment a finger lands on the thread until it leaves. Nothing may move the // viewport under an active gesture: the reader is steering, and any correction we apply is // fighting their thumb. const touchingRef = useRef(false); + const touchStartYRef = useRef(null); + const touchReleaseTimerRef = useRef(null); // Opening a conversation should *present* the newest message, not travel to it. History arrives // after mount and lands above what is already rendered, so every page used to count as new content // and animate the viewport downwards — you watched the thread scroll itself while you were trying @@ -294,7 +299,12 @@ export function ChatThread({ items, streaming = false, busy = false, intent = nu ); const scrollToLatest = useCallback((): void => { + if (touchReleaseTimerRef.current !== null) { + window.clearTimeout(touchReleaseTimerRef.current); + touchReleaseTimerRef.current = null; + } endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); + readerDetachedRef.current = false; pinnedRef.current = true; setIsPinned(true); setHasNewWhileUnpinned(false); @@ -302,8 +312,13 @@ export function ChatThread({ items, streaming = false, busy = false, intent = nu // Re-arm on arrival at a different conversation. useLayoutEffect(() => { + if (touchReleaseTimerRef.current !== null) { + window.clearTimeout(touchReleaseTimerRef.current); + touchReleaseTimerRef.current = null; + } settlingRef.current = true; setSettling(true); + readerDetachedRef.current = false; pinnedRef.current = true; setIsPinned(true); setHasNewWhileUnpinned(false); @@ -339,36 +354,74 @@ export function ChatThread({ items, streaming = false, busy = false, intent = nu // the reader went anywhere. Believing them is what stranded the view mid-history. if (settlingRef.current) return; const nextPinned = bottomGap(scroller) < PIN_SLACK_PX; + if (nextPinned && readerDetachedRef.current && touchReleaseTimerRef.current !== null) { + pinnedRef.current = false; + setIsPinned(false); + return; + } + readerDetachedRef.current = !nextPinned; pinnedRef.current = nextPinned; setIsPinned(nextPinned); if (nextPinned) setHasNewWhileUnpinned(false); }; // Gesture bookends. These fire synchronously with the touch, unlike scroll, which is why they // are the only signal fast enough to protect the very start of a drag. - const hold = (): void => { + const detachReader = (): void => { + readerDetachedRef.current = true; + pinnedRef.current = false; + setIsPinned(false); + }; + const hold = (event: TouchEvent): void => { + if (touchReleaseTimerRef.current !== null) { + window.clearTimeout(touchReleaseTimerRef.current); + touchReleaseTimerRef.current = null; + } touchingRef.current = true; + touchStartYRef.current = event.touches[0]?.clientY ?? null; + }; + const move = (event: TouchEvent): void => { + const startY = touchStartYRef.current; + const currentY = event.touches[0]?.clientY; + if (startY == null || currentY == null) return; + // Pulling the finger down moves the transcript toward older messages. Record that intent + // before momentum scrolling produces a reliable scrollTop, so a heartbeat in this narrow + // window cannot mistake the reader for someone still following the tail. + if (currentY - startY > TOUCH_DETACH_PX) detachReader(); }; const release = (): void => { touchingRef.current = false; - update(); + touchStartYRef.current = null; + if (!readerDetachedRef.current) { + update(); + return; + } + touchReleaseTimerRef.current = window.setTimeout(() => { + touchReleaseTimerRef.current = null; + update(); + }, MOMENTUM_GRACE_MS); }; const wheel = (event: WheelEvent): void => { if (settlingRef.current || event.deltaY >= 0) return; - pinnedRef.current = false; - setIsPinned(false); + detachReader(); }; update(); scroller.addEventListener('scroll', update, { passive: true }); scroller.addEventListener('touchstart', hold, { passive: true }); + scroller.addEventListener('touchmove', move, { passive: true }); scroller.addEventListener('touchend', release, { passive: true }); scroller.addEventListener('touchcancel', release, { passive: true }); scroller.addEventListener('wheel', wheel, { passive: true }); return () => { scroller.removeEventListener('scroll', update); scroller.removeEventListener('touchstart', hold); + scroller.removeEventListener('touchmove', move); scroller.removeEventListener('touchend', release); scroller.removeEventListener('touchcancel', release); scroller.removeEventListener('wheel', wheel); + if (touchReleaseTimerRef.current !== null) { + window.clearTimeout(touchReleaseTimerRef.current); + touchReleaseTimerRef.current = null; + } }; }, [cancelLongPress]); @@ -384,7 +437,16 @@ export function ChatThread({ items, streaming = false, busy = false, intent = nu prevTailIdRef.current = tailId; prevTailSignalRef.current = lastSignal; }; - const following = settlingRef.current || pinnedRef.current; + if (phoneJustSent) { + if (touchReleaseTimerRef.current !== null) { + window.clearTimeout(touchReleaseTimerRef.current); + touchReleaseTimerRef.current = null; + } + readerDetachedRef.current = false; + pinnedRef.current = true; + } + const following = + settlingRef.current || (!readerDetachedRef.current && pinnedRef.current); // A finger on the glass outranks everything. Even a correct "they are at the bottom" reading // must not move the viewport mid-gesture, or the first pixel of an upward drag gets undone // before it becomes a scroll. @@ -429,7 +491,7 @@ export function ChatThread({ items, streaming = false, busy = false, intent = nu const scroller = rootRef.current?.closest('.thread-scroll') as HTMLElement | null; if (!scroller) return undefined; const repin = (): void => { - if (!pinnedRef.current || touchingRef.current) return; + if (readerDetachedRef.current || !pinnedRef.current || touchingRef.current) return; endRef.current?.scrollIntoView({ behavior: 'auto', block: 'end' }); }; // window resize covers the keyboard and rotation; the observer covers the composer changing diff --git a/mobile/src/ui/thread/Markdown.tsx b/mobile/src/ui/thread/Markdown.tsx index 45a179a..b58d781 100644 --- a/mobile/src/ui/thread/Markdown.tsx +++ b/mobile/src/ui/thread/Markdown.tsx @@ -304,6 +304,8 @@ function renderList(lines: string[], start: number, blockKey: string): { element function parseLevel(index: number, indent: number, ordered: boolean, keyBase: string): ListParse { const items: ListItem[] = []; let next = index; + const firstMatch = LIST_ITEM.exec(lines[index]); + const orderedStart = ordered && firstMatch ? Number.parseInt(firstMatch[2], 10) : undefined; while (next < lines.length) { const match = LIST_ITEM.exec(lines[next]); @@ -343,7 +345,13 @@ function renderList(lines: string[], start: number, blockKey: string): { element ), ); return { - element: ordered ?
    {renderedItems}
:
    {renderedItems}
, + element: ordered ? ( +
    + {renderedItems} +
+ ) : ( +
    {renderedItems}
+ ), next, }; } diff --git a/mobile/src/ui/thread/ToolCard.tsx b/mobile/src/ui/thread/ToolCard.tsx index 17bd444..458f8d6 100644 --- a/mobile/src/ui/thread/ToolCard.tsx +++ b/mobile/src/ui/thread/ToolCard.tsx @@ -1,57 +1,12 @@ import { useEffect, useRef, useState } from 'react'; import type { JSX } from 'react'; import type { ToolItem } from '@/lib/timeline'; +import { toolDisplayName } from '@/ui/tools/toolPresentation'; interface ToolCardProps { item: ToolItem; } -const TOOL_LABELS: Record = { - powershell: 'Run', - bash: 'Run', - shell: 'Run', - view: 'View', - read: 'Read', - str_replace: 'Edit', - edit: 'Edit', - create: 'Create', - write: 'Write', - grep: 'Search', - glob: 'Find', - ls: 'List', -}; - -function titleCase(name: string): string { - return name - .replace(/[_-]+/g, ' ') - .replace(/\b\w/g, (c) => c.toUpperCase()) - .trim(); -} - -/** - * When the relay couldn't resolve a real tool name (falls back to the literal "tool"), infer a - * friendlier generic label from the argument shape instead of showing bare "Tool" in the header. - */ -function labelFromArgs(args: unknown): string | null { - if (!args || typeof args !== 'object') return null; - const record = args as Record; - if (typeof record.command === 'string') return 'Run'; - if (typeof record.old_string === 'string' || typeof record.new_string === 'string') return 'Edit'; - if (typeof record.pattern === 'string' || typeof record.query === 'string') return 'Search'; - if (typeof record.url === 'string') return 'Fetch'; - if (typeof record.path === 'string' || typeof record.file === 'string') return 'View'; - return null; -} - -function label(name: string, args?: unknown): string { - const known = TOOL_LABELS[name]; - if (known) return known; - if (!name || name.trim().toLowerCase() === 'tool') { - return labelFromArgs(args) ?? 'Tool'; - } - return titleCase(name); -} - interface ArgSummary { primary: string; isPath: boolean; @@ -79,6 +34,17 @@ interface EditDiff { const SHELL_TOOL_NAMES = new Set(['powershell', 'bash', 'shell']); +interface ShellPresentation { + command: string; + metadata: string[]; +} + +interface ShellResult { + output: string; + shellId?: string; + exitCode?: number; +} + /** * One-line summary of the most useful argument, flagging file-path args for basename styling. * For shell-style tools, the human-authored `description` is favored over the raw `command` @@ -226,6 +192,38 @@ function getEditDiff(name: string, args: unknown): EditDiff | null { return null; } +function getShellPresentation(name: string, args: unknown): ShellPresentation | null { + if (!SHELL_TOOL_NAMES.has(name.trim().toLowerCase())) return null; + const record = asRecord(args); + if (!record || typeof record.command !== 'string' || !record.command.trim()) return null; + + const metadata: string[] = []; + if (typeof record.mode === 'string' && record.mode.trim()) metadata.push(record.mode.trim()); + if (typeof record.initial_wait === 'number') metadata.push(`wait up to ${record.initial_wait}s`); + if (record.detach === true) metadata.push('detached'); + return { command: record.command.trim(), metadata }; +} + +function parseShellResult(value: string): ShellResult { + const complete = /(?:\r?\n)?]+)\s+completed with exit code\s+(-?\d+)>\s*$/.exec(value); + if (complete) { + return { + output: value.slice(0, complete.index).trimEnd(), + shellId: complete[1], + exitCode: Number.parseInt(complete[2], 10), + }; + } + + const started = /(?:\r?\n)?]+)>\s*$/.exec(value); + if (started) { + return { + output: value.slice(0, started.index).trimEnd(), + shellId: started[1].trim(), + }; + } + return { output: value }; +} + function elapsed(item: ToolItem): string { if (item.finishedAt) { const ms = Math.max(0, item.finishedAt - item.startedAt); @@ -246,8 +244,11 @@ export function ToolCard({ item }: ToolCardProps): JSX.Element { const hasDetail = !!argLine || !!item.resultPreview; const argsText = formatArgs(item.args); const resultText = item.resultPreview ?? ''; - const canViewFull = isLongOutput(resultText); const editDiff = getEditDiff(item.name, item.args); + const shell = getShellPresentation(item.name, item.args); + const shellResult = shell ? parseShellResult(resultText) : null; + const displayedResult = shellResult?.output ?? resultText; + const canViewFull = isLongOutput(displayedResult); useEffect(() => { return () => { @@ -275,7 +276,7 @@ export function ToolCard({ item }: ToolCardProps): JSX.Element { onClick={() => hasDetail && setExpanded((v) => !v)} > - {label(item.name, item.args)} + {toolDisplayName(item.name, item.args)} {argLine ? ( argPath ? ( @@ -315,6 +316,30 @@ export function ToolCard({ item }: ToolCardProps): JSX.Element { ))}
+ ) : shell ? ( + <> +
+ INPUT + +
+
{shell.command}
+ {shell.metadata.length > 0 ? ( +
+ {shell.metadata.map((entry) => {entry})} +
+ ) : null} +
+ View raw arguments +
{argsText}
+
+ ) : argLine ? ( <>
@@ -334,12 +359,12 @@ export function ToolCard({ item }: ToolCardProps): JSX.Element { {item.resultPreview ? ( <>
- {item.status === 'error' ? 'ERROR' : 'RESULT'} + {item.status === 'error' ? 'ERROR' : 'OUTPUT'} @@ -353,9 +378,23 @@ export function ToolCard({ item }: ToolCardProps): JSX.Element { ) : null}
-
{item.resultPreview}
+
+                {displayedResult || 'No output'}
+              
) : null} + {shell ? ( +
+ + {item.status === 'running' + ? 'Running' + : shellResult?.exitCode === undefined + ? item.status === 'success' ? 'Completed' : 'Failed' + : `Exit ${shellResult.exitCode}`} + + {shellResult?.shellId ? shell {shellResult.shellId} : null} +
+ ) : null}
) : null} diff --git a/mobile/src/ui/thread/__tests__/ChatThread.test.tsx b/mobile/src/ui/thread/__tests__/ChatThread.test.tsx index a86a5b6..9759c88 100644 --- a/mobile/src/ui/thread/__tests__/ChatThread.test.tsx +++ b/mobile/src/ui/thread/__tests__/ChatThread.test.tsx @@ -90,14 +90,14 @@ describe('ChatThread', () => { />, ); - const toolButton = screen.getByRole('button', { name: /Runnpm test42ms/i }); + const toolButton = screen.getByRole('button', { name: /Run Commandnpm test42ms/i }); expect(toolButton.closest('.tool-card')).toHaveClass('success'); expect(toolButton).toHaveAttribute('aria-expanded', 'false'); await user.click(toolButton); expect(toolButton).toHaveAttribute('aria-expanded', 'true'); - expect(screen.getByText('ARGUMENTS')).toBeInTheDocument(); - expect(screen.getByText('RESULT')).toBeInTheDocument(); + expect(screen.getByText('INPUT')).toBeInTheDocument(); + expect(screen.getByText('OUTPUT')).toBeInTheDocument(); expect(screen.getByText(/"command": "npm test"/)).toBeInTheDocument(); expect(screen.getByText('passed')).toBeInTheDocument(); }); @@ -510,6 +510,31 @@ describe('letting the reader scroll away while the agent is still writing', () = expect(screen.getByRole('button', { name: 'Scroll to latest' })).toBeInTheDocument(); }); + it('honors touch intent when a heartbeat lands before momentum scroll updates position', async () => { + const { scroller, scrollIntoView, rerender } = mountStreaming(); + await settleThread(); + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 }); + Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 600 }); + Object.defineProperty(scroller, 'scrollTop', { configurable: true, value: 400 }); + fireEvent.scroll(scroller); + + fireEvent.touchStart(scroller, { touches: [{ clientY: 200 }] }); + fireEvent.touchMove(scroller, { touches: [{ clientY: 230 }] }); + fireEvent.touchEnd(scroller); + scrollIntoView.mockClear(); + + // A real heartbeat toggles activity/status and can resize the available thread area before + // Android reports the momentum scroll position. Neither rerender nor resize may reclaim it. + rerender([{ kind: 'assistant', id: 'a1', text: 'one two', ts: now }]); + fireEvent(window, new Event('resize')); + + expect(scrollIntoView).not.toHaveBeenCalled(); + + scrollUp(scroller); + fireEvent.scroll(scroller); + expect(screen.getByRole('button', { name: 'Scroll to latest' })).toBeInTheDocument(); + }); + it('does not treat prepended history as a newly sent phone prompt', async () => { const scrollIntoView = vi.fn(); Element.prototype.scrollIntoView = scrollIntoView as never; diff --git a/mobile/src/ui/thread/__tests__/Markdown.test.tsx b/mobile/src/ui/thread/__tests__/Markdown.test.tsx new file mode 100644 index 0000000..e7c8c2a --- /dev/null +++ b/mobile/src/ui/thread/__tests__/Markdown.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Markdown } from '@/ui/thread/Markdown'; + +describe('Markdown', () => { + it('preserves ordered-list numbering when paragraphs split items into separate lists', () => { + render( + , + ); + + const lists = screen.getAllByRole('list'); + expect(lists).toHaveLength(3); + expect(lists[0]).toHaveAttribute('start', '1'); + expect(lists[1]).toHaveAttribute('start', '2'); + expect(lists[2]).toHaveAttribute('start', '3'); + }); + + it('preserves the starting marker for a contiguous ordered list', () => { + render(); + + expect(screen.getByRole('list')).toHaveAttribute('start', '4'); + expect(screen.getAllByRole('listitem')).toHaveLength(2); + }); +}); diff --git a/mobile/src/ui/thread/__tests__/ToolCard.test.tsx b/mobile/src/ui/thread/__tests__/ToolCard.test.tsx index 6937266..a0692bf 100644 --- a/mobile/src/ui/thread/__tests__/ToolCard.test.tsx +++ b/mobile/src/ui/thread/__tests__/ToolCard.test.tsx @@ -4,6 +4,46 @@ import { describe, expect, it } from 'vitest'; import { ToolCard } from '@/ui/thread/ToolCard'; describe('ToolCard', () => { + it('renders shell input, output, and completion status as separate surfaces', async () => { + const user = userEvent.setup(); + + render( + ', + startedAt: 1, + finishedAt: 2001, + ts: 1, + }} + />, + ); + + await user.click(screen.getByRole('button', { name: /Run CommandCheck working tree2\.0s/i })); + + expect(screen.getByText('INPUT')).toBeInTheDocument(); + expect(screen.getByText('OUTPUT')).toBeInTheDocument(); + expect(screen.getByText('git -C "C:\\repos\\weft" status --short')).toHaveClass('tc-command'); + expect(screen.getByText('M mobile/src/App.tsx')).toHaveClass('tc-output'); + expect(screen.getByText('Exit 0')).toBeInTheDocument(); + expect(screen.getByText('shell 7')).toBeInTheDocument(); + expect(screen.getByText('sync')).toBeInTheDocument(); + expect(screen.getByText('wait up to 120s')).toBeInTheDocument(); + expect(screen.getByText(/"command":/)).not.toBeVisible(); + + await user.click(screen.getByText('View raw arguments')); + expect(screen.getByText(/"command":/)).toBeVisible(); + }); + it('renders edit tool arguments as a colored unified diff', async () => { const user = userEvent.setup(); diff --git a/mobile/src/ui/tools/__tests__/toolPresentation.test.ts b/mobile/src/ui/tools/__tests__/toolPresentation.test.ts new file mode 100644 index 0000000..af85e69 --- /dev/null +++ b/mobile/src/ui/tools/__tests__/toolPresentation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { compactToolDetail, toolDisplayName } from '@/ui/tools/toolPresentation'; + +describe('tool presentation', () => { + it('uses the shared explainable labels for internal tool names', () => { + expect([ + 'rg', + 'glob', + 'view', + 'apply_patch', + 'powershell', + 'skill', + 'task', + 'read_agent', + 'write_agent', + 'web_fetch', + 'web_search', + 'ask_user', + ].map((name) => toolDisplayName(name))).toEqual([ + 'Search', + 'Find Files', + 'View', + 'Edit Files', + 'Run Command', + 'Activate Skill', + 'Start Agent', + 'Read Agent', + 'Message Agent', + 'Fetch Web Page', + 'Search Web', + 'Ask User', + ]); + }); + + it('title-cases understandable names and keeps compact details private', () => { + expect(toolDisplayName('create_report')).toBe('Create Report'); + expect(compactToolDetail('view', { path: 'C:\\private\\repo\\App.tsx' })).toBe('App.tsx'); + expect(compactToolDetail('rg', { pattern: 'private search text' })).toBeNull(); + expect(compactToolDetail('powershell', { command: 'secret command' })).toBeNull(); + expect(compactToolDetail('view', { description: 'Read C:\\private\\repo\\App.tsx' })).toBe('Read App.tsx'); + }); +}); diff --git a/mobile/src/ui/tools/toolPresentation.ts b/mobile/src/ui/tools/toolPresentation.ts new file mode 100644 index 0000000..932b8b4 --- /dev/null +++ b/mobile/src/ui/tools/toolPresentation.ts @@ -0,0 +1,76 @@ +const TOOL_LABELS: Record = { + rg: 'Search', + glob: 'Find Files', + view: 'View', + apply_patch: 'Edit Files', + powershell: 'Run Command', + skill: 'Activate Skill', + task: 'Start Agent', + read_agent: 'Read Agent', + write_agent: 'Message Agent', + web_fetch: 'Fetch Web Page', + web_search: 'Search Web', + ask_user: 'Ask User', +}; + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +function titleCase(name: string): string { + return name + .replace(/[_-]+/g, ' ') + .replace(/\b\w/g, (character) => character.toUpperCase()) + .trim(); +} + +function basename(path: string): string { + const normalized = path.replace(/\\/g, '/'); + return normalized.slice(normalized.lastIndexOf('/') + 1); +} + +function hideFullPaths(value: string): string { + return value + .replace(/[A-Za-z]:\\(?:[^\\\s]+\\)*([^\\\s]+)/g, '$1') + .replace(/(^|\s)\/(?:[^/\s]+\/)+([^/\s]+)/g, '$1$2'); +} + +function inferredToolLabel(args: unknown): string | null { + const record = asRecord(args); + if (!record) return null; + if (typeof record.command === 'string') return 'Run Command'; + if (typeof record.old_string === 'string' || typeof record.new_string === 'string') return 'Edit Files'; + if (typeof record.pattern === 'string' || typeof record.query === 'string') return 'Search'; + if (typeof record.url === 'string') return 'Fetch Web Page'; + if (typeof record.path === 'string' || typeof record.file === 'string') return 'View'; + return null; +} + +export function toolDisplayName(name: string, args?: unknown): string { + const normalized = name.trim().toLowerCase(); + if (TOOL_LABELS[normalized]) return TOOL_LABELS[normalized]; + if (!normalized || normalized === 'tool') return inferredToolLabel(args) ?? 'Tool'; + return titleCase(name); +} + +export function compactToolDetail(name: string, args: unknown): string | null { + const record = asRecord(args); + if (!record) return null; + + const description = typeof record.description === 'string' ? record.description.trim() : ''; + if (description) return hideFullPaths(description); + + const normalized = name.trim().toLowerCase(); + if (normalized === 'view' || normalized === 'read' || normalized === 'edit' || normalized === 'create') { + const path = + typeof record.path === 'string' ? record.path : + typeof record.file === 'string' ? record.file : + typeof record.file_path === 'string' ? record.file_path : + null; + return path ? basename(path) : null; + } + + return null; +} diff --git a/openspec/changes/polish-session-command-experience/.openspec.yaml b/openspec/changes/polish-session-command-experience/.openspec.yaml new file mode 100644 index 0000000..0ca5fbe --- /dev/null +++ b/openspec/changes/polish-session-command-experience/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-26 diff --git a/openspec/changes/polish-session-command-experience/design.md b/openspec/changes/polish-session-command-experience/design.md new file mode 100644 index 0000000..e0d8b4a --- /dev/null +++ b/openspec/changes/polish-session-command-experience/design.md @@ -0,0 +1,91 @@ +## Context + +Weft `0.2.27` already keeps the active chat mounted beneath Explore, projects real assistant/tool activity into a bottom dock, and exposes a shared whitelist of phone-invokable CLI commands. However, command arguments are represented only as `none | optional | required`, Chat and Explore humanize tool names independently, and the chat-follow state depends too heavily on transient layout measurements. Two local commits from the previous 48 hours also contain useful UX fixes that never received a PR and now include stale release metadata. + +The implementation spans `shared`, `extension`, and `mobile`, so compatibility and validation must remain centralized. Relays continue to carry encrypted envelopes only; this change does not expose additional private session content. + +## Goals / Non-Goals + +**Goals:** + +- Recover only the still-missing behavior from the abandoned commits. +- Make explicit reader intent authoritative over heartbeat and layout churn. +- Provide one generic command-argument interface that supports future curated commands without Composer-specific branches. +- Offer a small, validated mobile model list while keeping model IDs out of visible UI. +- Make Chat and Explore tool names consistent and understandable. +- Add a direct mobile gesture into Discover without breaking browser Back, vertical scrolling, or Discover card swipes. +- Simplify the Explore live Copilot tile while preserving real ordered activity. + +**Non-Goals:** + +- Enumerating every model available to every Copilot account. +- Claiming an authoritative current model after reconnect when the CLI cannot report one. +- Changing relay encryption, pairing, or transport behavior. +- Restoring obsolete package versions, changelog entries, or entire abandoned commits. +- Replacing existing slash commands with a new remote execution mechanism. + +## Decisions + +### Recover abandoned work as focused edits + +The ordered-list, Start Session, resume-list, and shell ToolCard changes will be reapplied against current `main`. The stale commits will not be cherry-picked because they mix release bumps and unrelated code with behavior that has since evolved. + +### Deepen the shared command definition + +`PhoneCommand.arg` will become a discriminated input definition: + +- `none` +- `text` with required/optional semantics and a placeholder +- `options` with centrally declared values, labels, hints, aliases, and optional custom input + +Shared helpers will resolve commands and validate option values. The mobile composer will consume the definition to drive command search, argument filtering, keyboard/touch selection, hidden values, confirmation, and submission. The extension will use the same definition to reject values that the phone was not allowed to send. + +The curated `/model` definition will expose `Auto`, `GPT-5.6 Sol`, `Claude Sonnet 5`, and `Gemini 3.8 Flash`. Visible labels remain separate from internal CLI arguments. A successful command invocation can confirm the phone-requested choice for the current connection, but reconnects will not pretend that locally remembered state is authoritative. + +### Centralize tool presentation + +A small mobile presentation module will map internal names to explainable labels such as `Search`, `View`, `Edit Files`, `Activate Skill`, and `Read Agent`. It will prefer an existing human description for secondary detail, preserve useful basenames, and avoid exposing full paths or raw arguments in compact activity. ToolCard and Explore will share this interface. + +### Latch reader detachment from touch intent + +ChatThread will track the initial touch position and mark the reader detached as soon as a vertical gesture toward older messages crosses a small threshold. That latch survives heartbeat, busy-state, streaming, ResizeObserver, and momentum-scroll timing. It clears only when the reader genuinely returns near the bottom, taps Jump to latest, sends a phone prompt, or changes conversations. + +### Treat direct Discover entry as a distinct history shape + +App history will distinguish Explore-home navigation from a direct Chat-to-Discover entry. A right-edge gesture begins only on narrow/touch layouts, within a small right-edge activation zone, and only when conflicting overlays or the soft keyboard are absent. Horizontal dominance locks the gesture; a lightweight Discover preview follows the finger. Crossing the distance or velocity threshold opens Discover directly. Cancelling springs the preview back. One Back action returns to the same chat. + +### Make the live Copilot tile a presence card + +The tile will show: + +- a Copilot glyph and sentence-case state, +- elapsed time only after it becomes meaningful, +- one primary current activity, +- up to two lines of streaming assistant text, +- at most one muted previous activity, +- consecutive duplicate tool events collapsed, +- an integrated `Open chat` affordance. + +Attention and error states replace the activity feed with a single clear action. The card remains fully tappable and uses only real session events. + +## Risks / Trade-offs + +- **Curated model IDs may become unavailable** → Keep the list small, validate in the extension, surface the CLI failure, and preserve the prior session behavior. +- **Direct Discover gestures can conflict with vertical scrolling** → Require an edge start and horizontal dominance before preventing native scrolling. +- **Touch-intent latching can stop legitimate following** → Clear only on measured return to the bottom, explicit Jump to latest, phone send, or conversation change. +- **Shared command definitions can become overly general** → Support only the three input shapes needed now; avoid dynamic remote option providers until a second real adapter exists. +- **Activity deduplication can hide individual calls** → Collapse only adjacent identical compact labels; full Chat tool cards remain available. + +## Migration Plan + +1. Add backward-compatible shared command metadata and update both mobile and extension in the same release. +2. Reapply current-source versions of the abandoned UX changes. +3. Add focused tests for every recovered or newly introduced invariant. +4. Validate the complete monorepo and protocol artifacts. +5. Merge the feature PR, run the normal patch release, and verify production plus immutable manifests. + +Rollback is a normal revert followed by a patch release. No persistent data migration is required. + +## Open Questions + +None. The curated model catalogue is intentionally controlled by Weft and can be revised in future releases. diff --git a/openspec/changes/polish-session-command-experience/proposal.md b/openspec/changes/polish-session-command-experience/proposal.md new file mode 100644 index 0000000..fc35dcd --- /dev/null +++ b/openspec/changes/polish-session-command-experience/proposal.md @@ -0,0 +1,30 @@ +## Why + +Several recently completed mobile UX fixes never reached `main`, while the shipped chat and Explore surfaces still expose avoidable friction: heartbeat updates can pull readers to the latest message, internal tool names leak into user-facing activity, command arguments require ad hoc handling, and Discover lacks a direct gesture from chat. These issues should land together because they share the same goal: make Weft feel like a coherent mobile control surface for a live Copilot session. + +## What Changes + +- Recover the abandoned ordered-list numbering, Start Session name-field, compact resume-list, and shell ToolCard improvements without restoring stale release metadata. +- Preserve a reader's explicit scroll-away intent across heartbeat, streaming, and layout updates until they deliberately return to the latest message. +- Add a right-edge mobile gesture that reveals Discover from chat and returns to the same chat with one Back action. +- Centralize lightly humanized tool labels so Chat and Explore show consistent, explainable names without exposing raw internal identifiers. +- Redesign the Explore live Copilot tile around one current activity, streaming assistant text, deduplicated recent activity, meaningful elapsed time, and an integrated Open Chat action. +- Deepen the shared phone-command definition into a generic command-argument platform supporting no input, free text, curated options, filtering, hidden values, optional custom text, validation, and confirmation. +- Use that platform to present a curated mobile `/model` chooser while keeping model IDs internal and validating the selected value again on the extension. + +## Capabilities + +### New Capabilities + +- `mobile-session-command-experience`: Mobile chat, Explore, session-launch, resume-list, ToolCard, Markdown, and phone-command behavior for controlling a live Copilot CLI session. + +### Modified Capabilities + +None. + +## Impact + +- Shared phone-command declarations and runtime validation. +- Extension command invocation and tests. +- Mobile session state, composer command palette, ChatThread scrolling, ToolCard and Markdown rendering, Start Session and resume-list UX, Explore navigation and live activity presentation. +- OpenSpec, product documentation, focused tests, complete monorepo validation, release packaging, and hosted PWA deployment. diff --git a/openspec/changes/polish-session-command-experience/specs/mobile-session-command-experience/spec.md b/openspec/changes/polish-session-command-experience/specs/mobile-session-command-experience/spec.md new file mode 100644 index 0000000..842e88a --- /dev/null +++ b/openspec/changes/polish-session-command-experience/specs/mobile-session-command-experience/spec.md @@ -0,0 +1,87 @@ +## ADDED Requirements + +### Requirement: Ordered Markdown preserves authored numbering +The mobile transcript SHALL preserve the starting marker of every ordered Markdown list, including lists separated by paragraphs. + +#### Scenario: Paragraphs split numbered findings +- **WHEN** an assistant response contains separately rendered ordered-list blocks starting at 1, 2, and 3 +- **THEN** each rendered list starts at its authored number + +### Requirement: Start Session prioritizes naming before permissions +The New Session form SHALL place the optional session name before permission selection and keep the focused field usable above the mobile keyboard. + +#### Scenario: User names and starts a session from the keyboard +- **WHEN** the user focuses the session-name field, enters a valid name, and presses Enter +- **THEN** the field remains visible and the session starts with that name + +### Requirement: Resume sessions use a compact readable list +The Resume view SHALL present sessions as compact rows with title and age on the primary line, repository/branch metadata on the secondary line, and a clear lightweight selected state. + +#### Scenario: Many resumable sessions are visible +- **WHEN** the device returns multiple sessions +- **THEN** the view fits more rows than the previous card layout without losing title, age, repository, branch, or selection information + +### Requirement: Shell ToolCards separate command, output, and status +Expanded shell ToolCards SHALL present the command, options, output, completion status, exit code, and shell identifier as distinct readable surfaces while keeping raw arguments optional. + +#### Scenario: Completed shell command includes a shell marker +- **WHEN** a shell tool result ends with an internal shell completion marker +- **THEN** the visible output excludes that marker and the card displays the parsed exit code and shell identifier separately + +### Requirement: Reader scroll intent survives session heartbeats +The chat SHALL treat an explicit upward touch gesture as detached reading intent until the reader deliberately returns to the latest content. + +#### Scenario: Heartbeat arrives during momentum scrolling +- **WHEN** the reader swipes upward, releases the screen, and a heartbeat or resize update arrives before the next decisive scroll event +- **THEN** the transcript remains at the reader's position and offers Jump to latest + +### Requirement: Phone commands support generic argument experiences +The shared phone-command catalogue SHALL describe no-input, free-text, and curated-option arguments so the mobile composer can render and validate command arguments without command-specific UI branches. + +#### Scenario: Option command is selected +- **WHEN** the user selects a command with curated options +- **THEN** the existing suggestion panel displays filterable friendly choices and executes the hidden validated value + +#### Scenario: Text command is selected +- **WHEN** the user selects a command that accepts required or optional text +- **THEN** the composer accepts free text according to the shared requirement and placeholder + +### Requirement: Mobile model selection is curated and validated +The mobile `/model` experience SHALL expose only Weft-approved friendly choices, keep CLI model IDs out of visible UI, and validate the chosen value on both phone and extension. + +#### Scenario: User selects an approved model +- **WHEN** the user chooses a curated model from the `/model` argument list +- **THEN** Weft invokes `/model` with the hidden internal value and reports the real command outcome + +#### Scenario: Model is unavailable +- **WHEN** the CLI rejects a curated model for the current account or version +- **THEN** Weft displays the failure and does not claim that the model changed + +### Requirement: Tool activity uses consistent explainable labels +Chat and Explore SHALL use one centralized mapping for compact tool names while preserving detailed raw arguments only in expandable ToolCards. + +#### Scenario: Ripgrep activity is projected +- **WHEN** the session emits an `rg` tool event +- **THEN** compact activity displays `Search` rather than `Rg` or a raw search pattern + +### Requirement: Chat can enter Discover with a right-edge gesture +On supported mobile layouts, Chat SHALL allow a deliberate right-edge swipe left to reveal and open Discover without interfering with vertical scrolling or other overlays. + +#### Scenario: Gesture crosses the threshold +- **WHEN** a touch begins in the right-edge activation zone and moves left with horizontal dominance beyond the commit threshold +- **THEN** Discover opens directly and one Back action returns to the same chat + +#### Scenario: Gesture is cancelled +- **WHEN** the movement remains below threshold or becomes vertically dominant +- **THEN** the preview springs back and Chat remains active + +### Requirement: Explore live Copilot tile prioritizes current work +The Explore live Copilot tile SHALL present one clear current activity, real streaming assistant text, limited deduplicated history, meaningful elapsed time, and an integrated path back to chat. + +#### Scenario: Repeated identical tools complete +- **WHEN** adjacent tool events have the same compact presentation +- **THEN** the tile collapses them into one truthful summarized activity rather than rendering duplicate rows + +#### Scenario: Approval needs attention +- **WHEN** a pending approval or elicitation exists +- **THEN** the tile replaces the normal feed with one clear attention action that opens Chat diff --git a/openspec/changes/polish-session-command-experience/tasks.md b/openspec/changes/polish-session-command-experience/tasks.md new file mode 100644 index 0000000..65dc874 --- /dev/null +++ b/openspec/changes/polish-session-command-experience/tasks.md @@ -0,0 +1,47 @@ +## 1. Recover Abandoned Mobile UX + +- [x] 1.1 Preserve authored ordered-list start values and add focused Markdown tests +- [x] 1.2 Move the New Session name field above permissions and restore keyboard visibility plus Enter submission +- [x] 1.3 Restore compact resume rows with separated age and repository metadata +- [x] 1.4 Restore rich shell ToolCard input, output, metadata, status, and parsing tests + +## 2. Protect Chat Reading Position + +- [x] 2.1 Track older-message touch intent independently from transient bottom-gap measurements +- [x] 2.2 Preserve detached reading through heartbeat, streaming, resize, and momentum-scroll timing +- [x] 2.3 Add regression tests for heartbeat and ResizeObserver updates during mobile scrolling + +## 3. Deepen Phone Command Arguments + +- [x] 3.1 Replace the shallow command argument flag with shared no-input, text, and option definitions +- [x] 3.2 Add shared option lookup and validation helpers used by mobile and extension +- [x] 3.3 Refactor the composer suggestion flow into command and argument stages with filtering and keyboard/touch selection +- [x] 3.4 Add curated mobile model choices with hidden IDs and extension-side allowlist validation +- [x] 3.5 Add shared, extension, and mobile tests for text, option, custom, confirmation, failure, and model flows + +## 4. Refine Chat and Explore Navigation + +- [x] 4.1 Add direct Discover history state and correct one-step return to Chat +- [x] 4.2 Add right-edge swipe intent, preview, commit, cancellation, overlay guards, and reduced-motion behavior +- [x] 4.3 Add focused tests for threshold, direction, cancellation, direct Back, and disabled contexts + +## 5. Centralize Tool Presentation and Redesign the Live Tile + +- [x] 5.1 Create one explainable tool-label mapping shared by ToolCard and Explore +- [x] 5.2 Replace the debug-log tile layout with a Copilot presence card and integrated Open Chat action +- [x] 5.3 Deduplicate adjacent compact tool activities and preserve real streaming assistant text +- [x] 5.4 Add tests for mappings, elapsed-time thresholds, deduplication, attention states, and chat return + +## 6. Documentation and Validation + +- [x] 6.1 Update README and relevant product documentation for command options, model selection, gestures, and live activity +- [x] 6.2 Run focused shared, extension, composer, chat, Start Session, ToolCard, Markdown, App, and Explore tests +- [x] 6.3 Run mobile type tests, complete repository tests, build, lint, version check, and strict OpenSpec validation +- [x] 6.4 Review the integrated diff for protocol compatibility, privacy, accessibility, gesture conflicts, and unrelated changes + +## 7. Land and Release + +- [ ] 7.1 Create and link the matching work item and feature PR +- [ ] 7.2 Merge the feature after all required checks pass +- [ ] 7.3 Build and deploy the next patch release with the normal release script +- [ ] 7.4 Merge the release PR, verify production and immutable manifests, and complete the work item diff --git a/shared/commands.d.ts b/shared/commands.d.ts index 5a8f430..a49196a 100644 --- a/shared/commands.d.ts +++ b/shared/commands.d.ts @@ -1,7 +1,27 @@ // SPDX-License-Identifier: Apache-2.0 // Types for the phone-invokable Copilot CLI slash-command whitelist. See commands.mjs. -export type PhoneCommandArg = "none" | "optional" | "required"; +export interface PhoneCommandOption { + /** Internal CLI value; never use as visible UI copy. */ + value: string; + /** Friendly value shown in the phone palette. */ + label: string; + /** Optional supporting copy shown under the label. */ + hint?: string; + /** Friendly search and input aliases. */ + aliases?: ReadonlyArray; +} + +export type PhoneCommandInput = + | { kind: "none" } + | { kind: "text"; required: boolean; placeholder: string } + | { + kind: "options"; + required: boolean; + allowCustom: boolean; + placeholder: string; + options: ReadonlyArray; + }; export interface PhoneCommand { /** Canonical command name (no leading slash), lower-case. */ @@ -10,8 +30,8 @@ export interface PhoneCommand { label: string; /** One-line description shown under the label. */ hint: string; - /** Whether the command takes free-text input after the name. */ - arg: PhoneCommandArg; + /** Argument experience and validation rules. */ + input: PhoneCommandInput; /** Require an explicit phone confirmation before running (destructive / permission-broadening). */ confirm?: boolean; } @@ -27,3 +47,17 @@ export function getPhoneCommand(name: string): PhoneCommand | null; /** True iff `name` is a command the phone is allowed to invoke. */ export function isPhoneCommandAllowed(name: string): boolean; + +/** Resolve a curated option by internal value, friendly label, or alias. */ +export function getPhoneCommandOption( + commandOrName: PhoneCommand | string, + raw: unknown, +): PhoneCommandOption | null; + +/** Validate and canonicalize a phone-command argument. */ +export function validatePhoneCommandInput( + commandOrName: PhoneCommand | string, + raw: unknown, +): + | { valid: true; input?: string; option?: PhoneCommandOption } + | { valid: false; error: string }; diff --git a/shared/commands.mjs b/shared/commands.mjs index cb98b12..3f3d816 100644 --- a/shared/commands.mjs +++ b/shared/commands.mjs @@ -11,33 +11,76 @@ // /theme, /ide, /help, /copy, /login, /voice (weft Voice Mode), /new (weft spawn), /pr, /delegate… /** - * @typedef {"none" | "optional" | "required"} PhoneCommandArg + * @typedef {Object} PhoneCommandOption + * @property {string} value Internal CLI value; never used as visible copy. + * @property {string} label Friendly value shown in the phone palette. + * @property {string} [hint] Optional supporting copy shown under the label. + * @property {ReadonlyArray} [aliases] Friendly search and input aliases. + * + * @typedef {{ kind: "none" }} PhoneCommandNoInput + * @typedef {{ kind: "text", required: boolean, placeholder: string }} PhoneCommandTextInput + * @typedef {{ kind: "options", required: boolean, allowCustom: boolean, placeholder: string, options: ReadonlyArray }} PhoneCommandOptionsInput + * @typedef {PhoneCommandNoInput | PhoneCommandTextInput | PhoneCommandOptionsInput} PhoneCommandInput + * * @typedef {Object} PhoneCommand * @property {string} name Canonical command name (no leading slash), lower-case. * @property {string} label Short human label for the palette. * @property {string} hint One-line description shown under the label. - * @property {PhoneCommandArg} arg Whether the command takes free-text input after the name. + * @property {PhoneCommandInput} input Argument experience and validation rules. * @property {boolean} [confirm] Require an explicit phone confirmation before running * (destructive / permission-broadening commands). */ +const noInput = Object.freeze({ kind: "none" }); + +function textInput(required, placeholder) { + return Object.freeze({ kind: "text", required, placeholder }); +} + +function optionsInput(required, allowCustom, placeholder, options) { + return Object.freeze({ + kind: "options", + required, + allowCustom, + placeholder, + options: Object.freeze( + options.map((option) => + Object.freeze({ + ...option, + ...(option.aliases ? { aliases: Object.freeze([...option.aliases]) } : {}), + }), + ), + ), + }); +} + /** @type {ReadonlyArray} */ export const PHONE_COMMANDS = Object.freeze( [ // --- Tier 1: safe, fire-and-return, meaningful when driving from a phone --- - { name: "rename", label: "/rename", hint: "Rename this session", arg: "required" }, - { name: "compact", label: "/compact", hint: "Summarize context to free space", arg: "optional" }, - { name: "model", label: "/model", hint: "Switch model (give a model id)", arg: "required" }, - { name: "autopilot", label: "/autopilot", hint: "Toggle autopilot mode", arg: "optional" }, - { name: "plan", label: "/plan", hint: "Enter plan mode", arg: "none" }, - { name: "review", label: "/review", hint: "Review the current changes", arg: "none" }, - { name: "security-review", label: "/security-review", hint: "Security-review the changes", arg: "none" }, - { name: "rubber-duck", label: "/rubber-duck", hint: "Independent critique of the work", arg: "none" }, - { name: "keep-alive", label: "/keep-alive", hint: "Keep the laptop awake", arg: "optional" }, + { name: "rename", label: "/rename", hint: "Rename this session", input: textInput(true, "Session name") }, + { name: "compact", label: "/compact", hint: "Summarize context to free space", input: textInput(false, "Optional focus") }, + { + name: "model", + label: "/model", + hint: "Switch the model for this session", + input: optionsInput(true, false, "Choose a model", [ + { value: "auto", label: "Auto", hint: "Recommended", aliases: ["recommended", "default"] }, + { value: "gpt-5.6-sol", label: "GPT-5.6 Sol", aliases: ["gpt", "sol"] }, + { value: "claude-sonnet-5", label: "Claude Sonnet 5", aliases: ["claude", "sonnet"] }, + { value: "gemini-3.8-flash", label: "Gemini 3.8 Flash", aliases: ["gemini", "flash"] }, + ]), + }, + { name: "autopilot", label: "/autopilot", hint: "Toggle autopilot mode", input: textInput(false, "Optional instructions") }, + { name: "plan", label: "/plan", hint: "Enter plan mode", input: noInput }, + { name: "review", label: "/review", hint: "Review the current changes", input: noInput }, + { name: "security-review", label: "/security-review", hint: "Security-review the changes", input: noInput }, + { name: "rubber-duck", label: "/rubber-duck", hint: "Independent critique of the work", input: noInput }, + { name: "keep-alive", label: "/keep-alive", hint: "Keep the laptop awake", input: textInput(false, "Optional duration") }, // --- Tier 2: allowed but require an explicit confirm on the phone --- - { name: "allow-all", label: "/allow-all", hint: "Allow all tools, paths & URLs", arg: "none", confirm: true }, - { name: "clear", label: "/clear", hint: "Abandon this session, start fresh", arg: "none", confirm: true }, - ].map((c) => Object.freeze(c)), + { name: "allow-all", label: "/allow-all", hint: "Allow all tools, paths & URLs", input: noInput, confirm: true }, + { name: "clear", label: "/clear", hint: "Abandon this session, start fresh", input: noInput, confirm: true }, + ].map((command) => Object.freeze(command)), ); /** Normalize free-form input ("/Rename", " rename ") to a canonical command name. */ @@ -60,3 +103,60 @@ export function getPhoneCommand(name) { export function isPhoneCommandAllowed(name) { return getPhoneCommand(name) !== null; } + +function normalizeOptionLookup(raw) { + return typeof raw === "string" ? raw.trim().toLocaleLowerCase() : ""; +} + +/** + * Resolve a curated option by internal value, friendly label, or alias. + * @param {PhoneCommand | string} commandOrName + * @param {unknown} raw + * @returns {PhoneCommandOption | null} + */ +export function getPhoneCommandOption(commandOrName, raw) { + const command = + typeof commandOrName === "string" ? getPhoneCommand(commandOrName) : commandOrName; + if (!command || command.input.kind !== "options") return null; + const lookup = normalizeOptionLookup(raw); + if (!lookup) return null; + return ( + command.input.options.find((option) => + [option.value, option.label, ...(option.aliases ?? [])].some( + (candidate) => candidate.toLocaleLowerCase() === lookup, + ), + ) ?? null + ); +} + +/** + * Validate and canonicalize a phone-command argument. + * @param {PhoneCommand | string} commandOrName + * @param {unknown} raw + * @returns {{ valid: true, input?: string, option?: PhoneCommandOption } | { valid: false, error: string }} + */ +export function validatePhoneCommandInput(commandOrName, raw) { + const command = + typeof commandOrName === "string" ? getPhoneCommand(commandOrName) : commandOrName; + if (!command) return { valid: false, error: "Command is not allowed from the phone." }; + + const input = typeof raw === "string" ? raw.trim() : ""; + if (command.input.kind === "none") { + return input + ? { valid: false, error: `/${command.name} does not accept an argument.` } + : { valid: true }; + } + + if (!input) { + return command.input.required + ? { valid: false, error: `/${command.name} needs an argument.` } + : { valid: true }; + } + + if (command.input.kind === "text") return { valid: true, input }; + + const option = getPhoneCommandOption(command, input); + if (option) return { valid: true, input: option.value, option }; + if (command.input.allowCustom) return { valid: true, input }; + return { valid: false, error: `That value isn't available for /${command.name}.` }; +} diff --git a/shared/test/commands.test.mjs b/shared/test/commands.test.mjs index 8c582a4..57c130b 100644 --- a/shared/test/commands.test.mjs +++ b/shared/test/commands.test.mjs @@ -7,7 +7,9 @@ import { PHONE_COMMANDS, normalizeCommandName, getPhoneCommand, + getPhoneCommandOption, isPhoneCommandAllowed, + validatePhoneCommandInput, } from "../commands.mjs"; import { EVENT_TYPE, SUBTYPE, invokeCommand } from "../messages.mjs"; @@ -20,7 +22,12 @@ test("PHONE_COMMANDS is a frozen, non-empty list with well-formed entries", () = assert.equal(c.name, c.name.toLowerCase()); assert.doesNotMatch(c.name, /^\//); // no leading slash in canonical name assert.equal(c.label, `/${c.name}`); - assert.ok(["none", "optional", "required"].includes(c.arg)); + assert.ok(["none", "text", "options"].includes(c.input.kind)); + assert.ok(Object.isFrozen(c.input)); + if (c.input.kind === "options") { + assert.ok(Object.isFrozen(c.input.options)); + assert.ok(c.input.options.every((option) => Object.isFrozen(option))); + } assert.ok(Object.isFrozen(c)); } }); @@ -53,6 +60,71 @@ test("confirm-gated commands are marked and destructive", () => { assert.equal(getPhoneCommand("plan")?.confirm, undefined); }); +test("model exposes curated friendly options with hidden canonical values", () => { + const model = getPhoneCommand("model"); + assert.equal(model?.input.kind, "options"); + assert.deepEqual( + model.input.options.map(({ value, label, hint }) => ({ value, label, hint })), + [ + { value: "auto", label: "Auto", hint: "Recommended" }, + { value: "gpt-5.6-sol", label: "GPT-5.6 Sol", hint: undefined }, + { value: "claude-sonnet-5", label: "Claude Sonnet 5", hint: undefined }, + { value: "gemini-3.8-flash", label: "Gemini 3.8 Flash", hint: undefined }, + ], + ); +}); + +test("option lookup accepts values, friendly labels, and aliases", () => { + assert.equal(getPhoneCommandOption("model", "GPT-5.6 Sol")?.value, "gpt-5.6-sol"); + assert.equal(getPhoneCommandOption("model", "recommended")?.value, "auto"); + assert.equal(getPhoneCommandOption("model", "claude-sonnet-5")?.label, "Claude Sonnet 5"); + assert.equal(getPhoneCommandOption("rename", "anything"), null); +}); + +test("input validation preserves text/no-input behavior and canonicalizes options", () => { + assert.deepEqual(validatePhoneCommandInput("plan", ""), { valid: true }); + assert.deepEqual(validatePhoneCommandInput("plan", "extra"), { + valid: false, + error: "/plan does not accept an argument.", + }); + assert.deepEqual(validatePhoneCommandInput("rename", " My Session "), { + valid: true, + input: "My Session", + }); + assert.deepEqual(validatePhoneCommandInput("rename", ""), { + valid: false, + error: "/rename needs an argument.", + }); + + const model = validatePhoneCommandInput("model", "Sonnet"); + assert.equal(model.valid, true); + assert.equal(model.input, "claude-sonnet-5"); + assert.equal(model.option?.label, "Claude Sonnet 5"); + assert.deepEqual(validatePhoneCommandInput("model", "unlisted-model"), { + valid: false, + error: "That value isn't available for /model.", + }); +}); + +test("option validation supports custom values only when the definition allows them", () => { + const customCommand = { + name: "custom", + label: "/custom", + hint: "Custom option command", + input: { + kind: "options", + required: true, + allowCustom: true, + placeholder: "Choose or type", + options: [{ value: "known", label: "Known" }], + }, + }; + assert.deepEqual(validatePhoneCommandInput(customCommand, "other"), { + valid: true, + input: "other", + }); +}); + test("invokeCommand builds a CONTROL/INVOKE_COMMAND envelope, omitting empty input", () => { const bare = invokeCommand("plan"); assert.equal(bare.eventType, EVENT_TYPE.CONTROL); From 98e229348dd85e7241f341092865feb60c277e39 Mon Sep 17 00:00:00 2001 From: Ashish Kumar Date: Fri, 25 Sep 2026 17:41:03 -0700 Subject: [PATCH 2/2] docs: record feature pull request Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- openspec/changes/polish-session-command-experience/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/polish-session-command-experience/tasks.md b/openspec/changes/polish-session-command-experience/tasks.md index 65dc874..8ca8ffe 100644 --- a/openspec/changes/polish-session-command-experience/tasks.md +++ b/openspec/changes/polish-session-command-experience/tasks.md @@ -41,7 +41,7 @@ ## 7. Land and Release -- [ ] 7.1 Create and link the matching work item and feature PR +- [x] 7.1 Create and link the matching work item and feature PR - [ ] 7.2 Merge the feature after all required checks pass - [ ] 7.3 Build and deploy the next patch release with the normal release script - [ ] 7.4 Merge the release PR, verify production and immutable manifests, and complete the work item