diff --git a/AGENTS.md b/AGENTS.md index 700a14eb8..a9f6fb2de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,10 @@ app/api/ agent/new/route.ts POST { cwd, message, toolNames?, provider?, modelId? } agent/[id]/route.ts GET state | POST any RPC command agent/[id]/events/route.ts GET SSE stream - agent/running/events/route.ts GET SSE stream of currently-running session ids + agent/running/events/route.ts GET SSE stream of currently-running session ids (+ this tab's notifications) + notifications/devices/route.ts GET VAPID key + subscribed | PUT { deviceId, prefs, subscription? } + notifications/presence/route.ts POST { clientId, deviceId, visible, sessionId, seq } + notifications/test/route.ts POST { deviceId } send a test push (502 on push-service refusal) auth/** provider list, login/logout, API keys (via RPC) cwd/validate/route.ts POST validate/select a cwd default-cwd/route.ts POST create ~/omp-cwd-YYYYMMDD @@ -83,10 +86,14 @@ app/api/ skills/route.ts GET/PATCH loaded skills and disable-model-invocation skills/install/route.ts POST install skills through npx skills add skills/search/route.ts GET/POST skills.sh search + stt/route.ts POST audio (+scope) → 202 { jobId } | GET ?scope= live jobs (lib/stt-jobs.ts) + stt/[jobId]/route.ts GET job state | POST retry with kept audio | DELETE ?claim= claim/discard + stt/[jobId]/audio/route.ts GET the kept recording (audio/* only) for playback worktrees/route.ts GET/POST/DELETE git worktrees lib/ omp/ shared omp foundations (paths, CLI probe, RpcProcess) + provider-accounts.ts distinct omp accounts per provider from `omp usage` reports (Models → provider detail) agent-client.ts typed fetch helper for /api/agent commands btw.ts /btw side-question records + pure frame/snapshot merge (order-safe) draft-store.ts local draft persistence helpers @@ -100,6 +107,9 @@ lib/ pi-types.ts local structural types for agent/RPC objects project-ordering.ts pure project sort/group/activity helpers (client + tests) project-registry.ts on-disk managed-project registry (~/.omp/agent/projects.json) + notification-events.ts shared: event/prefs types, frame→event detector, renderNotification() + notification-hub.ts server: presence, device store + VAPID keys, routing, Web Push + notification-client.ts browser: device id, prefs store, service worker + push subscription rpc-manager.ts session registry + startRpcSession over RpcProcess session-reader.ts session .jsonl parsing + path cache + buildSessionContext session-resume.ts running-session list for auto-resume after a restart @@ -108,6 +118,7 @@ lib/ tool-presets.ts PRESET_NONE/DEFAULT/FULL + getToolNamesForPreset() types.ts shared TypeScript types normalize.ts normalizeToolCalls() — field name mismatch between file format and our types + navigation-history.ts pure back/forward view-history stack + shortcut matcher (⌘[/⌘], Alt+←/→) word-prediction.ts pure ghost-text arithmetic (advance/accept) for composer word prediction worktree.ts project/worktree resolution and git worktree operations @@ -139,10 +150,12 @@ components/ hooks/ useAgentSession.ts messages + streaming + SSE + fork/navigate/reconciliation logic useAudio.ts completion sound + browser AudioContext unlock + useNotifications.ts presence reports, toast/system delivery, notification clicks useDragDrop.ts shared drag/drop state useIsMobile.ts responsive breakpoint hook usePrefersReducedMotion.ts OS reduce-motion preference (SMIL-safe) useTheme.ts theme state (localStorage key "omp-theme") + useNavigationHistory.ts in-app back/forward stack (record/peek/commit/drop) for visited chat views useWordPrediction.ts debounced omp predict_word ghost text + feedback useBtw.ts /btw records/active panel/history dialog fed by btw_* SSE frames ``` @@ -186,6 +199,29 @@ hooks/ - Known limit: resume does not detect a terminal `omp --resume ` started on the same session while omp-web was down; both would write the file. +### Session moves (omp >= 18.5 ownership) and title generation +- Only the first omp process to write a session file owns it; a non-owner moves + to a sibling file with a new id on its first write and emits a + `notice` with `source: "session-persistence"`. `handleFrame` answers it with + `followSessionMove()`: the pending flag makes the next `applyIdentity` treat + the id change as the same conversation (stream/run state kept, **old id kept + as a registry alias**, new id registered via `onIdentityChange({keepOldId})`). + Branch/new/switch still drop the old key. `startRpcSession` also reuses any + live wrapper reporting the requested session file instead of spawning a second + `--resume` child (which would fork again). `onDestroy` removes every key that + points at the wrapper. +- `POST /api/sessions/[id]/auto-name` asks omp to generate the title + (`AgentSessionWrapper.generateTitle()`: native `generate_title`, else + argument-less `/rename`, never while a run is in flight). Only when omp cannot + does it fall back to the stored/derived title (`generated:false`), saved + through the live process when there is one. + +### Browser compatibility +Client code (`components/`, `hooks/`) must not call `AbortSignal.timeout`, +`AbortSignal.any` or `Promise.withResolvers` — older mobile browsers throw and +the whole tree shows the "unexpected error" screen. Use `AbortController` + timers and +executor-style promises instead. Server code may use them freely. + ### Two kinds of branching — don't confuse them - **Fork** ("Fork a new session from this point" button, `messageView.newSessionTitle`, on user and assistant messages; only offered while the session is idle — ChatWindow gates it on `!sessionBusy && !isNew`): creates a new independent `.jsonl` file via omp's `branch` RPC. Shown as a child in the sidebar tree via `parentSession` header field. `branch` only takes a user entry and keeps the history *before* it, so `lib/chat-fork.ts` maps rows: a user prompt forks at itself and its returned text prefills the fork's composer (edit-and-resend, text only — attached images are not restored); an assistant reply forks at the next user prompt so the reply is kept; the newest reply falls back to its own prompt with the prefill. Rows that would edit the very first prompt (an empty fork) offer no fork. - **In-session branch** (Continue button / BranchNavigator): navigates the entry tree within the same file. Multiple entries share the same `parentId`. Switching between them calls `/api/sessions/[id]/context?leafId=`. @@ -230,18 +266,27 @@ client-side — every client viewing the session must show the same queue. One sequence (`queueSeqRef`) orders every source: a get_state snapshot takes a number when requested and applies only if no newer snapshot or `queue_update` was applied (HTTP and SSE can reorder). Edit/Delete use -`remove_queued_message` (act only on `removed: true`), Steer uses +`remove_queued_message` (act only on `removed: true`; newer omp also returns +the message's `images`, which Edit restores), Steer uses `promote_queued_message`; the chip changes when omp's next snapshot arrives. -`handleAbort` coalesces overlapping Stops, then withdraws pending messages BEFORE sending `abort` (bounded by -`WITHDRAW_BEFORE_ABORT_MS`), like the TUI's Esc: omp runs a queued steer as -soon as an abort lands. Withdrawn text goes to the session draft via -`recoverDraftText`, saved as each removal confirms. A follow-up that answers -`removed: false` is retried on `steering` (a concurrent promotion moved it); -never the reverse. The abort is fenced to the prompt run id captured at Stop, -so it cannot kill a prompt started during the wait. Known gap: input taken by -live steering answers `removed: false`, and RPC `abort` does not call -`withdrawLiveSteering` (the TUI's `clearQueue({ forInterrupt: true })` does), -so omp requeues it on abort and runs it next; omp-web cannot prevent that. +`handleAbort` coalesces overlapping Stops, then sends `abort_and_restore_queue`: +omp's Esc (`clearQueue({ forInterrupt: true })`, then abort) in one step, +returning the withdrawn user messages, whose texts and images go to the +session draft via `recoverDraft`. omp labels an image-only message `[Image]`; +that label is never restored as text. It covers what a client snapshot cannot: a steer promoted +after the last `queue_update`, and live-steered input the run claimed but never +recorded (omp would otherwise requeue it and drain it into a new turn right +after the abort). Never reimplement this client-side. A failed request is +retried once (omp returns whatever is still queued) and only while the run +captured at the click is current; texts lost with a response that never +arrived cannot be recovered, so the hook warns (`queueRestoreUncertain`). +Fallback ONLY when omp answers "Unknown command" (omp without the command): +withdraw each listed message with `remove_queued_message` BEFORE sending +`abort` (bounded by `WITHDRAW_BEFORE_ABORT_MS`), saved as each removal +confirms. A follow-up that answers `removed: false` is retried on `steering` +(a concurrent promotion moved it); never the reverse. Every abort is fenced to +the prompt run id captured at the click, so it cannot kill a prompt started +during the wait. ### Running state SSE + reconciliation - The sidebar listens to `/api/agent/running/events`, backed by `subscribeRunningSessions()` in `lib/rpc-manager.ts`, so running badges update without polling. @@ -269,9 +314,9 @@ so omp requeues it on abort and runs it next; omp-web cannot prevent that. running `btw_record` comes first. - `/btw ` and `/btw` are client builtins (`handleBuiltinSlashCommand` case `"btw"`). `ChatInput.sendSideQuestion` routes them there from both the - idle and the streaming submit path, *before* the attachment gate: never sent - as a prompt, never queued, and refused with a toast (draft and attachments - kept) while attachments are attached. Asking starts the wrapper + idle and the streaming submit path, *before* attachments are folded in: + never sent as a prompt, never queued, and refused with a toast (draft and + attachments kept) while attachments are attached. Asking starts the wrapper (`get_state`) and attaches SSE first when it is not open, so no early delta is lost; a second ask while one is starting is ignored. - The `btw` response, history snapshots and frames race (HTTP vs SSE): merge @@ -461,6 +506,37 @@ so omp requeues it on abort and runs it next; omp-web cannot prevent that. - The Models panel reads and writes `models.yml` in the omp agent directory (`~/.omp/agent/models.yml`, `.yaml` fallback). - API-key status endpoints must never return the raw key. +### Navigate back / forward (`lib/navigation-history.ts`, `hooks/useNavigationHistory.ts`) +- Browser-style back/forward over visited chat views (sessions + the new-chat + composer), in-memory per page load. It is **omp-web's own stack**, never the + browser History API — the app only ever `router.replace`s `?session=`, and + the real history stack belongs to the mobile back-gesture / exit-guard + machinery (`useSidebarHistory` + the popstate bridge). +- AppShell records views from one effect keyed on + `(selectedSession?.id, newSessionCwd)`: recording the entry the cursor + already sits on is a no-op, which is what makes back/forward + self-suppressing — `navigateInHistory` commits the step, the view lands, the + effect re-records the target, nothing is pushed. Any other view change + (sidebar/palette select, new chat, session created, fork, project-switch + close) pushes normally and truncates the forward branch, browser-style. +- Applying a step: peek → resolve the session id via `/api/sessions` → + commit + `handleSelectSession`, or `handleNewSession` for new-chat entries. + A dead id (deleted session) drops that entry and tries the next one in the + same direction; a failed list fetch aborts without dropping. A view change + during the await (versioned ref) aborts the navigation so a slow fetch + never yanks the chat away. +- Shortcuts live in `useGlobalKeyboardShortcuts`: ⌘[/⌘] (macOS standard), + Alt+←/Alt+→ (Windows/Linux standard; on macOS Alt+Arrow stays free — it is + word-wise caret movement), plus the mouse back/forward buttons + (`BrowserBack`/`BrowserForward`). The keystroke is always swallowed while a + handler is registered — an exhausted stack stops dead rather than falling + through to the browser's own back/forward, so the app is never backed out + of by accident — and shortcuts are skipped entirely while a + `[role="dialog"]` modal is open. The sidebar header buttons (before Archived + Sessions, wrapped in `.sidebar-nav-buttons`) disable on stack bounds, show + the platform shortcut in their tooltip, and hide below a 240px sidebar via + the `.sidebar-shell` container query (the keyboard shortcuts still work). + ### Composer word prediction (`hooks/useWordPrediction.ts`, `components/GhostMirror.tsx`) - Ghost text comes from omp's `predict_word` RPC (engine = omp's `spelling.autocomplete` setting; omp applies the prose gates). Tab or → @@ -480,10 +556,93 @@ so omp requeues it on abort and runs it next; omp-web cannot prevent that. `GhostMirror`), not ChatInput state: re-rendering the composer per ghost change was the dominant per-keystroke cost. +### Voice transcription jobs (`lib/stt-jobs.ts`, `/api/stt`, `hooks/useDictation.ts`) +- The browser never waits on the STT endpoint: `POST /api/stt` keeps the + recording in memory and starts a job; the hook polls `GET /api/stt/[jobId]` + (proxies with ~30s timeouts would otherwise return HTML 504s). Job failures + are 200 payloads for the same reason. +- Jobs carry the composer scope (`draftKey`: session id or `new:`). The + hook adopts the newest job for its scope on mount, focus, visibility and + every 4s while visible and idle, so another browser can play (`/audio`), + retry (`POST`) or discard it. Leaving the scope stops following without + discarding. +- A finished job is delivered only by claim: + `DELETE ?claim=&owner=` returns the text to the + first claim token (repeatable with the same token); polls never carry text, + and other composers see `gone` and stand down silently. A claim on an + unfinished job is a no-op; a `DELETE` without `claim` discards and aborts + the upstream request. +- Two tokens, never merged. The tab token (sessionStorage) identifies the + job owner: it survives the composer remounting (`AppShell` keys + `ChatWindow` by session), gets the 15s first claim, and alone gets the + send/queue choice back. A duplicated tab copies it, so exclusivity comes + from the claim token, which is per hook instance. +- The send/queue choice (`after`: send | steer | followup) is uploaded with + the recording, kept on the job, and returned with the owner's claim. Never + keep it only in component state: a session switch remounts the composer and + loses it. A non-owner claim only inserts, since that composer holds its own + draft and attachments. +- Store is per process (`globalThis` map) with caps (4 pending, 20 live) and + TTLs; a server restart loses jobs, and the hook then re-uploads its local + copy if it has one. + ### Completion sound - `hooks/useAudio.ts` stores the toggle in `localStorage` and reuses one `AudioContext`. - Browser autoplay policy means sound must be unlocked from a user gesture; `ChatInput` calls the unlock hook from interactive controls, and `ChatWindow` plays the tone from `onAgentEnd`. +### Notifications (`lib/notification-*.ts`, `public/sw.js`, Settings → Notifications) +- Detection runs on the server in `AgentSessionWrapper.handleFrame` (and + `handleProcessExit` for crashes), so sessions no tab watches still notify. + `createNotificationDetector()` only inspects top-level frames; subagent + activity is wrapped in `subagent_*` frames and never notifies. "Completed" + fires on `session_settled` (not `prompt_result`), so background work started + by the prompt is done too; a failed or aborted last prompt suppresses it. + Auto-approved tool confirmations return before the hook and never notify. + omp sends `retry_fallback_applied` before the `auto_retry_start` of the same + failure, so the detector holds a reason-less fallback until that frame. + Failures also arrive as omp-web's own `prompt_error` (failed `response`) and + `process_exit` frames; the detector reports one failure per run. +- A session omp moved to a new file keeps its old ids as `aliases` on the + event, so a tab still showing an old id counts as viewing it. +- Presence and streams are separate on purpose. Presence: each tab reports + `{clientId, deviceId, visible, sessionId}` to `/api/notifications/presence` + (visibility/session change, input after idling, every 30 s while present; + expires 60 s after receipt). `visible` means visible AND input within 3 min. + `sessionId` is null while full-page Settings hides the chat. Streams: the + tab's open SSE connections (`attachNotificationClient`), which reconnect and + overlap independently of presence. `clientId` is per page load, `deviceId` + per browser (`localStorage`). Reports carry a per-page `seq`; the server + drops one older than the stored report (requests can arrive out of order). + Prefs PUTs run one at a time and read the prefs when sent, for the same reason. +- Routing (`routeNotification`, unit-tested): a visible tab on the session + drops the event everywhere (presence only, so a reconnecting stream cannot + leak it); else if a visible tab with an open stream exists, `whenActive: + "toast"` devices get a toast on their active tabs only; else push (subscribed + devices) or in-page `showNotification` (tabs with an open stream). +- The server decides before pushing: iOS revokes push permission for pushes + that display nothing, so `public/sw.js` always shows what it receives. +- Notifications ride the sidebar's `/api/agent/running/events?clientId=` stream, + not a new SSE connection: browsers allow six HTTP/1.1 connections per host. + `SessionSidebar` re-dispatches them as a window event. The sidebar unmounts + while Settings is open, so that tab gets no toasts then; it also stops + counting as active, so other devices are not silenced. +- Prefs and push subscriptions are per device, in `localStorage` and mirrored to + `~/.omp/agent/omp-web/notifications.json` (mode 0600; also holds the VAPID + private key). The server reads that file on every use, never caching it: an + installed server and `npm run dev` can share the agent dir. Push text is + rendered server-side in the device's locale. Browsers with saved settings (enabled or not) sync on load; never-configured browsers do not. +- A crash notifies "Run failed" while a run is live or while background work + started by it has not settled yet (`unsettled`, cleared by `session_settled`). +- The Settings test button pushes through the server for subscribed devices + (502 with the push service's answer on failure) and shows a local + notification otherwise, or a toast where none are allowed. +- `/sw.js` and `/badge-96.png` are exempt from the password gate (`proxy.ts`): + browsers re-fetch the worker for updates without the session cookie. +- Notification clicks survive an expired sign-in: `proxy.ts` sends `/?session=…` + to `/login?next=/?session=…`, and `LoginForm` returns there (only `/?…` + targets are accepted). The worker navigates non-app windows to the session + URL; the app falls back to that URL when `/api/sessions` cannot be read. + ## omp Session File Format (v3) Location: `~/.omp/agent/sessions//_.jsonl` @@ -529,7 +688,10 @@ motion: --dur-fast (150ms) --dur-med (220ms) --dur-slow (320ms) --ease-out-warm `components/ui/` holds the shared primitives (built on `@base-ui/react`): `primitives.tsx` (Dialog/Tooltip/Collapsible), `field.tsx` (form fields + -ConfirmDialog), `toast.tsx` (`toast.success/error/info`, mounted in AppShell). +ConfirmDialog), `toast.tsx` (`toast.success/error/info`, mounted in AppShell; +the `onClick` option makes the whole card the action: a click outside its +buttons, links and expandable text, ignoring drags and text selection, or Enter +on the focused card. There is no separate action button). Icons come from `lucide-react` — do not add new inline SVGs. The command palette (`components/CommandPalette.tsx`, ⌘K/Ctrl+K) is built on `cmdk`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 728389cc5..2f918fdf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,20 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f ### Added +- Add **Settings → Notifications**: desktop and mobile notifications for a finished task (after the prompt and any background work it started are done; subagents never notify), a question or approval waiting for you, a run that failed after retries and fallbacks, and a model switch omp made on its own (for example Fable falling back to Opus after a refusal, or a usage-limit fallback). Each browser chooses its own events and whether to show an in-app toast or a system notification while you use omp-web in another tab. Nothing is shown for the session you are viewing, and clicking a notification opens its session; an in-app toast opens it from a click or tap anywhere on it, or Enter when it has keyboard focus. Over HTTPS (or on `localhost`) notifications arrive through Web Push even with no omp-web tab open, including the iPhone/iPad app added to the Home Screen; the push keys and device list live in `~/.omp/agent/omp-web/notifications.json`. Notifications are off until you turn them on. This replaces the previous completion notification for the open session, and the agent's `notify` tool now also works on Android. +- Choose **Classifier** or **Vendor default** in Settings → AI Model Defaults → **Auto Thinking Source** when Reasoning is `auto`. The choice is saved in omp's native config for newly started or restarted agents. Vendor mode prefers the publisher's effort, then omp's per-model default, then normal Auto fallback. Requires an omp release containing [can1357/oh-my-pi#14114](https://github.com/can1357/oh-my-pi/pull/14114). +- Show conflicting skill names and deduplicated installations when an OMP session starts, with resolution details, a **×** button that dismisses the notice for the session until the diagnostic report changes, and a persisted **Turn off** action that disables startup notices. Inspect a running session manually in **Settings → Extensions & Tools → Skills**, even when notices are off; inspection never resumes a stopped session. Requires an OMP runtime with the skill-diagnostics RPC commands; older runtimes report live diagnostics as unavailable. - Ask a side question with `/btw `: omp answers from the current conversation in a panel above the composer, even while the agent is working, and the question never enters the chat. Cancel a running answer, copy it, or ask follow-ups in the same topic. `/btw` on its own lists the session's past side questions, including ones asked in the terminal, each expandable to all its turns with **Copy** and **Follow up**. Side questions are text-only: with an attachment in the composer, `/btw` is refused and the draft is kept. An answer that omp lost (for example after its process restarted) shows as **Interrupted** instead of spinning. Requires an omp with the `btw` RPC commands; older omp shows "/btw requires a newer omp" instead of sending the text to the model. -- Show omp's Claude usage-limit badge in the composer toolbar, matching the terminal status line during the wrap-up allowance or once `/slow` low priority takes over. Reset times use the browser's locale and timezone. Requires an omp version whose `get_state` reports structured `anthropicSlowMode` state. +- Show omp's Claude usage-limit badge in the composer toolbar, matching the terminal status line during the wrap-up allowance or once `/slow` low priority takes over. Reset times use the browser's locale and timezone. Requires an omp version whose `get_state` reports structured `usageLimit` state. +- Add a **Slow** toggle next to **Fast** in the composer toolbar, the equivalent of omp's `/slow`. On Claude models it turns low priority on or off in your omp settings, so it applies to every session and terminal; on OpenAI and Google models it switches this session to the provider's flex tier. Once the session's omp process runs, the toggle follows omp's `get_state` (`slowModeSupported`); before that, including in a new session, it follows the selected model's provider (`anthropic`, `openai`, `openai-codex`, `google`, `google-vertex`) and, on Claude, the `providers.anthropic.slowMode` setting. Clicking it there starts the session's omp process. +- Show the composer's reasoning level with TUI-style icons, alongside its name on desktop and without the name on narrow toolbars. The menu pairs each icon with its level name. Icons use consistent, vertically centered shapes rather than font-dependent Unicode glyphs, with larger mobile controls, smaller desktop controls, and support for high-contrast colors. Keep the model icon visible on phones and reduce unused space between the model and reasoning controls without wrapping the standard toolbar controls. Provider-defined levels retain their names with normal truncation. +- Keep the reasoning menu in one column on phones as well as desktop, with one full-width level per row instead of side-by-side chips. - Add **Paste image** to the composer's **+** menu, with Japanese and Simplified Chinese translations. Requires HTTPS (or localhost), browser support for clipboard image reads, and clipboard permission; the item is hidden where the browser cannot read images from the clipboard. Attaching a file remains available. +- Name web app installations, iOS home-screen titles, and browser tabs after the server's hostname (without port), so multiple servers can be told apart. Localhost and IP addresses keep the generic `omp web` name; named LAN hosts are preserved. Tabs show `workspace - installation name`. Existing installations may need to be re-added to pick up the new name. - Create a workspace by cloning a Git repository: in the **Add workspace** dialog, enter an `https://` or ssh URL and choose **Clone here** to clone into a new folder under the selected directory, which then becomes the active workspace. Clone progress streams into the dialog; **Cancel clone** stops git and deletes the partial clone. git never prompts, so private repositories need a credential helper or ssh agent. +- Add a manual refresh button to the sidebar's Provider Usage panel, with the same success feedback as workspace refresh. Manual refresh bypasses omp-web's usage cache and invalidates omp's cached reports before fetching fresh limits; automatic five-minute refresh remains unchanged. Manual refresh requires an omp that supports `usage invalidate`; an unsupported or failed refresh shows as unavailable instead of reporting success. - Add an off-by-default **Resume running sessions after a restart** setting in Settings → System & Updates. When omp-web restarts while agents are working, it restarts those sessions and prompts each with "Session interrupted and resumed. Continue as you would have done without the interruption." Work in progress at the moment of the restart, such as a running command, is lost. Do not also resume those sessions from a terminal while omp-web is down. -- Add **Copy** and **Copy as Markdown** below user messages and completed assistant replies, with keyboard access and touch-sized controls. Copy only message text, excluding thinking, tool output, and renderer controls; preserve full source for oversized raw-text messages. +- Add **Copy** and **Markdown** below user messages and completed assistant replies, with keyboard access and touch-sized controls. Copy only message text, excluding thinking, tool output, and renderer controls; preserve full source for oversized raw-text messages. Shorten message actions to **Fork** and **Read** to leave more room on mobile; the speech tooltip and accessible label remain **Read aloud**. - Scope Ctrl+A / Cmd+A to the selected message, currently loaded chat, or active file contents instead of the whole page. Message selection includes collapsed extension previews and expanded details without toolbar labels. Newer pane focus takes precedence over retained child selections. Text fields and IME composition retain native behavior; browser-menu commands and embedded viewers remain browser-controlled. - Add an off-by-default **Scope native Select All (experimental)** switch in Settings → Interface & Behavior. The per-browser preference narrows whole-page selections from native menus while leaving keyboard scoping independent. Disable it if browser selection handles or menus behave unexpectedly; intentional whole-page selections can also be narrowed. - Play back a voice recording before transcribing or sending it. Pause keeps a left-side preview control; Stop opens a review deck with play, discard, and transcribe-and-send. @@ -21,12 +29,24 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - Show all of an agent's ask-tool questions in one panel, with checkboxes for multi-select, radio buttons for single-select (recommended option marked and preselected), and an **Other** free-text answer per question, submitted together. Requires an omp that supports `set_ask_dialog`; older omp keeps the one-question-at-a-time dialog. - Make `agent://` subagent handles in chat messages clickable. Bare handles, inline code containing only a handle, and Markdown links open that subagent's result dialog; `agent://Parent/Child` opens the nested `Parent.Child` subagent when the roster knows it. A `read` of an `agent://` handle opens the same dialog from its tool row. - Show the full model name, including its provider (for example `anthropic/claude-sonnet-5-5`), and the reasoning effort in the subagent result dialog, for running subagents and for finished ones after a reload. Finished background subagents take them from their saved transcript, so they also appear in the composer's subagent chips. +- Attach images and text files while the agent is running, and send them with a steer or a queued follow-up. Attaching through **+**, paste, or drag and drop now works during a run; with only attachments in the composer, **Stop** becomes **Queue**. An image-only message shows as `[Image]` in the queue. A steer or follow-up that omp refuses returns to the composer with its images, and so does a queued message taken back by **Edit** or **Stop**. Getting images back from **Edit** and **Stop** requires an omp with `abort_and_restore_queue` and image-returning `remove_queued_message` ([can1357/oh-my-pi#14179](https://github.com/can1357/oh-my-pi/pull/14179)); older omp returns only the text. ### Fixes & Improvements +- The completion notification omp-web used to show for the open session when its tab was in the background is gone, along with its permission prompt on the first finished run. Turn on **Settings → Notifications** to get notifications again; they now cover every session, not only the open one. +- Balance the composer icons on phones and narrow toolbars with a slightly smaller effort-level icon and a larger Slow-mode snail, while keeping menu icon sizes unchanged. +- Keep the model picker on the left and reasoning effort, Fast, Slow, microphone, and Send/Stop controls in a right-aligned composer group, including wrapped mobile rows. +- The Provider Usage panel now shows each account's email instead of omp's redacted form (`an*`); accounts without an email show as **Account N**. Only the email, plan and limits reach the browser. +- The composer's reasoning menu now uses the same borderless rows as the model picker instead of bordered cards. +- The context popover now uses the room above the composer instead of a fixed 380px cap, so it only scrolls when the window is too short for it. +- Fix settings crashes, stalled session catch-up, and failed voice transcription polling in browsers without `AbortSignal.timeout`, `AbortSignal.any`, or `Promise.withResolvers`, including development-mode clients. Client requests now use `AbortController` timers and ordinary promises while retaining timeouts and cancellation (#196). +- Allow development requests from all RFC1918 private IPv4 ranges, not just `192.168.*.*`. Set `OMP_WEB_DEV_ORIGIN` to allow a tunnel or reverse-proxy hostname without editing `next.config.ts`; the setting is development-only. +- Fix authenticated app-installation metadata: serve `/api/manifest` through the existing password guard with one credentialed manifest link and embedded packaged PNG icons, avoiding cookie-free native installer icon downloads. Preserve the app identity and root launch URL/scope, retain private revalidating caching, and remove the obsolete public-manifest exception. Verified in local Chromium with HTTP icon URLs blocked and no installability errors; the owner also confirmed successful installation on a physical phone behind Cloudflare Access. +- Keep workspace names and their rename fields visible on narrow sidebars by showing the Git branch on a compact second line instead of letting it squeeze the project name. Keep the separator and activity indicator beside the workspace name on the first line; long branch names remain truncated and clickable for worktree selection, with a 24px-high tap target and enough row space to avoid overlapping the workspace name. - The **Agent environment variables** editor in Settings → System & Updates now sits below its title and description and follows the page width, instead of sitting beside them and extending past the right edge. The text box also grows with its contents. - Show queued steers and follow-ups on every device viewing a session. The queue panel now shows omp's own queue (omp 18.4.4 or later) instead of a copy kept by the tab that sent them, so it also stays correct across reloads. -- **Stop** no longer loses a queued steer or lets the agent run it anyway. The text of messages still waiting in the queue moves back into the composer (images attached to them are dropped). Requires omp 18.4.4 or later. A steer the model already picked up through live steering (for example on OpenAI models with `providers.openaiLiveSteering`) cannot be taken back: omp requeues it when the run stops and runs it next. +- **Stop** no longer loses a queued steer or lets the agent run it anyway: as with Esc in omp's terminal, every message still waiting in the queue moves back into the composer. With omp's `abort_and_restore_queue` (newer omp), omp takes the queue back and aborts in one step, which also covers a steer you just promoted that this page's queue had not caught up with, and one the model already picked up through live steering (for example OpenAI models with `providers.openaiLiveSteering`). If that request fails, Stop retries it once and warns when the messages may not have come back. Older omp (18.4.4 or later) falls back to removing each message the page lists before the abort; there, a steer the page does not list or the model already took still runs after Stop, and attached images are dropped. +- Long voice transcriptions no longer fail with `Unexpected token '<', "/omp-web/` instead of `~/.omp/omp-web/`, so isolated test and development servers no longer write to the real journal. - Slide the file panel in from the right edge on phones, with the same timing as the left sidebar, instead of popping it open full-screen. The first open slides too, without a **Loading…** placeholder or a dimmed backdrop flashing over the chat. The panel still covers the whole screen once open, and it opens and closes instantly when reduced motion is enabled. +- On mobile, move the session information control (context ring with stats, tokens, cost, and **Compact context**) to the top-right of the top bar; desktop keeps it in the composer toolbar. Preserve the ring usage coloring, its popover, and the `/session` shortcut. Opening the panel moves focus into it; Escape dismisses it before run shortcuts and returns focus to its opener when focus is still within the panel. +- Make the mobile workspace sidebar full-screen without a dimmed backdrop, with a visible top-left close button. Place the file-panel opener inside the upper bar and add a separate top-right close button inside the file panel, with keyboard focus returning to its opener. Keep the session-title pill centered between the header controls. +- Continue the mobile file-panel tab-row border underneath its close-button cell, so the divider spans the full width. +- On mobile, swipe anywhere on the app surface: swipe right to open the left sidebar or close the right one, and swipe left for the opposite action. No device-edge start or perfectly straight movement is required. Recognition follows overall dominant-axis displacement and tolerates curved thumb movement, a brief corrective start, and shorter deliberate drags. Taps, predominantly vertical movement, multi-touch, text editing or selection, native horizontal scrolling, and open popups retain their normal behavior; persistent drawer lists, including Git changes, remain swipeable. Embedded previews retain native input and full width; swipe on their surrounding toolbar to close the sidebar. +- Let the centered session-title pill fit its content and grow for longer names up to the available header space, instead of stretching short names into a fixed-size box. - Keep **Collapse input** available after expanding a long user message with **Show full input**, so the message can be collapsed again. - Agent host tools (`open_url`, `notify`, `open_file`) no longer fail when you switch to another session mid-run. Any open omp-web tab now answers them, and a URL or file from a session you are not viewing opens only after you confirm it in a dialog. - Ask before opening links from the agent. Turn on **Open agent links without asking** in Settings → Interface & Behavior to open links from the session you are viewing right away; links from other sessions always ask. @@ -109,7 +134,6 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - Expand complete tool inputs inline, including multiline code and edit patches, while keeping command previews compact and output visibility unchanged. - Keep composer controls on one line, with equally sized Send, Stop, and Queue buttons and model names truncating before short effort labels. - Align the + button and primary action with matching composer insets. -- Clearly dim Attach files while the agent is running; queued messages remain text-only. - Recover saved responses before reporting an empty agent reply after returning to a backgrounded page or PWA. Preserve provider errors and distinguish new runs from older answers. - Catch up missed conversation entries incrementally after reconnecting or returning to the page, including during active runs. Restore quiet partial responses and live tool output without duplicating history or overwriting newer updates. - Send prompts with image attachments in full again: commands reach OMP as one unchunked JSONL record. Protocol-v2 `rpc_chunk` framing is outbound-only, so any prompt over 1 MiB was rejected as `Unknown command: rpc_chunk` and reset the session after the prompt-ack timeout. diff --git a/README.md b/README.md index 0e5a78847..78b928ba2 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,60 @@ Queue **Delete** and **Edit** additionally require `remove_queued_message`. Dele The queue panel shows omp's own queue (`queuedMessages` in `get_state` and `queue_update` events, omp 18.4.4 or later), so every device viewing a session sees the same queued messages. **Stop** moves the text of messages still waiting in the queue back into the composer instead of letting the agent run them; a steer the model already picked up through live steering still runs. Older omp runtimes show no queue panel, and Stop cannot take queued messages back. +## App installation behind authentication + +To install the web app, sign in first and use your browser's installation menu. +The single manifest link requests `/api/manifest` with credentials; that endpoint +uses the existing web-password guard and private, revalidating caching. Its +192×192 and 512×512 PNG icons are embedded from the packaged assets because +Android's native installer fetches ordinary icon URLs without authentication +cookies. The app still launches at `/` with scope `/`. + +Keep Cloudflare Access and application authentication enabled; no public +manifest exception or Access bypass rule is needed. This does not add offline +support. Local Chromium verification covered cookie-gated metadata with HTTP +icon URLs blocked (zero installability errors). The owner also confirmed that +installation on a physical phone works as expected behind Cloudflare Access. + +Skill startup notices require an OMP runtime with `get_skill_diagnostics`, +`set_skill_startup_diagnostics`, and `skill_diagnostics_update`. Conflicts and +redundant installations appear above the composer when its OMP session starts; +an empty new-chat page does not start OMP just for diagnostics. **Details** shows +the resolved default, variants, identical copies, backing paths, sources, and +selection reason. The **×** button dismisses the notice for that session until +the diagnostic report changes. **Turn off** and **Settings → +Interface & Behavior → Skill startup notices** use OMP's persisted +`skills.showStartupDiagnostics` preference, not a separate browser setting. +**Settings → Extensions & Tools → Skills → View skill diagnostics** remains +available for a selected running session when notices are off. Inspection does +not resume stopped sessions. Missing support or no running session is unavailable, +not a clean result. + +## Notifications + +Turn on notifications in **Settings → Notifications**. Each browser keeps its own +settings: which events notify (task finished, waiting for input, run failed, +model switched automatically) and what happens while you use omp-web in another +tab (an in-app toast, or always a system notification). Nothing is shown for the +session you are viewing. Clicking a notification opens its session. + +- **Push** reaches the browser even when no omp-web tab is open. It needs a + secure context (HTTPS through your reverse proxy, or `localhost`), and the + omp-web server needs outbound HTTPS access to the browser vendors' push + services. omp-web generates its push (VAPID) keys on first use and stores them + with the device list in `~/.omp/agent/omp-web/notifications.json` (mode 0600). +- On **iPhone and iPad** (iOS 16.4 or later), push works only in the app added + to the Home Screen. +- In a secure context without push support, system notifications appear while + an omp-web tab is open. Over plain HTTP on a non-localhost address, browsers + allow no system notifications: only the in-app toasts shown while you use + omp-web remain. +- Desktop browsers show notifications through the operating system's + notification center. Push delivery needs the browser process running. + +Completion notifications use omp's `prompt_result` and `session_settled` RPC +frames (omp 18.3.1 or later). + ## Quick Start **Run directly without installing:** @@ -212,7 +266,8 @@ host (KDE Plasma, and most Wayland/X11 desktops). - **Windows System Tray & Service**: Background service, tray icon, logon autostart, and Desktop/Start Menu shortcuts (Windows). - **macOS launchd Service**: LaunchAgent that starts at login, restarts on crash, and logs under `~/Library/Logs/ompweb`. - **Linux systemd Service & Tray**: User service that starts at login and restarts on crash, plus a StatusNotifierItem tray icon with service controls (KDE Plasma and compatible desktops). -- **Web-based Settings** (8 tabs): Interface & Behavior, Safety & Approvals, AI Model Defaults, API Keys & Providers, Usage, Agent & Intelligence (advisor, memory, compaction), Agents, Extensions & Tools (MCP, skills, plugins), System & Updates. +- **Notifications**: Desktop and mobile (PWA) notifications for finished tasks, questions waiting for an answer, failed runs, and automatic model switches, with Web Push when served over HTTPS. See [Notifications](#notifications). +- **Web-based Settings** (10 tabs): Interface & Behavior, Safety & Approvals, AI Model Defaults, API Keys & Providers, Usage, Agent & Intelligence (advisor, memory, compaction), Agents, Extensions & Tools (MCP, skills, plugins), Notifications, System & Updates. - **Slash Commands & Shortcuts**: Quick prompts (`/plan`, `/review`, `/fix`, `/test`, etc.), `⌘K` / `Ctrl+K` palette, and model/reasoning cycling. - **UI Themes & Localization**: Warm paper light/dark themes plus an omp.sh-inspired midnight (`omp`) theme, chat font size & interface scale, with full English, Chinese (简体中文), and Japanese (日本語) translations. @@ -226,6 +281,7 @@ host (KDE Plasma, and most Wayland/X11 desktops). | `OMP_WEB_NO_OPEN` | Set to `1` to prevent auto-opening browser | `0` | | `OMP_WEB_DISABLE_AUTOUPDATE` | Set to `1` to disable update checks and in-app updates; restart after changing | `0` | | `OMP_WEB_OMP_BIN` | Path to `omp` binary if not on `PATH` | _auto-detected_ | +| `OMP_WEB_DEV_ORIGIN` | Additional allowed hostname for the development server (no scheme or port); ignored in production | _None_ | | `PI_CODING_AGENT_DIR` | Custom omp agent directory | `~/.omp/agent` | | `OMP_WEB_STT_ENDPOINT` | OpenAI-compatible transcription endpoint URL | _None (disabled)_ | | `OMP_WEB_STT_KEY` | Optional API key for the STT endpoint | _None_ | @@ -242,6 +298,19 @@ npm run dev The dev server runs at [http://127.0.0.1:30178](http://127.0.0.1:30178). +The development server allows loopback and RFC1918 private IPv4 origins +(`10.0.0.0/8`, `172.16.0.0/12`, and `192.168.0.0/16`). When using a tunnel +or reverse proxy with a custom hostname, set it without editing `next.config.ts`: + +```bash +OMP_WEB_DEV_ORIGIN=dev.example.com npm run dev +``` + +For a persistent setup, set the variable in your local environment or service +configuration and restart the dev server. This does not change the bind address +or enable authentication. Next.js hostname patterns cannot express IPv6 CIDRs; +a private IPv6 origin must be supplied explicitly (for example, `[fd00::1]`). + ### Checks ```bash diff --git a/app/api/agent/[id]/route.ts b/app/api/agent/[id]/route.ts index f4eb1ba92..d6b394545 100644 --- a/app/api/agent/[id]/route.ts +++ b/app/api/agent/[id]/route.ts @@ -18,6 +18,13 @@ const NO_SPAWN_REPLIES: Record = { btw_cancel: { cancelled: false }, }; +// Live diagnostics of a skill-resolving omp. A saved or exited session has none, +// and resuming a full child (MCP, LSP) just to answer a read-only inspection or +// flip one setting is not what the click asked for: with no live wrapper these +// answer 409 instead of starting omp, and a live idle child is never replaced +// because the advisor flag differs. +const LIVE_SESSION_ONLY = new Set(["get_skill_diagnostics", "set_skill_startup_diagnostics"]); + /** omp-web's own failures carry a stable code the client can localize; omp's * errors stay opaque English text. */ function commandErrorResponse(error: unknown) { @@ -60,14 +67,21 @@ export async function POST( // running and pick the flag up at the next natural respawn. const existing = getRpcSession(id); const noSpawn = Object.hasOwn(NO_SPAWN_REPLIES, body.type); + const liveOnly = LIVE_SESSION_ONLY.has(body.type); if (existing?.isAlive()) { - if (noSpawn || existing.advisorSpawned === advisor || existing.isRunning()) { + if (noSpawn || liveOnly || existing.advisorSpawned === advisor || existing.isRunning()) { const result = await existing.send(body); return NextResponse.json({ success: true, data: result }); } await existing.destroyAndWait(); } if (noSpawn) return NextResponse.json({ success: true, data: NO_SPAWN_REPLIES[body.type] }); + if (liveOnly) { + return NextResponse.json( + { error: "Skill diagnostics require a running session", code: "skill_diagnostics_unavailable" }, + { status: 409 }, + ); + } const resolved = await resolveSessionPathOr404(id); if ("response" in resolved) return resolved.response; diff --git a/app/api/agent/running/events/route.ts b/app/api/agent/running/events/route.ts index dcd0086ed..9216e4d7c 100644 --- a/app/api/agent/running/events/route.ts +++ b/app/api/agent/running/events/route.ts @@ -1,12 +1,17 @@ import { getExitedRpcSessions, getRunningRpcSessions, subscribeRunningSessions } from "@/lib/rpc-manager"; +import { isValidNotificationId } from "@/lib/notification-events"; +import { attachNotificationClient } from "@/lib/notification-hub"; import { subscribeSessionFileChanges } from "@/lib/session-watcher"; export const dynamic = "force-dynamic"; // GET /api/agent/running/events - SSE stream of the set of currently-running // session ids. Also carries refresh hints when a live session's file metadata -// changes, so the sidebar can show a newly-started session immediately. +// changes, so the sidebar can show a newly-started session immediately, and +// this tab's notifications when `clientId` is given (one stream per tab: +// browsers allow only six HTTP/1.1 connections per host). export async function GET(req: Request) { + const clientId = new URL(req.url).searchParams.get("clientId"); // Hoisted so the stream's cancel() (half-open disconnects that never fire // the abort signal) can release the heartbeat and the subscriber. let streamCleanup: (() => void) | null = null; @@ -18,6 +23,7 @@ export async function GET(req: Request) { let cleaned = false; let unsubscribeRunning: (() => void) | null = null; let unsubscribeFiles: (() => void) | null = null; + let detachNotifications: (() => void) | null = null; let heartbeatTimer: ReturnType | null = null; const cleanup = () => { @@ -36,6 +42,8 @@ export async function GET(req: Request) { try { unsubscribeFiles(); } catch {} unsubscribeFiles = null; } + detachNotifications?.(); + detachNotifications = null; req.signal?.removeEventListener("abort", cleanup); try { controller.close(); @@ -77,6 +85,10 @@ export async function GET(req: Request) { encode({ type: "sessions-changed", sessionIds, refreshSessionList: true }); }); + if (isValidNotificationId(clientId)) { + detachNotifications = attachNotificationClient(clientId, (message) => encode({ type: "notification", ...message })); + } + // Initial snapshot so the client renders the correct state immediately. const initialRunning = getRunningRpcSessions(); encode({ diff --git a/app/api/manifest/route.ts b/app/api/manifest/route.ts new file mode 100644 index 000000000..c31282b98 --- /dev/null +++ b/app/api/manifest/route.ts @@ -0,0 +1,33 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { MetadataRoute } from "next"; +import { getInstallName } from "@/lib/install-name"; + +// Packaged public assets are read/encoded once per server module. Native +// installers fetch HTTP icons without cookies, even with a credentialed manifest. +const pngDataUrl = (filename: string) => + `data:image/png;base64,${readFileSync(join(process.cwd(), "public", filename)).toString("base64")}`; + +const manifest: MetadataRoute.Manifest = { + description: "Web UI for the oh-my-pi (omp) coding agent", + start_url: "/", + scope: "/", + display: "standalone", + background_color: "#000000", + theme_color: "#000000", + icons: [ + { src: pngDataUrl("icon-192.png"), sizes: "192x192", type: "image/png" }, + { src: pngDataUrl("icon.png"), sizes: "512x512", type: "image/png" }, + ], +}; + +// Authentication is enforced by the existing /api/ web-password proxy guard. +export function GET(request: Request) { + const name = getInstallName(request.headers); + return Response.json({ ...manifest, name, short_name: name }, { + headers: { + "Content-Type": "application/manifest+json", + "Cache-Control": "private, no-cache, max-age=0, must-revalidate", + }, + }); +} diff --git a/app/api/models/route.ts b/app/api/models/route.ts index c55ecd1e9..b69b82ee5 100644 --- a/app/api/models/route.ts +++ b/app/api/models/route.ts @@ -3,6 +3,7 @@ import { invalidateModelsCache, loadModelsWithCache, withModelRuntimeError, with import { disposeUtilityRpc, runUtilityCommand, type OmpModel } from "@/lib/omp/rpc-utility"; import { getModelsConfigPath } from "@/lib/omp/paths"; import { readDisabledProviders } from "@/lib/omp/model-roles"; +import { readAnthropicSlowMode } from "@/lib/omp/settings-config"; export const dynamic = "force-dynamic"; @@ -60,6 +61,12 @@ function supportsFastMode(model: OmpModel): boolean { return model.provider === "anthropic" || model.provider === "openai" || model.provider === "google"; } +// Copy of omp's AgentSession#slowModeTarget for the composer's Slow toggle +// before omp runs: Claude low priority on `anthropic`, else the flex tier of +// the OpenAI/Google service-tier families. Live get_state wins once omp runs. +// ponytail: provider-only; OpenRouter's openai/google ids need omp's model identity. +const SLOW_MODE_PROVIDERS: Record = { anthropic: true, openai: true, "openai-codex": true, google: true, "google-vertex": true }; + async function loadModels(): Promise { const availableResponse = await runUtilityCommand<{ models?: unknown }>( { type: "get_available_models" }, @@ -81,7 +88,7 @@ async function loadModels(): Promise { const nameMap = new Map(); const thinkingLevels: Record = {}; const modelList = available - .map((m) => ({ id: m.id, name: m.name, provider: m.provider, thinkingLevels: thinkingLevelsFor(m), supportsFastMode: supportsFastMode(m), contextWindow: m.contextWindow ?? undefined, maxTokens: m.maxTokens ?? undefined })) + .map((m) => ({ id: m.id, name: m.name, provider: m.provider, thinkingLevels: thinkingLevelsFor(m), supportsFastMode: supportsFastMode(m), supportsSlowMode: SLOW_MODE_PROVIDERS[m.provider] === true, contextWindow: m.contextWindow ?? undefined, maxTokens: m.maxTokens ?? undefined })) .sort(compareModelEntries); const loginResponse = await runUtilityCommand<{ providers?: unknown }>( { type: "get_login_providers" }, @@ -137,9 +144,11 @@ const EMPTY_MODELS: ModelsData = { export async function GET() { refreshModelsIfConfigChanged(); + // Outside the cache: `/slow` on a Claude model rewrites config.yml. + const anthropicSlowMode = readAnthropicSlowMode(); try { - return Response.json(await loadModelsWithCache(MODELS_CACHE_KEY, () => loadModels())); + return Response.json({ ...await loadModelsWithCache(MODELS_CACHE_KEY, () => loadModels()), anthropicSlowMode }); } catch { - return Response.json(withSafeModelLoadFailure(EMPTY_MODELS)); + return Response.json({ ...withSafeModelLoadFailure(EMPTY_MODELS), anthropicSlowMode }); } } diff --git a/app/api/notifications/devices/route.ts b/app/api/notifications/devices/route.ts new file mode 100644 index 000000000..ff4410c1f --- /dev/null +++ b/app/api/notifications/devices/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { isValidNotificationId, parseNotificationPrefs } from "@/lib/notification-events"; +import { getNotificationDevice, getVapidPublicKey, parsePushSubscription, saveNotificationDevice } from "@/lib/notification-hub"; +import { isRecord } from "@/lib/type-guards"; + +export const dynamic = "force-dynamic"; + +/** GET /api/notifications/devices?deviceId= — VAPID public key and whether this device has a push subscription. */ +export async function GET(req: Request) { + const deviceId = new URL(req.url).searchParams.get("deviceId"); + const device = isValidNotificationId(deviceId) ? getNotificationDevice(deviceId) : undefined; + return NextResponse.json({ publicKey: getVapidPublicKey(), subscribed: Boolean(device?.subscription) }); +} + +/** PUT /api/notifications/devices { deviceId, prefs, subscription? } — an omitted subscription keeps the stored one. */ +export async function PUT(req: Request) { + const body: unknown = await req.json().catch(() => null); + if (!isRecord(body) || !isValidNotificationId(body.deviceId)) { + return NextResponse.json({ error: "deviceId is required", code: "invalid_device" }, { status: 400 }); + } + const subscription = body.subscription === undefined ? undefined : parsePushSubscription(body.subscription); + if (subscription === null) return NextResponse.json({ error: "Invalid push subscription", code: "invalid_subscription" }, { status: 400 }); + const device = saveNotificationDevice(body.deviceId, parseNotificationPrefs(body.prefs), subscription); + return NextResponse.json({ subscribed: Boolean(device.subscription) }); +} diff --git a/app/api/notifications/presence/route.ts b/app/api/notifications/presence/route.ts new file mode 100644 index 000000000..cc3175195 --- /dev/null +++ b/app/api/notifications/presence/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { isValidNotificationId } from "@/lib/notification-events"; +import { reportPresence } from "@/lib/notification-hub"; +import { isRecord } from "@/lib/type-guards"; + +export const dynamic = "force-dynamic"; + +/** POST /api/notifications/presence { clientId, deviceId, visible, sessionId, seq } — which session this tab shows. */ +export async function POST(req: Request) { + const body: unknown = await req.json().catch(() => null); + if ( + !isRecord(body) || !isValidNotificationId(body.clientId) || !isValidNotificationId(body.deviceId) + || typeof body.visible !== "boolean" || !Number.isSafeInteger(body.seq) + ) { + return NextResponse.json({ error: "Invalid presence report", code: "invalid_presence" }, { status: 400 }); + } + const sessionId = typeof body.sessionId === "string" && body.sessionId ? body.sessionId : null; + reportPresence({ clientId: body.clientId, deviceId: body.deviceId, visible: body.visible, sessionId, seq: Number(body.seq) }); + return new NextResponse(null, { status: 204 }); +} diff --git a/app/api/notifications/test/route.ts b/app/api/notifications/test/route.ts new file mode 100644 index 000000000..eafce070e --- /dev/null +++ b/app/api/notifications/test/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { isValidNotificationId } from "@/lib/notification-events"; +import { sendTestPush } from "@/lib/notification-hub"; +import { isRecord } from "@/lib/type-guards"; + +export const dynamic = "force-dynamic"; + +/** POST /api/notifications/test { deviceId } — one push to this device; 502 when the push service refuses it. */ +export async function POST(req: Request) { + const body: unknown = await req.json().catch(() => null); + if (!isRecord(body) || !isValidNotificationId(body.deviceId)) { + return NextResponse.json({ error: "deviceId is required", code: "invalid_device" }, { status: 400 }); + } + const result = await sendTestPush(body.deviceId); + return result.ok ? new NextResponse(null, { status: 204 }) : NextResponse.json({ error: result.error, code: "push_failed" }, { status: 502 }); +} diff --git a/app/api/provider-usage/route.ts b/app/api/provider-usage/route.ts index 31dfa9943..fc50b8ba1 100644 --- a/app/api/provider-usage/route.ts +++ b/app/api/provider-usage/route.ts @@ -27,7 +27,7 @@ export async function GET(request: Request) { } try { - return NextResponse.json(await getProviderUsage({ provider, modelId })); + return NextResponse.json(await getProviderUsage({ provider, modelId }, searchParams.get("refresh") === "true")); } catch { return NextResponse.json( { error: "Provider usage is currently unavailable", code: "provider_usage_unavailable" }, diff --git a/app/api/sessions/[id]/auto-name/route.ts b/app/api/sessions/[id]/auto-name/route.ts index 73ccb2a5f..e63bceffc 100644 --- a/app/api/sessions/[id]/auto-name/route.ts +++ b/app/api/sessions/[id]/auto-name/route.ts @@ -1,17 +1,20 @@ import { NextResponse } from "next/server"; import { scanSessionInfo, setSessionTitle } from "@/lib/omp/session-files"; import { deriveSessionTitleFromFirstMessage, sanitizeSessionTitle } from "@/lib/session-title"; -import { getRpcSession } from "@/lib/rpc-manager"; -import { invalidateSessionCaches } from "@/lib/session-reader"; +import { getRpcSession, resolveSpawnCwdResult, startRpcSession, WebRpcError } from "@/lib/rpc-manager"; +import { invalidateSessionCaches, readSessionHeader } from "@/lib/session-reader"; import { resolveSessionPathOr404 } from "@/lib/api-utils"; /** * POST /api/sessions/[id]/auto-name * - * omp auto-generates session titles itself (persisted in the title slot), so - * this endpoint no longer runs an LLM: it returns the live session's title - * when the session is running, else the persisted title, else a fallback - * derived from the first user message (persisted so the sidebar updates). + * Generates a title with omp's own title generator (the native `generate_title` + * command, else argument-less `/rename`) through the session's omp process, + * starting one when the session is not live. omp persists the title itself. + * When omp cannot generate one (older omp, a run in flight, spawn failure) the + * endpoint falls back to the existing title, else a title derived from the + * first user message, and saves that fallback (through the live process when + * there is one). `generated` tells the caller which path produced the title. */ export async function POST( _req: Request, @@ -20,25 +23,34 @@ export async function POST( const { id } = await params; try { - // Running session: ask the live omp process (its in-memory title is newer - // than the file's slot while a rewrite is pending). This runs before the - // path check because omp does not create the session file until the history - // holds an assistant message. - const rpc = getRpcSession(id); - const running = Boolean(rpc?.isAlive?.()); - if (running && typeof rpc?.send === "function") { + // This runs before the path check because omp does not create the session + // file until the history holds an assistant message. + let rpc = getRpcSession(id); + if (!rpc?.isAlive?.()) { + const resolved = await resolveSessionPathOr404(id); + if ("response" in resolved) return resolved.response; try { - const state = await rpc.send({ type: "get_state" }) as { sessionName?: string } | null; - const liveTitle = sanitizeSessionTitle(state?.sessionName); - if (liveTitle) { - // The live child owns the file; ask it for the path so the targeted - // invalidation does not flush every other session's parse caches. - const filePath = rpc?.sessionFile; - invalidateSessionCaches(filePath); - return NextResponse.json({ title: liveTitle, usage: null }); - } + const header = readSessionHeader(resolved.filePath); + const { cwd } = resolveSpawnCwdResult(header?.cwd); + rpc = (await startRpcSession(id, resolved.filePath, cwd, undefined, undefined, header?.cwd)).session; } catch { - // Fall through to the on-disk title. + rpc = undefined; // Fall through to the fallback title. + } + } + + if (rpc?.isAlive?.()) { + try { + const generated = sanitizeSessionTitle((await rpc.generateTitle()) ?? undefined); + if (generated) { + invalidateSessionCaches(rpc.sessionFile); + return NextResponse.json({ title: generated, generated: true, usage: null }); + } + } catch (error) { + // A busy/restarting session reports a typed error the UI can show; any + // other failure degrades to the fallback below. + if (error instanceof WebRpcError) { + return NextResponse.json({ error: error.message, code: error.code }, { status: 409 }); + } } } @@ -49,7 +61,7 @@ export async function POST( const info = scanSessionInfo(filePath, false); const storedTitle = sanitizeSessionTitle(info?.title); if (storedTitle) { - return NextResponse.json({ title: storedTitle, usage: null }); + return NextResponse.json({ title: storedTitle, generated: false, usage: null }); } const derived = deriveSessionTitleFromFirstMessage(info?.firstMessage); @@ -60,13 +72,20 @@ export async function POST( ); } - // Persist only when no live process owns the file; a running session will - // title itself and would clobber our write on its next flush anyway. - if (!running) { - setSessionTitle(filePath, derived, "auto"); + // A live process owns the file; save through it so its next flush keeps the + // title instead of clobbering ours. + let saved = false; + if (rpc?.isAlive?.()) { + try { + await rpc.send({ type: "set_session_name", name: derived }); + saved = true; + } catch { + // Fall back to the on-disk title slot. + } } + if (!saved) setSessionTitle(filePath, derived, "auto"); invalidateSessionCaches(filePath); - return NextResponse.json({ title: derived, usage: null }); + return NextResponse.json({ title: derived, generated: false, usage: null }); } catch (error) { return NextResponse.json( { error: error instanceof Error ? error.message : String(error) }, diff --git a/app/api/stt/[jobId]/audio/route.ts b/app/api/stt/[jobId]/audio/route.ts new file mode 100644 index 000000000..60ee5f35a --- /dev/null +++ b/app/api/stt/[jobId]/audio/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { getSttJobAudio } from "@/lib/stt-jobs"; + +export const dynamic = "force-dynamic"; + +/** GET /api/stt/[jobId]/audio — the recording kept with a live job, for playback in any browser. */ +export async function GET(_req: Request, { params }: { params: Promise<{ jobId: string }> }) { + const audio = getSttJobAudio((await params).jobId); + if (!audio) { + return NextResponse.json({ error: "Transcription job not found", code: "stt_job_not_found" }, { status: 404 }); + } + // The type came from the uploading browser: serve only a bare audio/* type + // so a crafted upload cannot render as HTML on this origin. + const type = /^audio\/[a-z0-9.+-]+/i.exec(audio.type)?.[0] ?? "application/octet-stream"; + return new Response(audio, { + headers: { + "Content-Type": type, + "Content-Length": String(audio.size), + "Cache-Control": "private, no-store", + "X-Content-Type-Options": "nosniff", + }, + }); +} diff --git a/app/api/stt/[jobId]/route.ts b/app/api/stt/[jobId]/route.ts new file mode 100644 index 000000000..3498d7669 --- /dev/null +++ b/app/api/stt/[jobId]/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { readSttConfig } from "@/lib/stt"; +import { closeSttJob, getSttJob, retrySttJob } from "@/lib/stt-jobs"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ jobId: string }> }; + +const notFound = () => + NextResponse.json({ error: "Transcription job not found", code: "stt_job_not_found" }, { status: 404 }); + +/** + * GET /api/stt/[jobId] — { id, status: "pending" | "done" | "error" | "gone", error? }, + * or 404 for an unknown/expired job. The transcript is never here: claim it + * with DELETE ?claim=. "gone" means another browser claimed or + * discarded it. Job failures are 200 payloads, not 5xx, so an intermediate + * proxy cannot swap them for its own error page. + */ +export async function GET(_req: Request, { params }: Params) { + const job = getSttJob((await params).jobId); + return job ? NextResponse.json(job) : notFound(); +} + +/** POST /api/stt/[jobId]?owner= — retry a failed job with the audio the server kept. */ +export async function POST(req: Request, { params }: Params) { + const config = readSttConfig(); + if (!config) { + return NextResponse.json({ error: "STT not configured. Set OMP_WEB_STT_ENDPOINT." }, { status: 501 }); + } + const owner = new URL(req.url).searchParams.get("owner")?.slice(0, 128) || undefined; + const job = retrySttJob(config, (await params).jobId, owner); + if (job === "busy") { + return NextResponse.json({ error: "Too many transcriptions in progress", code: "stt_busy" }, { status: 429 }); + } + return job ? NextResponse.json(job) : notFound(); +} + +/** + * DELETE /api/stt/[jobId]?claim=&owner= — claim a + * finished transcript, or (without `claim`) discard the job. Returns the job + * as it was before closing: only the first claimer of a "done" job sees + * status "done" with its text (repeatable with the same claim token), and + * only the job's owner also gets its send/queue `after`; everyone else sees + * "gone". + */ +export async function DELETE(req: Request, { params }: Params) { + const query = new URL(req.url).searchParams; + const claim = query.get("claim")?.slice(0, 128) || undefined; + const owner = query.get("owner")?.slice(0, 128) || undefined; + const job = closeSttJob((await params).jobId, claim, owner); + return job ? NextResponse.json(job) : notFound(); +} diff --git a/app/api/stt/route.ts b/app/api/stt/route.ts index 0afc92818..2c31506a3 100644 --- a/app/api/stt/route.ts +++ b/app/api/stt/route.ts @@ -1,40 +1,36 @@ import { NextResponse } from "next/server"; import { parseFormDataWithinLimit, RequestBodyTooLargeError } from "@/lib/bounded-form-data"; -import { MAX_STT_AUDIO_BYTES, MAX_STT_REQUEST_BYTES } from "@/lib/stt"; +import { isSttAfter, MAX_STT_AUDIO_BYTES, MAX_STT_REQUEST_BYTES, readSttConfig } from "@/lib/stt"; +import { listSttJobs, startSttJob } from "@/lib/stt-jobs"; export const dynamic = "force-dynamic"; -function extractUpstreamErrorMessage(data: unknown, rawText: string, status: number): string { - if (data && typeof data === "object" && "error" in data) { - const error: unknown = data.error; - if (typeof error === "string" && error.trim()) return error; - if (error && typeof error === "object" && "message" in error) { - const message: unknown = error.message; - if (typeof message === "string" && message.trim()) return message; - } - } - if (rawText.trim()) return rawText.trim().slice(0, 500); - return `Transcription failed (upstream ${status})`; -} +const MAX_SCOPE_LENGTH = 1024; -function cleanEnvVar(val?: string): string | undefined { - const cleaned = val?.replace(/\\n|[\r\n]/g, "").trim(); - return cleaned || undefined; +/** GET /api/stt?scope= — live jobs for that composer, newest first. */ +export async function GET(request: Request) { + const scope = new URL(request.url).searchParams.get("scope"); + if (!scope || scope.length > MAX_SCOPE_LENGTH) { + return NextResponse.json({ error: "scope is required", code: "missing_scope" }, { status: 400 }); + } + return NextResponse.json({ jobs: listSttJobs(scope) }); } +/** + * POST /api/stt (multipart: file, optional scope) — keeps the audio and starts + * a server-side transcription job. Returns 202 { jobId }; poll + * GET /api/stt/[jobId] for the result. + */ export async function POST(request: Request) { - let apiKey: string | undefined; try { - const endpoint = cleanEnvVar(process.env.OMP_WEB_STT_ENDPOINT); - if (!endpoint) { + const config = readSttConfig(); + if (!config) { return NextResponse.json( { error: "STT not configured. Set OMP_WEB_STT_ENDPOINT." }, { status: 501 } ); } - apiKey = cleanEnvVar(process.env.OMP_WEB_STT_KEY); - const model = cleanEnvVar(process.env.OMP_WEB_STT_MODEL); const formData = await parseFormDataWithinLimit(request, MAX_STT_REQUEST_BYTES); const file = formData.get("file"); if (!file || typeof file === "string" || file.size === 0) { @@ -49,37 +45,31 @@ export async function POST(request: Request) { { status: 413 } ); } - - if (model && !formData.has("model")) { - formData.append("model", model); + const scope = formData.get("scope"); + if (scope !== null && (typeof scope !== "string" || scope.length > MAX_SCOPE_LENGTH)) { + return NextResponse.json({ error: "Invalid scope", code: "invalid_scope" }, { status: 400 }); } - - const res = await fetch(endpoint, { - method: "POST", - headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, - body: formData, - signal: AbortSignal.timeout(60000), - }); - - const rawText = await res.text(); - let data: unknown = null; - try { - data = rawText ? JSON.parse(rawText) : {}; - } catch { - data = null; + // The uploader's claim token: it gets first claim on the transcript. + const owner = formData.get("owner"); + // What to do with the transcript (send/steer/followup); absent = insert. + const after = formData.get("after"); + if (after !== null && !isSttAfter(after)) { + return NextResponse.json({ error: "Invalid after", code: "invalid_after" }, { status: 400 }); } - if (!res.ok) { + const jobId = startSttJob(config, { + audio: file, + scope: scope || null, + owner: typeof owner === "string" && owner ? owner.slice(0, 128) : undefined, + after: after ?? undefined, + }); + if (!jobId) { return NextResponse.json( - { error: extractUpstreamErrorMessage(data, rawText, res.status) }, - { status: res.status } + { error: "Too many transcriptions in progress", code: "stt_busy" }, + { status: 429 } ); } - - if (data !== null) { - return NextResponse.json(data, { status: res.status }); - } - return NextResponse.json({ text: rawText }, { status: res.status }); + return NextResponse.json({ jobId }, { status: 202 }); } catch (error) { if (error instanceof RequestBodyTooLargeError) { return NextResponse.json( @@ -87,8 +77,6 @@ export async function POST(request: Request) { { status: 413 } ); } - const rawMsg = error instanceof Error ? error.message : String(error); - const safeMsg = apiKey ? rawMsg.replaceAll(apiKey, "[REDACTED]") : rawMsg; - return NextResponse.json({ error: safeMsg }, { status: 500 }); + return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); } } diff --git a/app/globals.css b/app/globals.css index 643a16a8e..403544de2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1599,7 +1599,7 @@ pre, code { height: 44px !important; } .sidebar-project-header { - height: 44px !important; + min-height: 44px; } .sidebar-project-worktree button { min-height: 40px; @@ -1633,6 +1633,26 @@ pre, code { outline-offset: -2px; } +/* The sidebar is its own inline-size container so the header can adapt to the + user-adjustable width. Below ~240px the title plus five header icons no + longer fit, so the navigate back/forward buttons drop out first (they stay + reachable via ⌘[/⌘] / Alt+←/→); Archive/Import/Refresh are kept — with the + nav pair hidden, the header fits down to the 200px minimum. The wrapper's + display/gap live here, NOT as an inline style — an inline display:flex + would out-rank the container query's display:none and break the hiding. */ +.sidebar-shell { + container-type: inline-size; +} +.sidebar-nav-buttons { + display: flex; + gap: 2px; +} +@container (max-width: 240px) { + .sidebar-nav-buttons { + display: none; + } +} + /* Sidebar overflow menus: compact items with a subtle warm hover highlight. */ .sidebar-menu-item { display: block; @@ -1815,8 +1835,7 @@ pre, code { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } } -/* The activity dot is part of the sidebar's shared right-edge status column - (see SessionSidebar-rows.tsx) — it must not move on hover. */ +/* Workspace activity stays beside the project name, including on hover. */ .sidebar-project-activity[data-running="true"] .sidebar-project-activity-dot { position: relative; isolation: isolate; @@ -1940,21 +1959,90 @@ pre, code { } .composer-toolbar { container-type: inline-size; + justify-content: flex-end; } .composer-toolbar > button { flex-shrink: 0; } .composer-model-control { + /* Shrink before wrapping; cap intrinsic width to keep adjacent controls close. */ flex: 1 1 0; - max-width: 140px; + max-width: fit-content; min-width: 24px !important; } +.composer-model-control > button > span { + max-width: 96px; +} .composer-thinking-control { flex: 0 1 auto; min-width: 24px !important; max-width: 100px; } -.composer-slow-mode-badge { +.composer-thinking-glyph { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + position: relative; + font-size: 14px; + line-height: 1; +} +/* Draw the TUI shapes instead of relying on unequal font fallback metrics. + Match Auto's visible diameter, not the surrounding font's em square. */ +.composer-thinking-glyph[data-level] { + width: 1em; + height: 1em; +} +.composer-thinking-glyph > svg { + width: .8em; + height: .8em; +} +.composer-thinking-glyph[data-level]:not([data-level="auto"])::before { + content: ""; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: .65em; + height: .65em; + border: .075em solid currentColor; + border-radius: 50%; + box-sizing: border-box; +} +.composer-thinking-glyph[data-level="low"]::before { + background: conic-gradient(currentColor 90deg, transparent 90deg); +} +.composer-thinking-glyph[data-level="medium"]::before { + background: conic-gradient(currentColor 180deg, transparent 180deg); +} +.composer-thinking-glyph[data-level="high"]::before { + background: conic-gradient(from 90deg, currentColor 180deg, transparent 180deg); +} +.composer-thinking-glyph[data-level="xhigh"]::before { + background: conic-gradient(currentColor 270deg, transparent 270deg); +} +.composer-thinking-glyph:is([data-level="off"], [data-level="max"])::after { + content: ""; + position: absolute; + background: currentColor; +} +.composer-thinking-glyph[data-level="off"]::after { + width: .65em; + height: .075em; + transform: rotate(-45deg); +} +.composer-thinking-glyph[data-level="max"]::after { + width: .35em; + height: .35em; + border-radius: 50%; +} +@media (forced-colors: active) { + .composer-thinking-glyph { + forced-color-adjust: none; + color: ButtonText; + } +} +.composer-usage-limit-badge { flex: 0 1 auto; min-width: 0; max-width: 260px; @@ -1980,12 +2068,25 @@ pre, code { line-height: 20px; } @container (max-width: 400px) { - .composer-model-control > button > svg:first-child, - .composer-thinking-control > button > svg:first-child, - .composer-fast-control > span { + .composer-thinking-control > button > svg, + .composer-thinking-glyph[data-level] + .composer-thinking-label, + .composer-fast-control > span, + .composer-slow-control > span { display: none; } - .composer-fast-control { + /* Glyph-only trigger: size up to read level with the 14px lucide icons. */ + .composer-thinking-control > button > .composer-thinking-glyph { + font-size: 18px; + } + .picker-thinking-card > .composer-thinking-glyph { + font-size: 16px; + } + .composer-slow-control > svg { + width: 14px; + height: 14px; + } + .composer-fast-control, + .composer-slow-control { justify-content: center; width: 28px; padding-inline: 0 !important; @@ -2001,14 +2102,8 @@ pre, code { justify-content: center; gap: 0 !important; } - .composer-model-control > button > svg:first-child, - .composer-thinking-control > button > svg:first-child { - display: block; - } .composer-model-control > button > span, - .composer-model-control > button > svg:last-child, - .composer-thinking-control > button > span, - .composer-thinking-control > button > svg:last-child { + .composer-model-control > button > svg:last-child { display: none; } .composer-toolbar > button:not(.composer-primary-action), @@ -2020,6 +2115,25 @@ pre, code { padding-inline: 6px; } } +@media (max-width: 640px) { + .composer-thinking-control { + max-width: 28px; + } + .composer-thinking-control > button > svg, + .composer-thinking-glyph[data-level] + .composer-thinking-label { + display: none; + } + .composer-thinking-control > button > .composer-thinking-glyph { + font-size: 18px; + } + .picker-thinking-card > .composer-thinking-glyph { + font-size: 16px; + } + .composer-slow-control > svg { + width: 14px; + height: 14px; + } +} .composer-control-shelf { animation: ui-slide-up var(--dur-fast) var(--ease-out-warm) both; } @@ -2058,10 +2172,8 @@ pre, code { display: none; } .composer-toolbar { flex-wrap: wrap !important; row-gap: 6px; } - .composer-toolbar-spacer { display: none; } - .composer-primary-action { margin-left: auto; } /* Own row above the controls, so it never squeezes the model picker. */ - .composer-slow-mode-badge { order: -1; flex-basis: 100%; max-width: none; } + .composer-usage-limit-badge { order: -1; flex-basis: 100%; max-width: none; } .sidebar-icon-button, .sidebar-project-action, @@ -2387,39 +2499,9 @@ span.token.table { .picker-thinking-cards { display: flex; flex-direction: column; - gap: 3px; - max-height: 280px; - padding: 4px; + padding: 2px 4px 5px; overflow-y: auto; } -.picker-thinking-card { - display: flex; - align-items: center; - width: 100%; - min-height: 27px; - gap: 6px; - padding: 4px 7px; - color: var(--text-muted); - text-align: left; - cursor: pointer; - background: var(--bg); - border: 1px solid var(--border); - border-radius: 7px; - font-size: 12px; - transition: background-color var(--dur-fast) var(--ease-out-warm), border-color var(--dur-fast) var(--ease-out-warm), color var(--dur-fast) var(--ease-out-warm); -} -.picker-thinking-card:hover, -.picker-thinking-card:focus-visible { - color: var(--text); - background: var(--bg-hover); - outline: none; -} -.picker-thinking-card[data-active="true"] { - color: var(--text); - background: var(--bg-selected); - border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); - font-weight: 600; -} .picker-panel-footer { display: flex; align-items: center; @@ -2488,14 +2570,11 @@ span.token.table { outgrow it, even with more levels than today's seven. */ @media (max-width: 640px) { .picker-thinking-cards { - flex-direction: row; - flex-wrap: wrap; gap: 5px; max-height: min(340px, 40vh); padding: 5px; } .picker-thinking-card { - width: auto; min-height: 28px; gap: 5px; padding: 4px 9px; @@ -3324,15 +3403,24 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { .shell-toolbar-btn:active:not(:disabled) { transform: scale(0.96); } -.shell-panel-toggle { - border-radius: 0 !important; - background: var(--bg-panel); - color: var(--text-muted); +.right-panel-toolbar { + --right-panel-close-width: 36px; + position: relative; + padding-right: var(--right-panel-close-width); } -.shell-panel-toggle:hover:not(:disabled), -.shell-panel-toggle[aria-pressed="true"] { - background: var(--bg-hover); - color: var(--text); +html:root .shell-toolbar-btn.right-panel-close-button { + position: absolute; + top: 0; + right: 0; + width: var(--right-panel-close-width) !important; + height: var(--shell-topbar-height) !important; + min-width: 0 !important; + min-height: 0 !important; + padding: 0 !important; + border-radius: 0; +} +html[data-touch-targets="accessible"] .right-panel-toolbar { + --right-panel-close-width: 44px; } /* Subtle group separator between toolbar control clusters */ .shell-toolbar-divider { @@ -3351,6 +3439,13 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { width: 40px; height: 40px; } + .right-panel-toolbar { + --right-panel-close-width: 44px; + } + html:root .shell-toolbar-btn.right-panel-close-button { + padding-top: env(safe-area-inset-top, 0px) !important; + border-bottom: 1px solid var(--border); + } .shell-toolbar-divider { margin: 0 4px; } @@ -3372,7 +3467,17 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { } } +:root { + --shell-topbar-height: 36px; +} +@media (min-width: 641px) { + html[data-touch-targets="accessible"] { + --shell-topbar-height: 44px; + } +} .shell-topbar { + /* Fallback until AppShell measures the real clusters. */ + --topbar-side: 260px; display: flex; flex-wrap: nowrap; justify-content: space-between; @@ -3391,10 +3496,11 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { } .shell-topbar-center { position: absolute; - top: 4.5px; - left: 50%; - transform: translateX(-50%); - width: clamp(0px, calc(100% - 520px), 400px); + top: 0; + bottom: 0; + /* Equal insets center the content-sized pill between the control clusters. */ + left: var(--topbar-side); + right: var(--topbar-side); pointer-events: none; } .shell-topbar-center > .shell-topbar-breadcrumb { @@ -3408,42 +3514,23 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { } } -@media (min-width: 641px) and (max-width: 960px) { - .shell-topbar-center { - display: flex !important; - width: min(260px, 28vw); +@media (max-width: 640px) { + :root { + --shell-topbar-height: calc(44px + env(safe-area-inset-top, 0px)); } - .shell-topbar-center > .shell-topbar-breadcrumb { - max-width: 100%; + .shell-topbar { + --topbar-side: max(96px, calc(92px + env(safe-area-inset-right, 0px))); } -} - -@media (max-width: 640px) { .shell-topbar-center { - display: flex !important; - position: static; - inset: auto; - width: auto; - max-width: none; - min-width: 0; - flex: 1 1 auto; - justify-content: flex-start; - overflow: hidden; - transform: none; - } - .shell-topbar-center .shell-topbar-breadcrumb { - width: auto; - max-width: min(320px, 100%); + top: env(safe-area-inset-top, 0px); } .shell-topbar-center .shell-topbar-breadcrumb { visibility: visible; } - } @media (max-width: 640px) { .shell-topbar { gap: 0 4px !important; - padding-right: 48px !important; } .shell-topbar-overflow-content { overflow-x: auto; @@ -3462,20 +3549,13 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { .shell-topbar .shell-toolbar-divider { margin: 0; } - .shell-topbar [data-topbar-right-group] { - width: 0 !important; - min-width: 0 !important; - padding: 0 !important; - } - .shell-topbar-center { - margin: 0 4px; - justify-content: center; + .shell-topbar-metrics { + display: none; } .shell-topbar-project-context { display: none !important; } .shell-topbar-center .shell-topbar-breadcrumb { - max-width: 100%; height: 32px; padding-inline: 4px; border-color: transparent; @@ -3536,7 +3616,7 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { display: inline-flex; align-items: center; gap: 6px; - max-width: min(440px, 32vw); + max-width: 100%; height: 26px; padding: 0 8px; border-radius: var(--radius-control); @@ -4056,9 +4136,6 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { /* Desktop sidebar: animate width */ @media (min-width: 641px) { - .sidebar-overlay-backdrop { - display: none !important; - } .sidebar-container { position: relative; overflow: hidden; @@ -4118,7 +4195,7 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { } .right-panel-container.right-panel-open { position: fixed; - top: 36px; + top: var(--shell-topbar-height); right: 0; bottom: 0; width: min(360px, 55vw); @@ -4149,20 +4226,16 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { } .sidebar-container { position: fixed !important; - top: env(safe-area-inset-top, 0px); - left: 0; - bottom: 0; - width: 280px; - max-width: 85vw; - transition: transform var(--dur-slow) var(--ease-out-warm), box-shadow var(--dur-slow) var(--ease-out-warm); + inset: 0; + /* Full-screen drawer: safe-area padding (the list adds its own bottom). */ + padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) 0 env(safe-area-inset-left, 0px); + transition: transform var(--dur-slow) var(--ease-out-warm); } .sidebar-container.sidebar-open { transform: translateX(0); - box-shadow: var(--shadow-modal); } .sidebar-container.sidebar-closed { transform: translateX(-100%); - box-shadow: none; } /* Full-screen overlay that slides in from the right edge. Closed panels are @@ -4178,7 +4251,7 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { } .right-panel-toolbar { padding-top: env(safe-area-inset-top, 0px); - min-height: calc(44px + env(safe-area-inset-top, 0px)) !important; + min-height: var(--shell-topbar-height) !important; } .right-panel-container.right-panel-open { transform: none; @@ -4317,7 +4390,8 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { html[data-touch-targets="accessible"] :is( .shell-toolbar-btn, .right-panel-toolbar [role="toolbar"] > button, - .composer-primary-action + .composer-primary-action, + .composer-context-control ) { min-width: 44px; } @@ -4331,6 +4405,7 @@ html[data-touch-targets="accessible"] :is( .composer-toolbar > button, .composer-toolbar > div > button, .composer-primary-action, + .composer-context-control, .shell-toolbar-btn, .sidebar-icon-button, .sidebar-project-action, diff --git a/app/layout.tsx b/app/layout.tsx index 117fe39ea..d130e6fa4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,9 +1,11 @@ import type { Metadata, Viewport } from "next"; +import { headers } from "next/headers"; import Script from "next/script"; import { Geist, JetBrains_Mono, Noto_Sans_Mono, Noto_Serif_SC, Source_Serif_4 } from "next/font/google"; import { ThemeColor } from "@/hooks/useTheme"; import { IosFocusZoomGuard } from "@/components/IosFocusZoomGuard"; import { SIDEBAR_HISTORY_BRIDGE_SCRIPT } from "@/lib/sidebar-history-bridge"; +import { getInstallName } from "@/lib/install-name"; import "./globals.css"; const geist = Geist({ @@ -43,19 +45,22 @@ const notoSerifSC = Noto_Serif_SC({ display: "swap", }); -export const metadata: Metadata = { - title: "omp web", - description: "Web UI for the oh-my-pi (omp) coding agent", - // PWA-like behavior on iOS: standalone chrome, no telephone autodetect. - appleWebApp: { - capable: true, - title: "omp web", - statusBarStyle: "default", - }, - formatDetection: { - telephone: false, - }, -}; +export async function generateMetadata(): Promise { + const title = getInstallName(await headers()); + return { + title, + description: "Web UI for the oh-my-pi (omp) coding agent", + // PWA-like behavior on iOS: standalone chrome, no telephone autodetect. + appleWebApp: { + capable: true, + title, + statusBarStyle: "default", + }, + formatDetection: { + telephone: false, + }, + }; +} // `viewportFit: cover` honors safe-area-inset on notched devices. // `interactiveWidget: resizes-content` makes the soft keyboard shrink the @@ -75,6 +80,7 @@ export default function RootLayout({ return ( + {/* Register before Next's router. Owned sidebar traversals must be handled diff --git a/app/manifest.ts b/app/manifest.ts deleted file mode 100644 index fc063fb11..000000000 --- a/app/manifest.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { MetadataRoute } from "next"; - -export default function manifest(): MetadataRoute.Manifest { - return { - name: "omp web", - short_name: "omp web", - description: "Web UI for the oh-my-pi (omp) coding agent", - start_url: "/", - display: "standalone", - background_color: "#000000", - theme_color: "#000000", - // Chrome's install prompt requires both a 192px and a 512px PNG. - icons: [ - { src: "/icon.svg", type: "image/svg+xml", sizes: "any" }, - { src: "/icon-192.png", sizes: "192x192", type: "image/png" }, - { src: "/icon.png", sizes: "512x512", type: "image/png" }, - ], - }; -} diff --git a/app/page.tsx b/app/page.tsx index 921dfd65e..3664d090e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,10 +1,13 @@ import { Suspense } from "react"; +import { headers } from "next/headers"; import { AppShell } from "@/components/AppShell"; +import { getInstallName } from "@/lib/install-name"; -export default function Home() { +export default async function Home() { + const appName = getInstallName(await headers()); return ( }> - + ); } diff --git a/components/AppShell-provider-usage.test.mjs b/components/AppShell-provider-usage.test.mjs new file mode 100644 index 000000000..7214a93a7 --- /dev/null +++ b/components/AppShell-provider-usage.test.mjs @@ -0,0 +1,69 @@ +import "../tests/setup-dom.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJiti } from "jiti"; +import { act, cleanup, renderHook } from "@testing-library/react/pure.js"; + +const jiti = createJiti(import.meta.url, { tryNative: false, tsconfigPaths: true }); +const { useProviderUsage } = await jiti.import("./AppShell-provider-usage.ts"); +const snapshot = (percent) => ({ reports: [{ provider: "provider", fiveHour: { percent } }] }); + +test("manual refresh gets fresh limits on older browsers while automatic polling stays silent", async () => { + const originalFetch = globalThis.fetch; + const originalInterval = window.setInterval; + const originalClear = window.clearInterval; + const sizeDescriptor = Object.getOwnPropertyDescriptor(URLSearchParams.prototype, "size"); + let poll; + let finishPoll; + let freshPercent = 10; + let cachedPercent = 10; + try { + delete URLSearchParams.prototype.size; + window.setInterval = (callback) => { poll = callback; return 1; }; + window.clearInterval = () => {}; + globalThis.fetch = async (url) => { + if (new URL(url, "http://localhost").searchParams.get("refresh") === "true") cachedPercent = freshPercent; + if (finishPoll) await new Promise((resolve) => { finishPoll.resolve = resolve; }); + return { ok: true, json: async () => snapshot(cachedPercent) }; + }; + const { result, unmount } = renderHook(() => useProviderUsage("", 300_000)); + await act(async () => {}); + assert.equal(result.current.snapshot.reports[0].fiveHour.percent, 10); + freshPercent = 80; + finishPoll = {}; + act(() => poll()); + assert.equal(result.current.loading, false, "background polling must not change the loading state"); + assert.equal(result.current.snapshot.reports[0].fiveHour.percent, 10); + await act(async () => { finishPoll.resolve(); }); + finishPoll = undefined; + assert.equal(result.current.snapshot.reports[0].fiveHour.percent, 10, "automatic polling must not force cache invalidation"); + let refreshed; + await act(async () => { refreshed = await result.current.refresh(); }); + assert.equal(refreshed, true); + assert.equal(result.current.snapshot.reports[0].fiveHour.percent, 80, "manual refresh must request fresh limits even without URLSearchParams.size"); + freshPercent = 95; + finishPoll = {}; + act(() => poll()); + let queuedRefresh; + act(() => { queuedRefresh = result.current.refresh(); }); + await act(async () => { + const resolve = finishPoll.resolve; + finishPoll = undefined; + resolve(); + assert.equal(await queuedRefresh, true); + }); + assert.equal(result.current.snapshot.reports[0].fiveHour.percent, 95, "manual refresh during a background poll must still fetch fresh limits"); + finishPoll = {}; + let cancelled; + act(() => { cancelled = result.current.refresh(); }); + unmount(); + await act(async () => { finishPoll.resolve(); }); + assert.equal(await cancelled, false, "an unmounted refresh cannot report success"); + } finally { + cleanup(); + globalThis.fetch = originalFetch; + window.setInterval = originalInterval; + window.clearInterval = originalClear; + if (sizeDescriptor) Object.defineProperty(URLSearchParams.prototype, "size", sizeDescriptor); + } +}); diff --git a/components/AppShell-provider-usage.ts b/components/AppShell-provider-usage.ts index 2ffbddab7..df83eb2c8 100644 --- a/components/AppShell-provider-usage.ts +++ b/components/AppShell-provider-usage.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import type { ProviderUsageReport, ProviderUsageSnapshot } from "@/lib/provider-usage-types"; export function formatUsageReset(value: number, unit: "minutes" | "hours"): string { @@ -51,8 +51,10 @@ export type ProviderUsageState = { error: boolean; }; -export function useProviderUsage(query: string | null, refreshMs?: number): ProviderUsageState { +export function useProviderUsage(query: string | null, refreshMs?: number): ProviderUsageState & { refresh: () => Promise } { const [state, setState] = useState({ snapshot: null, loading: false, error: false }); + const refreshRef = useRef<() => Promise>(async () => false); + const refresh = useCallback(() => refreshRef.current(), []); useEffect(() => { if (query === null) { setState({ snapshot: null, loading: false, error: false }); @@ -60,22 +62,43 @@ export function useProviderUsage(query: string | null, refreshMs?: number): Prov } const controller = new AbortController(); setState({ snapshot: null, loading: true, error: false }); - const load = async () => { - try { - const response = await fetch(`/api/provider-usage${query ? `?${query}` : ""}`, { signal: controller.signal }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const snapshot = await response.json() as ProviderUsageSnapshot; - if (!controller.signal.aborted) setState({ snapshot, loading: false, error: false }); - } catch { - if (!controller.signal.aborted) setState({ snapshot: null, loading: false, error: true }); + let inFlight: Promise | undefined; + let inFlightForced = false; + const load = (force = false): Promise => { + if (controller.signal.aborted) return Promise.resolve(false); + if (inFlight) { + return force && !inFlightForced ? inFlight.then(() => load(true)) : inFlight; } + if (force) setState((previous) => ({ ...previous, loading: true, error: false })); + const params = new URLSearchParams(query); + if (force) params.set("refresh", "true"); + const queryString = params.toString(); + inFlightForced = force; + inFlight = (async () => { + try { + const response = await fetch(`/api/provider-usage${queryString ? `?${queryString}` : ""}`, { signal: controller.signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const snapshot = await response.json() as ProviderUsageSnapshot; + if (controller.signal.aborted) return false; + setState({ snapshot, loading: false, error: false }); + return true; + } catch { + if (!controller.signal.aborted) setState({ snapshot: null, loading: false, error: true }); + return false; + } finally { + inFlight = undefined; + } + })(); + return inFlight; }; + refreshRef.current = () => load(true); void load(); const interval = refreshMs ? window.setInterval(() => void load(), refreshMs) : undefined; return () => { + refreshRef.current = async () => false; controller.abort(); if (interval !== undefined) window.clearInterval(interval); }; }, [query, refreshMs]); - return state; + return { ...state, refresh }; } diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 0d8559893..e4051792b 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -1,11 +1,14 @@ "use client"; -import { useState, useCallback, useRef, useEffect, useLayoutEffect } from "react"; +import { useState, useCallback, useRef, useEffect, useLayoutEffect, useMemo } from "react"; import dynamic from "next/dynamic"; import { useRouter, useSearchParams } from "next/navigation"; import { useGlobalKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; import { useSidebarHistory } from "@/hooks/useSidebarHistory"; import { useModalDialog } from "@/hooks/useModalDialog"; +import { useNavigationHistory } from "@/hooks/useNavigationHistory"; +import { isMacPlatform, navigateShortcutHint, NAVIGATION_HISTORY_MAX_ENTRIES } from "@/lib/navigation-history"; +import { useMobileSidebarGestures } from "@/hooks/useMobileSidebarGestures"; import { SessionSidebar } from "./SessionSidebar"; import { ToastProvider } from "./ui/toast"; import { toast } from "./ui/toast"; @@ -17,7 +20,7 @@ import type { RightPanelView } from "./RightPanel"; import { BranchNavigator } from "./BranchNavigator"; import { LanguageSwitcher } from "./LanguageSwitcher"; import { CommandPaletteMount } from "./CommandPaletteMount"; -import { Check, ChevronDown, Command, Ellipsis, Folder, History, Menu, PanelLeft, PanelRight, Terminal, Wand2, X, Zap } from "lucide-react"; +import { Check, ChevronDown, Command, Ellipsis, Folder, History, Menu, PanelLeft, PanelRight, Terminal, Wand2, Zap } from "lucide-react"; import { ThemeSwitcher } from "./ThemeSwitcher"; import { translate, useI18n } from "@/lib/i18n"; import { formatApiError } from "@/lib/i18n/api-error"; @@ -30,7 +33,7 @@ import { buildAtMentionText, buildFileAtMentionsText, buildFileLineMentionText } import { getInitialNavigation } from "@/lib/initial-navigation"; import { comparableProjectPath } from "@/lib/comparable-path"; import { clearDraft } from "@/lib/draft-store"; -import { showCompletionNotification } from "@/lib/browser-notifications"; +import { useNotifications } from "@/hooks/useNotifications"; import { APP_UPDATE_COMPLETED_RELOAD_MS, APP_UPDATE_POLL_MS, @@ -101,7 +104,7 @@ type AutoNameStatus = | { kind: "error"; message: string }; type TimerHandle = NodeJS.Timeout; -export function AppShell() { +export function AppShell({ appName }: { appName: string }) { const router = useRouter(); const searchParams = useSearchParams(); const [initialNavigation] = useState(() => getInitialNavigation(searchParams)); @@ -648,6 +651,7 @@ export function AppShell() { }, [resetAppUpdateVisibleStage]); const chatInputRef = useRef(null); const topBarRef = useRef(null); + const [sessionInfoContainer, setSessionInfoContainer] = useState(null); // Branch navigator state — populated by ChatWindow via onBranchDataChange const [branchTree, setBranchTree] = useState([]); @@ -762,8 +766,7 @@ export function AppShell() { }); }, [activeTopPanel, systemPrompt, systemPromptLoading, toggleTopPanel]); - // The topbar session panel is gone (its content lives in the composer ring - // popover), so /session opens that instead. + // /session opens the same information panel as the top-bar control. const openSessionStatsPanel = useCallback(() => { chatInputRef.current?.openContextPanel(); }, []); @@ -970,6 +973,12 @@ export function AppShell() { const [fileTabs, setFileTabs] = useState([]); const [activeFileTabId, setActiveFileTabId] = useState(null); const [rightPanelOpen, setRightPanelOpen] = useState(false); + const closeRightPanel = useCallback(() => { + setRightPanelOpen(false); + if (!isCompactOverlay) { + topBarRef.current?.querySelector(".shell-panel-opener")?.focus(); + } + }, [isCompactOverlay]); const [rightPanelHasOpened, setRightPanelHasOpened] = useState(false); const [rightView, setRightView] = useState<"explorer" | "git" | "file">("explorer"); // User-chosen pixel width (null = fluid 42% default), persisted. @@ -983,13 +992,20 @@ export function AppShell() { const [rightPanelMounted, setRightPanelMounted] = useState(false); const rightPanelIsModal = isCompactOverlay && rightPanelMounted && rightPanelOpen && !settingsTab; const rightPanelRef = useModalDialog({ - onClose: () => setRightPanelOpen(false), + onClose: closeRightPanel, active: rightPanelIsModal, }); const attachRightPanel = useCallback((element: HTMLDivElement | null) => { rightPanelRef.current = element; setRightPanelMounted(element !== null); }, [rightPanelRef]); + useMobileSidebarGestures({ + enabled: isMobile && mobileSidebarReady && !settingsTab, + leftOpen: sidebarOpen, + rightOpen: rightPanelOpen, + onLeftOpenChange: setSidebarOpen, + onRightOpenChange: setRightPanelOpen, + }); const pendingRightPanelWidthRef = useRef(null); const rightResizeHandlersRef = useRef<{ onMove: (ev: MouseEvent) => void; onUp: () => void } | null>(null); useEffect(() => { @@ -1170,7 +1186,7 @@ export function AppShell() { } }, [router, isMobile, selectedSession?.id]); - const handleNewSession = useCallback((_sessionId: string, cwd: string) => { + const handleNewSession = useCallback((_sessionId: string, cwd: string | null) => { setSettingsTab(null); setSelectedSession(null); setNewSessionCwd(cwd); @@ -1187,10 +1203,102 @@ export function AppShell() { router.replace("/", { scroll: false }); }, [router, isMobile]); + // ---- In-app Navigate back / forward over visited chat views ---- + // Own stack, not the browser History API (that one belongs to the mobile + // back-gesture/exit-guard machinery — see lib/navigation-history.ts). + const navigationHistory = useNavigationHistory(); + const { + record: recordNavigationView, + peekBack: peekNavigationBack, + peekForward: peekNavigationForward, + } = navigationHistory; + // Bumped by the recording effect on every view change. navigateInHistory + // captures it across its await and bails if the user moved on meanwhile, + // so a slow session-list fetch can never yank the chat out from under a + // view the user just switched to. + const navViewVersionRef = useRef(0); + const navSessionId = selectedSession?.id ?? null; + const navSessionCwd = selectedSession?.cwd ?? null; + useEffect(() => { + navViewVersionRef.current += 1; + recordNavigationView({ + sessionId: navSessionId, + cwd: navSessionId ? navSessionCwd : newSessionCwd, + }); + }, [navSessionId, navSessionCwd, newSessionCwd, recordNavigationView]); + + // Resolves a history entry's session id against the live list. `null` + // means the id is gone (entry should be dropped); "unavailable" means the + // list itself could not be fetched (keep the entry, abort this navigation). + const fetchSessionForNavigation = useCallback(async (sessionId: string): Promise => { + try { + const res = await fetch("/api/sessions"); + if (!res.ok) return "unavailable"; + const data = (await res.json()) as { sessions?: SessionInfo[] }; + return data.sessions?.find((s) => s.id === sessionId) ?? null; + } catch { + return "unavailable"; + } + }, []); + + const navigateInHistory = useCallback(async (direction: -1 | 1): Promise => { + // Dead entries (deleted sessions) are skipped, like browser history + // skipping a dead slot; bound the loop by the stack's own cap. + for (let attempt = 0; attempt <= NAVIGATION_HISTORY_MAX_ENTRIES; attempt++) { + const entry = direction === -1 ? peekNavigationBack() : peekNavigationForward(); + // Nothing that way: stay put. An exhausted stack never falls through to + // the browser's own back/forward — the keystroke is swallowed by the + // keyboard layer, so the app can never be backed out of by accident. + if (!entry) return; + if (entry.sessionId === null) { + if (direction === -1) navigationHistory.commitBack(); + else navigationHistory.commitForward(); + handleNewSession("navigate-history", entry.cwd); + return; + } + const versionAtPress = navViewVersionRef.current; + const session = await fetchSessionForNavigation(entry.sessionId); + if (navViewVersionRef.current !== versionAtPress) return; // user moved on: do nothing + if (session === "unavailable") return; // list fetch failed: keep the entry, try again next press + if (session) { + if (direction === -1) navigationHistory.commitBack(); + else navigationHistory.commitForward(); + handleSelectSession(session, false); + return; + } + if (direction === -1) navigationHistory.dropPeekedBack(); + else navigationHistory.dropPeekedForward(); + } + }, [navigationHistory, peekNavigationBack, peekNavigationForward, fetchSessionForNavigation, handleNewSession, handleSelectSession]); + + // Shared by the sidebar buttons and the global shortcuts. The keyboard + // layer always swallows the keystroke, so both directions stop dead at the + // ends of the stack instead of continuing into browser history. + const handleNavigateBack = useCallback(() => { + void navigateInHistory(-1); + }, [navigateInHistory]); + const handleNavigateForward = useCallback(() => { + void navigateInHistory(1); + }, [navigateInHistory]); + + // Tooltip shortcut labels follow the platform (⌘[ vs Alt+←). Computed from + // navigator after mount state to keep SSR markup stable. + const [navIsMac] = useState(() => isMacPlatform()); + const sidebarNavigation = useMemo(() => ({ + canBack: navigationHistory.canBack, + canForward: navigationHistory.canForward, + onBack: handleNavigateBack, + onForward: handleNavigateForward, + backShortcut: navigateShortcutHint(-1, navIsMac), + forwardShortcut: navigateShortcutHint(1, navIsMac), + }), [navigationHistory.canBack, navigationHistory.canForward, handleNavigateBack, handleNavigateForward, navIsMac]); + // Global keyboard shortcuts (handles Esc, Ctrl+Alt+N etc.) useGlobalKeyboardShortcuts({ onNewSession: (cwd: string) => handleNewSession(`kb-${Date.now()}`, cwd), + onNavigateBack: handleNavigateBack, + onNavigateForward: handleNavigateForward, activeCwd, scopeNativeSelectAll, }); @@ -1222,28 +1330,34 @@ export function AppShell() { const handleAgentEnd = useCallback(() => { setRefreshKey((k) => k + 1); setExplorerRefreshKey((k) => k + 1); - if (document.visibilityState !== "hidden" || !("Notification" in window)) return; - - const targetSession = selectedSession; - const notify = () => { - showCompletionNotification( - targetSession?.name ?? translate("appShell.sessionComplete"), - translate("appShell.taskFinished"), - () => { - window.focus(); - if (targetSession) handleSelectSession(targetSession); - }, - ); - }; - if (Notification.permission === "granted") notify(); - else if (Notification.permission === "default") { - void Notification.requestPermission().then((permission) => { if (permission === "granted") notify(); }); - } else { - // "denied": the OS blocks notifications, so surface the completion as an - // in-app toast instead of leaving background completions silent. - toast.info(targetSession?.name ?? translate("appShell.sessionComplete"), translate("appShell.taskFinished")); - } - }, [handleSelectSession, selectedSession]); + }, []); + + // Notification clicks name a session id; the session list carries its cwd + // and project, which selecting needs. When the list cannot be read (the + // sign-in expired), load the session URL: sign-in carries it through. + const openSessionById = useCallback((sessionId: string) => { + window.focus(); + // On narrow screens the file panel covers the chat it is about to show. + if (isCompactOverlay) setRightPanelOpen(false); + // A full load (not router navigation) so an expired sign-in reaches proxy.ts and the login page. + const sessionUrl = new URL(`/?session=${encodeURIComponent(sessionId)}`, window.location.origin).href; + void fetch("/api/sessions") + .then((r) => { + if (!r.ok) { + window.location.assign(sessionUrl); + return; + } + return (r.json() as Promise<{ sessions: SessionInfo[] }>).then((d) => { + const session = d.sessions.find((s) => s.id === sessionId); + if (session) handleSelectSession(session); + else toast.error(translate("notifications.sessionNotFound")); + }); + }) + .catch(() => window.location.assign(sessionUrl)); + }, [handleSelectSession, isCompactOverlay]); + + // Full-page Settings hides the chat, so its session counts as not viewed. + useNotifications({ sessionId: settingsTab ? null : selectedSession?.id ?? null, locale, onOpenSession: openSessionById }); const handleAutoName = useCallback(async () => { const sessionId = selectedSession?.id; @@ -1564,23 +1678,30 @@ export function AppShell() { !["absolute", "fixed"].includes(getComputedStyle(child).position)); const contentStyle = getComputedStyle(content); const headerStyle = getComputedStyle(header); - const rightStyle = getComputedStyle(right); const controlsWidth = children.reduce((width, child) => { const style = getComputedStyle(child); return width + child.getBoundingClientRect().width + parseFloat(style.marginLeft) + parseFloat(style.marginRight); }, 0) + Math.max(0, children.length - 1) * parseFloat(contentStyle.columnGap); - const required = controlsWidth + const leftNeed = controlsWidth + (tools.firstElementChild?.getBoundingClientRect().width ?? 0) + parseFloat(getComputedStyle(tools).columnGap) - + parseFloat(headerStyle.paddingLeft) + parseFloat(headerStyle.paddingRight) - + parseFloat(headerStyle.columnGap) - + parseFloat(rightStyle.minWidth) - + parseFloat(rightStyle.paddingLeft) + parseFloat(rightStyle.paddingRight); + + parseFloat(headerStyle.paddingLeft); + const rightNeed = parseFloat(headerStyle.paddingRight) + right.offsetWidth; + const required = leftNeed + parseFloat(headerStyle.columnGap) + rightNeed; if (closed) details.open = false; content.style.display = display; content.style.visibility = visibility; - const compact = required > header.clientWidth; + // Title pill: same inset on both sides = widest real cluster (+8px gap), so it + // is truly centered and never under a control. Expand the tools only if the + // pill still keeps >= 200px (the container-query hide threshold) with them. + header.style.setProperty( + "--topbar-side", + `${Math.ceil(Math.max(tools.offsetLeft + tools.offsetWidth, header.clientWidth - right.offsetLeft)) + 8}px`, + ); + const titleFits = header.querySelector(".shell-topbar-center") === null + || 2 * Math.max(leftNeed, rightNeed) + 216 <= header.clientWidth; + const compact = required > header.clientWidth || !titleFits; if (compact && details.dataset.compact !== "true" && hadControlsFocus) { restoreToolsFocusRef.current = true; } @@ -1588,6 +1709,8 @@ export function AppShell() { }; const observer = new ResizeObserver(update); observer.observe(header); + observer.observe(right); + observer.observe(tools); observer.observe(content); for (const child of content.children) observer.observe(child); document.fonts.addEventListener("loadingdone", update); @@ -1596,7 +1719,7 @@ export function AppShell() { observer.disconnect(); document.fonts.removeEventListener("loadingdone", update); }; - }, [hasGenerationSpeed, isMobile, locale, rightPanelOpen, showChat]); + }, [isMobile, locale, settingsTab, showChat]); // While restoring initial session from URL, don't show the placeholder const showPlaceholder = initialSessionRestored && !showChat; @@ -1614,7 +1737,7 @@ export function AppShell() { }, [sidebarHistory.exitNeedsNativeBack, t]); const activeCwdName = activeCwd ? getFileName(activeCwd) || activeCwd : null; - const windowTitle = activeCwdName ? `${activeCwdName} - omp web` : "omp web"; + const windowTitle = activeCwdName ? `${activeCwdName} - ${appName}` : appName; useEffect(() => { const syncWindowTitle = () => { @@ -1647,7 +1770,9 @@ export function AppShell() { settingsOpen={Boolean(settingsTab)} onOpenSettings={() => setSettingsTab((prev) => prev ? null : "general")} onOpenArchive={() => setArchiveBrowserOpen(true)} + navigation={sidebarNavigation} updateAvailable={Boolean(appUpdate?.updateAvailable) || ompUpdateAvailable} + onClose={isMobile ? handleSidebarToggle : undefined} /> ); @@ -1755,13 +1880,8 @@ export function AppShell() { } } @media (max-width: 640px) { - .sidebar-overlay-backdrop.sidebar-mobile-pending { - opacity: 0 !important; - pointer-events: none !important; - } .sidebar-container.sidebar-mobile-pending.sidebar-open { transform: translateX(-100%); - box-shadow: none; } } `} @@ -1771,21 +1891,6 @@ export function AppShell() { {/* Left sidebar: hidden on full-page Settings */} {!settingsTab && ( <> - {/* Mobile overlay backdrop */} -
setSidebarOpen(false)} - style={{ - position: "fixed", - inset: 0, - zIndex: 199, - background: "color-mix(in srgb, var(--text) 28%, transparent)", - opacity: sidebarOpen ? 1 : 0, - pointerEvents: sidebarOpen ? "auto" : "none", - transition: "opacity var(--dur-slow) var(--ease-out-warm)", - }} - /> -
@@ -2282,6 +2412,7 @@ export function AppShell() { onSystemPromptLoaderChange={handleSystemPromptLoaderChange} onSessionStatsChange={handleSessionStatsChange} onSessionStatsPanelOpen={openSessionStatsPanel} + sessionInfoContainer={sessionInfoContainer} onGenerationSpeedChange={handleGenerationSpeedChange} onOpenProviders={() => setSettingsTab("providers")} toolCallsDefaultCollapsed={toolCallsDefaultCollapsed} @@ -2354,7 +2485,7 @@ export function AppShell() { - {!settingsTab && ( - - )} void proceedWithAppUpdate()} onNotNow={dismissAppUpdate} /> {archiveBrowserOpen && ( { const oldKey = `edit-old-${navigation}`; @@ -118,13 +121,14 @@ for (const navigation of ["draft-key change", "unmount"]) { } fireEvent.change(screen.getByRole("textbox"), { target: { value: "new session draft" } }); await act(async () => { - release(true); + release(QUEUED_IMAGES); await cancellation; }); assert.equal(screen.getByRole("textbox").value, "new session draft"); assert.equal(getDraft(newKey).value, "new session draft"); assert.equal(getDraft(oldKey).value, "queued question\n\ndraft updated while waiting"); assert.deepEqual(getDraft(oldKey).files, originalFiles); + assert.deepEqual(getDraft(oldKey).images, QUEUED_IMAGES, "the queued message's images come back with its text"); if (navigation === "unmount") { view.unmount(); @@ -167,13 +171,14 @@ for (const navigation of ["original composer", "same-key remount"]) { await act(async () => { fireEvent.change(screen.getByRole("textbox"), { target: { value: "typed while waiting" } }); ref.current.insertText("and queued insertion"); - release(true); + release(QUEUED_IMAGES); await cancellation; }); const recovered = "queued question\n\ntyped while waiting and queued insertion"; assert.equal(screen.getByRole("textbox").value, recovered); assert.equal(getDraft(draftKey).value, recovered); assert.deepEqual(getDraft(draftKey).files, originalFiles); + assert.deepEqual(getDraft(draftKey).images, QUEUED_IMAGES, "the queued message's images come back with its text"); fireEvent.change(screen.getByRole("textbox"), { target: { value: `${recovered}\nnext user edit` } }); assert.equal(screen.getByRole("textbox").value, `${recovered}\nnext user edit`); diff --git a/components/ChatInput.queue-attachments.test.mjs b/components/ChatInput.queue-attachments.test.mjs new file mode 100644 index 000000000..53b4d5a6e --- /dev/null +++ b/components/ChatInput.queue-attachments.test.mjs @@ -0,0 +1,139 @@ +import "../tests/setup-dom.mjs"; +import assert from "node:assert/strict"; +import test, { afterEach, beforeEach } from "node:test"; +import React, { act } from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react/pure.js"; +import userEvent from "@testing-library/user-event"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { jsx: { runtime: "automatic" }, tsconfigPaths: true }); +const { ChatInput } = await jiti.import("./ChatInput.tsx"); +const { clearDraft, getDraft } = await jiti.import("@/lib/draft-store"); + +const KEY = "queue-attachments"; +// 1x1 transparent PNG. +const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + +// Same stand-in as ChatInput.clipboard-image.test.mjs: jsdom's FileReader +// brand-checks against its own realm's Blob; the composer only needs +// readAsDataURL -> `result`. +const previousFileReader = globalThis.FileReader; +class TestFileReader { + result = null; + onload = null; + onerror = null; + readAsDataURL(blob) { + blob.arrayBuffer().then( + (buffer) => { + this.result = `data:${blob.type};base64,${Buffer.from(buffer).toString("base64")}`; + this.onload?.(); + }, + (error) => this.onerror?.(error), + ); + } +} + +beforeEach(() => { + window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); + globalThis.FileReader = TestFileReader; + URL.createObjectURL ??= () => "blob:test"; + URL.revokeObjectURL ??= () => {}; +}); +afterEach(() => { + cleanup(); + clearDraft(KEY); + localStorage.clear(); + delete window.matchMedia; + globalThis.FileReader = previousFileReader; +}); + +/** A composer mid-run with an image and a text file attached through the picker path. */ +async function renderRunningWithAttachments({ queued = true } = {}) { + const calls = []; + // Images are the last argument of every queue callback, which resolves + // false when omp refused the message. + const record = (name) => async (message, ...rest) => { calls.push({ name, message, images: rest.at(-1) }); return queued; }; + const ref = React.createRef(); + render(React.createElement(ChatInput, { + ref, + draftKey: KEY, + isStreaming: true, + onSend: record("onSend"), + onAbort: record("onAbort"), + onSteer: record("onSteer"), + onFollowUp: record("onFollowUp"), + onPromptWithStreamingBehavior: record("onPromptWithStreamingBehavior"), + })); + await act(async () => { + ref.current.addFiles([ + new File([Buffer.from(PNG, "base64")], "dot.png", { type: "image/png" }), + new File(["hello"], "notes.txt", { type: "text/plain" }), + ]); + }); + await waitFor(() => { + const draft = getDraft(KEY); + assert.equal(draft?.images.length, 1, "attaching is allowed while the agent runs"); + assert.equal(draft?.files.length, 1); + }); + return calls; +} + +function assertQueuedWithAttachments(call, text) { + assert.ok(call.message.startsWith(text), "typed text leads the message"); + assert.match(call.message, /Attached file: notes\.txt\n```[^\n]*\nhello\n```/, "text file is folded in"); + assert.deepEqual(call.images?.map((image) => [image.data, image.mimeType]), [[PNG, "image/png"]]); +} + +test("Enter during a run steers with the attached image and text file, then clears them", async () => { + const user = userEvent.setup(); + const calls = await renderRunningWithAttachments(); + await user.type(screen.getByRole("textbox"), "look at this"); + await user.keyboard("{Enter}"); + await waitFor(() => assert.equal(calls.length, 1)); + assert.equal(calls[0].name, "onSteer", "default submit-during-run behavior steers"); + assertQueuedWithAttachments(calls[0], "look at this"); + await waitFor(() => assert.equal(getDraft(KEY), null)); +}); + +test("attachments alone turn Stop into Queue, which queues them as a follow-up", async () => { + const calls = await renderRunningWithAttachments(); + fireEvent.click(screen.getByRole("button", { name: /queue/i })); + await waitFor(() => assert.equal(calls.length, 1)); + assert.equal(calls[0].name, "onFollowUp"); + assertQueuedWithAttachments(calls[0], "Attached file:"); +}); + +test("a queued web slash command is expanded and keeps the attachments", async () => { + const { expandWebSlashCommand } = await jiti.import("@/lib/web-slash-commands"); + window.HTMLElement.prototype.scrollIntoView = () => {}; + const user = userEvent.setup(); + const calls = await renderRunningWithAttachments(); + await user.type(screen.getByRole("textbox"), "/goal ship it"); + await user.keyboard("{Enter}"); + await waitFor(() => assert.equal(calls.length, 1)); + delete window.HTMLElement.prototype.scrollIntoView; + assert.equal(calls[0].name, "onPromptWithStreamingBehavior"); + assertQueuedWithAttachments(calls[0], expandWebSlashCommand("/goal ship it").prompt); +}); + +test("a refused follow-up goes back whole to its draft, ahead of what was typed meanwhile", async () => { + let refuse; + const calls = await renderRunningWithAttachments({ queued: new Promise((resolve) => { refuse = () => resolve(false); }) }); + fireEvent.click(screen.getByRole("button", { name: /queue/i })); + await waitFor(() => assert.equal(calls.length, 1)); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "typed meanwhile" } }); + await act(async () => { refuse(); }); + await waitFor(() => assert.deepEqual(getDraft(KEY)?.images, [{ data: PNG, mimeType: "image/png" }])); + assert.equal(getDraft(KEY)?.value, `${calls[0].message}\n\ntyped meanwhile`, "the text, file contents included, comes back too"); + assert.equal(screen.getByRole("textbox").value, `${calls[0].message}\n\ntyped meanwhile`); +}); + +test("recovered images past the attachment cap all stay in the composer", async () => { + const { MAX_ATTACHED_IMAGES } = await jiti.import("@/lib/image-attachments"); + const { recoverDraft } = await jiti.import("@/lib/draft-store"); + await renderRunningWithAttachments(); + const recovered = Array.from({ length: MAX_ATTACHED_IMAGES }, () => ({ data: PNG, mimeType: "image/png" })); + await act(async () => { recoverDraft(KEY, { text: "", images: recovered }); }); + await waitFor(() => assert.equal(getDraft(KEY)?.images.length, MAX_ATTACHED_IMAGES + 1)); + assert.equal(document.querySelectorAll("img").length >= MAX_ATTACHED_IMAGES + 1, true, "every image has a preview to remove"); +}); diff --git a/components/ChatInput.slow.test.mjs b/components/ChatInput.slow.test.mjs new file mode 100644 index 000000000..a315d7b1f --- /dev/null +++ b/components/ChatInput.slow.test.mjs @@ -0,0 +1,67 @@ +import "../tests/setup-dom.mjs"; +import assert from "node:assert/strict"; +import test, { afterEach, beforeEach } from "node:test"; +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react/pure.js"; +import userEvent from "@testing-library/user-event"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { jsx: { runtime: "automatic" }, tsconfigPaths: true }); +const { ChatInput } = await jiti.import("./ChatInput.tsx"); +const enModule = await jiti.import("@/lib/i18n/locales/en.json"); +const en = enModule.default ?? enModule; + +beforeEach(() => { + window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); +}); +afterEach(() => { + cleanup(); + localStorage.clear(); + delete window.matchMedia; +}); + +function renderSlow(props) { + const changes = []; + render(React.createElement(ChatInput, { + onSend() {}, + onAbort() {}, + isStreaming: false, + slowModeSupported: true, + slowModeScope: "session", + onSlowModeChange: (enabled) => { changes.push(enabled); }, + ...props, + })); + return { changes, button: screen.getByRole("button", { name: en["chatInput.slowLabel"] }) }; +} + +for (const enabled of [true, false]) { + test(`clicking the ${enabled ? "pressed" : "unpressed"} Slow toggle asks for ${!enabled}`, async () => { + const user = userEvent.setup(); + const { changes, button } = renderSlow({ slowModeEnabled: enabled }); + assert.equal(button.getAttribute("aria-pressed"), String(enabled)); + await user.click(button); + assert.deepEqual(changes, [!enabled]); + }); +} + +test("the Slow toggle is disabled and inert while a run streams", async () => { + const user = userEvent.setup(); + const { changes, button } = renderSlow({ slowModeEnabled: false, isStreaming: true }); + assert.equal(button.disabled, true); + await user.click(button); + assert.deepEqual(changes, []); +}); + +test("the Slow tooltip states the scope omp reports", () => { + const titles = {}; + for (const scope of ["global", "session", undefined]) { + const { button } = renderSlow({ slowModeScope: scope }); + titles[String(scope)] = button.getAttribute("title"); + cleanup(); + } + assert.equal(titles.global, en["chatInput.slowTitleGlobal"]); + assert.equal(titles.session, en["chatInput.slowTitleSession"]); + assert.notEqual(titles.global, titles.session); + // A supported model without a reported scope never claims a shared setting. + assert.equal(titles.undefined, en["chatInput.slowTitleSession"]); +}); diff --git a/components/ChatInput.test.mjs b/components/ChatInput.test.mjs index 059c858ca..c1a856d56 100644 --- a/components/ChatInput.test.mjs +++ b/components/ChatInput.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { createJiti } from "jiti"; +import { JSDOM } from "jsdom"; const jiti = createJiti(import.meta.url, { jsx: { runtime: "automatic" }, @@ -11,6 +12,25 @@ const jiti = createJiti(import.meta.url, { const { ChatInput, ModelErrorBanner, filterModelOptions } = await jiti.import("./ChatInput.tsx"); const { setDraft, clearDraft } = await jiti.import("@/lib/draft-store"); +test("provider-defined reasoning levels appear once in the trigger", () => { + for (const thinkingLevel of ["ultra", "ultrathink-extended", "constructor"]) { + const html = renderToStaticMarkup(React.createElement(ChatInput, { + onSend() {}, + onThinkingLevelChange() {}, + thinkingLevel, + isStreaming: false, + })); + const dom = new JSDOM(html); + try { + const trigger = dom.window.document.querySelector(".composer-thinking-control > button"); + assert.equal(trigger.textContent, thinkingLevel); + assert.ok(trigger.getAttribute("aria-label").endsWith(`: ${thinkingLevel}`)); + } finally { + dom.window.close(); + } + } +}); + test("shows Queue instead of Stop for typed text during a run", () => { const draftKey = "chat-input-queue-action-test"; setDraft(draftKey, { value: "Continue after the current run", images: [], files: [] }); @@ -57,7 +77,7 @@ test("formats structured Claude low-priority state in the browser locale", () => React.createElement(ChatInput, { onSend() {}, isStreaming: false, - anthropicSlowMode: { stage: "low_priority", resetsAtSec, allowanceLeftPercent: 62 }, + usageLimit: { stage: "low_priority", resetsAtSec, allowanceLeftPercent: 62 }, }), ); assert.match(html, new RegExp(`low priority until ${time.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")} · 62% left`)); @@ -76,11 +96,11 @@ test("formats every Claude usage-limit stage", () => { [{ stage: "wrap_up", extraUsage: true }, "limit reached · wrap-up, then extra usage"], [{ stage: "low_priority", resetsAtSec: longReset }, `low priority until ${longTime}`], ]; - for (const [anthropicSlowMode, label] of rows) { + for (const [usageLimit, label] of rows) { const html = renderToStaticMarkup( - React.createElement(ChatInput, { onSend() {}, isStreaming: false, anthropicSlowMode }), + React.createElement(ChatInput, { onSend() {}, isStreaming: false, usageLimit }), ); - assert.ok(html.includes(label), `${JSON.stringify(anthropicSlowMode)} should render ${label}`); + assert.ok(html.includes(label), `${JSON.stringify(usageLimit)} should render ${label}`); } }); @@ -97,18 +117,18 @@ test("never renders a null percentage, an invalid date, or a past reset clock", { stage: "wrap_up", resetsAtSec: null, extraUsage: false }, { stage: "wrap_up", resetsAtSec: pastSec, extraUsage: false }, ]; - for (const anthropicSlowMode of rows) { + for (const usageLimit of rows) { const html = renderToStaticMarkup( - React.createElement(ChatInput, { onSend() {}, isStreaming: false, anthropicSlowMode }), + React.createElement(ChatInput, { onSend() {}, isStreaming: false, usageLimit }), ); - const label = `${JSON.stringify(anthropicSlowMode)} -> ${html}`; + const label = `${JSON.stringify(usageLimit)} -> ${html}`; // "null" / "NaN" / "Invalid Date" reaching the label is the bug this pins: // every one of them is a value the user would plan their session around. assert.ok(!html.includes("Invalid Date"), `invalid date rendered: ${label}`); assert.ok(!html.includes("null%"), `null percentage rendered: ${label}`); assert.ok(!html.includes("NaN%"), `NaN percentage rendered: ${label}`); // A reset already in the past must not render a plausible wrong clock. - if (anthropicSlowMode.resetsAtSec === pastSec) { + if (usageLimit.resetsAtSec === pastSec) { assert.ok(!html.includes(new Date(pastSec * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })), `past reset clock rendered: ${label}`); } @@ -119,7 +139,7 @@ test("never renders a null percentage, an invalid date, or a past reset clock", React.createElement(ChatInput, { onSend() {}, isStreaming: false, - anthropicSlowMode: { stage: "low_priority", resetsAtSec: null, allowanceLeftPercent: 62 }, + usageLimit: { stage: "low_priority", resetsAtSec: null, allowanceLeftPercent: 62 }, }), ), /low priority · 62% left/, @@ -130,10 +150,24 @@ test("never renders a null percentage, an invalid date, or a past reset clock", React.createElement(ChatInput, { onSend() {}, isStreaming: false, - anthropicSlowMode: { stage: "low_priority", resetsAtSec: null }, + usageLimit: { stage: "low_priority", resetsAtSec: null }, }), ), - /class="composer-slow-mode-badge"[^>]*>low priority]*>low priority { + const render = (props) => renderToStaticMarkup( + React.createElement(ChatInput, { onSend() {}, isStreaming: false, onSlowModeChange() {}, ...props }), + ); + // Older omp omits the field; an unsupported model reports false. + for (const props of [{}, { slowModeSupported: false, slowModeEnabled: true }]) { + assert.doesNotMatch(render(props), /composer-slow-control/); + } + assert.match( + render({ slowModeSupported: true, slowModeEnabled: true }), + /]*class="composer-slow-control"[^>]*aria-pressed="true"/, ); }); @@ -180,19 +214,6 @@ test("renders goal, planning, and advisor indicators at the composer", () => { assert.match(html, /aria-label="(More actions|chatInput\.plusMenu)"/); }); -test("renders the compact toolbar action", () => { - const html = renderToStaticMarkup( - React.createElement(ChatInput, { - onSend() {}, - onAbort() {}, - onCompact() {}, - isStreaming: false, - }), - ); - - assert.match(html, /title="(Compact context|chatInput\.compactContext)"/); -}); - test("shows the advisor thunder indicator with the reviewing model and reasoning", () => { const html = renderToStaticMarkup( React.createElement(ChatInput, { diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index de70c4399..b6912929e 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -1,7 +1,8 @@ "use client"; import React, { useRef, useState, useCallback, useEffect, useLayoutEffect, useImperativeHandle, forwardRef, memo, KeyboardEvent } from "react"; -import { ChevronDown, ClipboardPaste, ListChecks, Loader2, Mic, Paperclip, Plus, Shrink, Sparkles, Wrench, X, Zap } from "lucide-react"; +import { createPortal } from "react-dom"; +import { ChevronDown, ClipboardPaste, ListChecks, Loader2, Mic, Paperclip, Plus, RotateCw, Shrink, Snail, Sparkles, Wrench, X, Zap } from "lucide-react"; import { getSubmitDuringRunBehavior, isWordCompletionEnabled } from "@/lib/composer-prefs"; import type { BuiltinSlashCommandResult, CompactResultInfo, QueuedMessages, SlashCommandInfo } from "@/hooks/useAgentSession"; import type { ActiveGoal, ActivePlan } from "@/lib/web-mode-state"; @@ -9,11 +10,12 @@ import { toast } from "@/components/ui/toast"; import { ConfirmDialog } from "@/components/ui/field"; import { useDictation } from "@/hooks/useDictation"; import { toastBtwError } from "@/hooks/useBtw"; -import type { AnthropicSlowModeState, GenerationSpeedInfo, SessionStatsInfo } from "@/lib/pi-types"; +import type { GenerationSpeedInfo, SessionStatsInfo, SlowModeScope, UsageLimitState } from "@/lib/pi-types"; +import type { SttAfter } from "@/lib/stt"; import { formatCompactNumber, formatPercent } from "@/lib/format"; import { ContextDetailPanel } from "./ComposerPanels"; import { RecordingDeck } from "./RecordingDeck"; -import { clearDraft, getDraft, mergeRecoveredText, recoverDraftText, setDraft, subscribeDraftRecovery } from "@/lib/draft-store"; +import { clearDraft, getDraft, mergeRecoveredText, recoverDraft, setDraft, subscribeDraftRecovery, type ChatDraftImage } from "@/lib/draft-store"; import { expandWebSlashCommand } from "@/lib/web-slash-commands"; import type { AttachedImage, AttachedTextFile } from "./ChatInput-draft-attachments"; import { @@ -67,9 +69,18 @@ import { useWordPrediction, type PredictWord, type PredictWordFeedback } from "@ import { acceptGhost } from "@/lib/word-prediction"; import { GhostMirror } from "@/components/GhostMirror"; import { useI18n } from "@/lib/i18n"; -import { selectableThinkingLevels } from "@/lib/thinking-levels"; +import { selectableThinkingLevels, DEFAULT_THINKING_LEVELS } from "@/lib/thinking-levels"; import type { ToolPreset } from "@/lib/tool-presets"; +function ThinkingGlyph({ level }: { level: string }) { + if (!DEFAULT_THINKING_LEVELS.includes(level)) return null; + return ( + + ); +} + const SLOW_MODE_SAME_DAY_MS = 20 * 3_600_000; function formatSlowModeResetClock(resetsAtSec: number, now: number): string { @@ -100,8 +111,8 @@ function slowModeAllowancePercent( : null; } -function formatAnthropicSlowModeLabel( - state: AnthropicSlowModeState, +function formatUsageLimitLabel( + state: UsageLimitState, t: (key: string, vars?: Record) => string, now = Date.now(), ): string { @@ -139,9 +150,10 @@ interface Props { onPredictWord?: PredictWord; onPredictWordFeedback?: PredictWordFeedback; onAbort: () => void; - onSteer?: (message: string, images?: AttachedImage[]) => void; - onFollowUp?: (message: string, images?: AttachedImage[]) => void; - onPromptWithStreamingBehavior?: (message: string, behavior: "steer" | "followUp", images?: AttachedImage[]) => void; + /** Steer/follow-up callbacks resolve false when omp refused the message. */ + onSteer?: (message: string, images?: AttachedImage[]) => Promise; + onFollowUp?: (message: string, images?: AttachedImage[]) => Promise; + onPromptWithStreamingBehavior?: (message: string, behavior: "steer" | "followUp", images?: AttachedImage[]) => Promise; isStreaming: boolean; model?: { provider: string; modelId: string } | null; isAutoModelSelection?: boolean; @@ -152,10 +164,16 @@ interface Props { onModelChange?: (provider: string, modelId: string) => void; fastModeEnabled?: boolean; fastModeActive?: boolean; - /** omp's structured Claude usage-limit state, shown as a warning chip. */ - anthropicSlowMode?: AnthropicSlowModeState; + /** omp's structured provider usage-limit state, shown as a warning chip. */ + usageLimit?: UsageLimitState; fastModeSupported?: boolean; onFastModeChange?: (enabled: boolean) => void; + /** omp reports `/slow` applies to the active model (absent on older omp). */ + slowModeSupported?: boolean; + slowModeEnabled?: boolean; + /** `global` = shared persisted omp setting; `session` = this session only. */ + slowModeScope?: SlowModeScope; + onSlowModeChange?: (enabled: boolean) => void; onAbortCompaction?: () => void; isCompacting?: boolean; compactResult?: CompactResultInfo | null; @@ -176,18 +194,21 @@ interface Props { advisorActive?: boolean; /** Resolved advisor role (display model + reasoning) for the composer tooltips. */ advisorModel?: { name: string; reasoning: string | null } | null; - /** Compact the session context from the composer toolbar. */ + /** Compact the session context from the session information panel. */ onCompact?: () => void; - /** Live context totals feeding the composer context ring. */ + /** Live context totals feeding the session information ring. */ contextUsage?: { percent: number | null; contextWindow: number; tokens: number | null } | null; /** Session stats shown in the context ring popover. */ sessionStats?: SessionStatsInfo | null; + /** Mobile top-bar mount point for the session information control. */ + sessionInfoContainer?: HTMLDivElement | null; /** Model capacity shown in the context ring popover. */ modelCapacity?: { contextWindow?: number; maxTokens?: number } | null; /** Generation speed shown in the context ring popover. */ generationSpeed?: GenerationSpeedInfo | null; - /** Cancel one pending message in omp before removing it from the queue panel. */ - onRemoveQueuedMessage?: (text: string, queue: keyof QueuedMessages) => Promise; + /** Cancel one pending message in omp before removing it from the queue + * panel; resolves to its images once omp confirms, else false. */ + onRemoveQueuedMessage?: (text: string, queue: keyof QueuedMessages) => Promise; /** Promote the first matching native follow-up into steering. */ onPromoteQueuedToSteer?: (text: string) => void | Promise; slashCommands?: SlashCommandInfo[]; @@ -296,9 +317,8 @@ function menuDropStyle(placement: MenuPlacement, maxHeight: number | null): Reac }; } - export const ChatInput = memo(forwardRef(function ChatInput({ - onSend, onPredictWord, onPredictWordFeedback, onAbort, onSteer, onFollowUp, isStreaming, model, isAutoModelSelection, modelNames, modelList, modelError, modelsLoading, onModelChange, fastModeEnabled, fastModeActive, anthropicSlowMode, fastModeSupported, onFastModeChange, + onSend, onPredictWord, onPredictWordFeedback, onAbort, onSteer, onFollowUp, isStreaming, model, isAutoModelSelection, modelNames, modelList, modelError, modelsLoading, onModelChange, fastModeEnabled, fastModeActive, usageLimit, fastModeSupported, onFastModeChange, slowModeSupported, slowModeEnabled, slowModeScope, onSlowModeChange, onAbortCompaction, isCompacting, compactResult, thinkingLevel, onThinkingLevelChange, availableThinkingLevels, thinkingLevelMap, modelNameOverride, toolPreset, onToolPresetChange, @@ -312,6 +332,7 @@ export const ChatInput = memo(forwardRef(function ChatIn onCompact, contextUsage, sessionStats, + sessionInfoContainer, modelCapacity, generationSpeed, onRemoveQueuedMessage, @@ -357,8 +378,8 @@ export const ChatInput = memo(forwardRef(function ChatIn items[next]?.focus(); }, []); const { t, tn, locale } = useI18n(); - const anthropicSlowModeLabel = anthropicSlowMode - ? formatAnthropicSlowModeLabel(anthropicSlowMode, t) + const usageLimitLabel = usageLimit + ? formatUsageLimitLabel(usageLimit, t) : undefined; const modelCollator = React.useMemo( () => new Intl.Collator(locale, { numeric: true, sensitivity: "base" }), @@ -374,6 +395,11 @@ export const ChatInput = memo(forwardRef(function ChatIn const [modelDropdownOpen, setModelDropdownOpen] = useState(false); const [thinkingDropdownOpen, setThinkingDropdownOpen] = useState(false); const [contextOpen, setContextOpen] = useState(false); + // Desktop context popover height cap: the room above its trigger, so it only + // scrolls when the window is genuinely too short for it. + const [contextMaxHeight, setContextMaxHeight] = useState(); + const [composerContextContainer, setComposerContextContainer] = useState(null); + const contextContainer = isMobile ? sessionInfoContainer : composerContextContainer; const [plusMenuOpen, setPlusMenuOpen] = useState(false); const [plusExpanded, setPlusExpanded] = useState<"tools" | "advisor" | null>(null); const [modelSearchQuery, setModelSearchQuery] = useState(""); @@ -406,6 +432,7 @@ export const ChatInput = memo(forwardRef(function ChatIn const modelSearchInputRef = useRef(null); const thinkingDropdownRef = useRef(null); const contextWrapRef = useRef(null); + const contextReturnFocusRef = useRef(null); const plusMenuRef = useRef(null); const historyMenuRef = useRef(null); const slashMenuRef = useRef(null); @@ -621,15 +648,11 @@ export const ChatInput = memo(forwardRef(function ChatIn }, []); const processFiles = useCallback((files: File[]) => { - if (isStreaming) { - setAttachError(t("chatInput.attachmentsDisabled")); - return; - } const imageFiles = files.filter((file) => file.type.startsWith("image/")); const otherFiles = files.filter((file) => !file.type.startsWith("image/")); void processImageFiles(imageFiles); void processTextFiles(otherFiles); - }, [isStreaming, processImageFiles, processTextFiles]); + }, [processImageFiles, processTextFiles]); const processFilesRef = useRef(processFiles); processFilesRef.current = processFiles; @@ -768,6 +791,10 @@ export const ChatInput = memo(forwardRef(function ChatIn const { text } = recovery; // Merge with pending edits rather than replacing them with a store snapshot. setValue((current) => mergeRecoveredText(current, recovery)); + const images = recovery.images ?? []; + if (images.length) { + setAttachedImages((prev) => [...prev, ...draftImagesToAttachedImages(images)]); + } setAtQuery(null); setHistoryMenuOpen(false); requestAnimationFrame(() => { @@ -856,11 +883,6 @@ export const ChatInput = memo(forwardRef(function ChatIn onSend(composedMessage, attachedImagesRef.current.length ? attachedImagesRef.current : undefined); clearInput(); }, [value, isStreaming, onBuiltinCommand, onSend, clearInput, onAudioUnlock, rejectsOversizedPrompt, runBuiltinCommand, sendSideQuestion]); - /** What happens to the composer after the transcript lands: null inserts it - * for editing; "send" dispatches immediately; "steer"/"followup" queue it - * into the running agent. */ - type DictationAfterMode = "send" | "steer" | "followup"; - const dictationAfterRef = useRef(null); const { isRecording, isPaused, @@ -881,17 +903,20 @@ export const ChatInput = memo(forwardRef(function ChatIn seekPreview: seekPreviewDictation, confirmTranscribe: confirmTranscribeDictation, } = useDictation({ - onTranscript: (text) => { - const after = dictationAfterRef.current; - dictationAfterRef.current = null; + scope: draftKey, + // `after` comes back with the transcript from the server job, so a send or + // queue chosen before a session switch (which remounts this composer) holds. + // The run may have started or ended since: queue while the agent runs + // (Send becomes a follow-up), send when idle (nothing left to queue behind). + onTranscript: (text, after) => { const base = valueRef.current; const sep = base.length > 0 && !base.endsWith(" ") ? " " : ""; const finalText = base + sep + text; insertTextAtCursor(text); - if (after === "send") { + if (after && onFollowUp) { + sendQueued(after === "send" ? "followup" : after, finalText); + } else if (after && !isStreaming) { void handleSend(finalText); - } else if (after === "steer" || after === "followup") { - sendQueued(after, finalText); } else { toast.success(t("chatInput.dictationSuccess")); } @@ -912,26 +937,12 @@ export const ChatInput = memo(forwardRef(function ChatIn toast.error(msg); }, }); - const stopAndInsertDictation = useCallback(() => { - dictationAfterRef.current = null; - stopDictation(); - }, [stopDictation]); - const stopAndSendDictation = useCallback(() => { - dictationAfterRef.current = "send"; - stopDictation({ immediateSend: true }); - }, [stopDictation]); - const stopAndQueueDictation = useCallback((mode: "steer" | "followup") => { - dictationAfterRef.current = mode; - stopDictation({ immediateSend: true }); - }, [stopDictation]); - const cancelDictationAndReset = useCallback(() => { - dictationAfterRef.current = null; - cancelDictation(); - }, [cancelDictation]); - const startFreshDictation = useCallback(() => { - dictationAfterRef.current = null; - toggleDictation(); - }, [toggleDictation]); + // From the review deck the recorder has already stopped, so finishing with a + // send/queue choice transcribes the reviewed recording instead. + const stopAndFinishDictation = useCallback((after: SttAfter) => { + if (isReviewing) confirmTranscribeDictation({ after }); + else stopDictation({ after }); + }, [isReviewing, confirmTranscribeDictation, stopDictation]); // While the recording deck replaces the textarea there is no focused input, // so Escape/Enter are handled at window level: Escape cancels/discard, Enter // retries after an error or converts the recording to text. @@ -940,7 +951,7 @@ export const ChatInput = memo(forwardRef(function ChatIn const onKeyDown = (e: globalThis.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); - cancelDictationAndReset(); + cancelDictation(); } else if (e.key === "Enter" && !e.shiftKey && !isTranscribing) { // Let focused deck/toolbar controls keep their own activation; // only hijack Enter from the non-interactive page context. @@ -949,12 +960,12 @@ export const ChatInput = memo(forwardRef(function ChatIn e.preventDefault(); if (transcribeError) retryDictation(); else if (isReviewing) confirmTranscribeDictation(); - else stopAndInsertDictation(); + else stopDictation(); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [isRecording, isPaused, isReviewing, isTranscribing, transcribeError, cancelDictationAndReset, retryDictation, confirmTranscribeDictation, stopAndInsertDictation]); + }, [isRecording, isPaused, isReviewing, isTranscribing, transcribeError, cancelDictation, retryDictation, confirmTranscribeDictation, stopDictation]); const slashQuery = value.startsWith("/") && !/\s/.test(value.slice(1)) ? value.slice(1).toLowerCase() @@ -1257,7 +1268,18 @@ export const ChatInput = memo(forwardRef(function ChatIn const msg = raw.trim(); if (!msg && !attachedImagesRef.current.length && !attachedTextFilesRef.current.length) return; if (sendSideQuestion(msg, overrideText)) return; - if (attachedImagesRef.current.length || attachedTextFilesRef.current.length) return; + const images = attachedImagesRef.current.length ? attachedImagesRef.current : undefined; + // The queue callbacks resolve false when omp refused the message: it goes + // back, text and images together, to the draft it was sent from, even if + // the user has typed or switched sessions since. + const key = draftKeyRef.current; + const keptImages = images?.map(imageToDraftImage); + const recoverOnFailure = (queued: Promise, text: string) => { + void Promise.resolve(queued).then((ok) => { + if (ok === false && key) recoverDraft(key, { text, images: keptImages }); + }); + }; + const files = attachedTextFilesRef.current; onAudioUnlock?.(); const streamingBehavior = mode === "steer" ? "steer" : "followUp"; if (msg.startsWith("/") && onPromptWithStreamingBehavior) { @@ -1274,8 +1296,9 @@ export const ChatInput = memo(forwardRef(function ChatIn // own ACP handlers can run them. const expansion = expandWebSlashCommand(msg); if (expansion.kind === "expand") { - if (rejectsOversizedPrompt(expansion.prompt, attachedImagesRef.current)) return; - onPromptWithStreamingBehavior(expansion.prompt, streamingBehavior, attachedImagesRef.current.length ? attachedImagesRef.current : undefined); + const prompt = composeMessageWithTextAttachments(expansion.prompt, files); + if (rejectsOversizedPrompt(prompt, attachedImagesRef.current)) return; + recoverOnFailure(onPromptWithStreamingBehavior(prompt, streamingBehavior, images), prompt); clearInput(); return; } @@ -1286,29 +1309,29 @@ export const ChatInput = memo(forwardRef(function ChatIn })); return; } - if (rejectsOversizedPrompt(msg, attachedImagesRef.current)) return; - onPromptWithStreamingBehavior(msg, streamingBehavior, attachedImagesRef.current.length ? attachedImagesRef.current : undefined); + const prompt = composeMessageWithTextAttachments(msg, files); + if (rejectsOversizedPrompt(prompt, attachedImagesRef.current)) return; + recoverOnFailure(onPromptWithStreamingBehavior(prompt, streamingBehavior, images), prompt); clearInput(); return; } - if (rejectsOversizedPrompt(msg, attachedImagesRef.current)) return; + const composedMessage = composeMessageWithTextAttachments(msg, files); + if (rejectsOversizedPrompt(composedMessage, attachedImagesRef.current)) return; if (mode === "steer" && onSteer) { - onSteer(msg, attachedImagesRef.current.length ? attachedImagesRef.current : undefined); + recoverOnFailure(onSteer(composedMessage, images), composedMessage); } else if (mode === "followup" && onFollowUp) { - onFollowUp(msg, attachedImagesRef.current.length ? attachedImagesRef.current : undefined); + recoverOnFailure(onFollowUp(composedMessage, images), composedMessage); } clearInput(); }, [value, onPromptWithStreamingBehavior, onSteer, onFollowUp, clearInput, onAudioUnlock, t, advisorEnabled, rejectsOversizedPrompt, sendSideQuestion]); - // A typed, text-only message during a run is a queued follow-up — and so is - // a dictation in progress: the primary button must take the same state it + // A typed message or attachment during a run is a queued follow-up — and so + // is a dictation in progress: the primary button must take the same state it // would have if the composer already held text. Keep Stop as the action // while the composer is empty and nothing is being recorded. const dictationCapturing = isRecording || isPaused || isReviewing; const primaryActionQueuesMessage = isStreaming - && (Boolean(value.trim()) || dictationCapturing) - && attachedImages.length === 0 - && attachedTextFiles.length === 0 + && (Boolean(value.trim()) || attachedImages.length > 0 || attachedTextFiles.length > 0 || dictationCapturing) && Boolean(onFollowUp); // ── Queued follow-up bar ──────────────────────────────────────────────── @@ -1346,7 +1369,8 @@ export const ChatInput = memo(forwardRef(function ChatIn setQueuedDeleteTarget(null); if (!removed || action !== "edit") return; // Recover through the store even if a new composer now owns this key. - recoverDraftText(key, entry.text); + // omp labels an image-only message "[Image]": not text when it has images. + recoverDraft(key, { text: entry.text === "[Image]" && removed.length > 0 ? "" : entry.text, images: removed }); } catch (error) { setQueuedDeleteTarget(null); toast.error(error instanceof Error ? error.message : String(error)); @@ -1433,7 +1457,7 @@ export const ChatInput = memo(forwardRef(function ChatIn } if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "m") { e.preventDefault(); - startFreshDictation(); + toggleDictation(); return; } @@ -1580,7 +1604,7 @@ export const ChatInput = memo(forwardRef(function ChatIn } } }, - [isMobile, isStreaming, onSteer, onFollowUp, onAbort, onMinimize, slashMenuOpen, slashQuery, filteredSlashCommands, slashActiveIndex, applySlashCommand, sendQueued, handleSend, getNextSlashIndex, atMenuOpen, atQuery, atMatches, atActiveIndex, applyAtCompletion, historyMenuOpen, inputHistory, historyActiveIndex, applyHistoryInput, value, startFreshDictation, wordPrediction] + [isMobile, isStreaming, onSteer, onFollowUp, onAbort, onMinimize, slashMenuOpen, slashQuery, filteredSlashCommands, slashActiveIndex, applySlashCommand, sendQueued, handleSend, getNextSlashIndex, atMenuOpen, atQuery, atMatches, atActiveIndex, applyAtCompletion, historyMenuOpen, inputHistory, historyActiveIndex, applyHistoryInput, value, toggleDictation, wordPrediction] ); @@ -1689,7 +1713,7 @@ export const ChatInput = memo(forwardRef(function ChatIn saved: formatTokenCount(compactSavedTokens, locale), }) : null; - // Composer context ring: live totals, falling back to the session snapshot. + // Session information ring: live totals, falling back to the session snapshot. const ringCtx = contextUsage ?? sessionStats?.contextUsage ?? null; const ringPct = ringCtx?.percent ?? null; const ringTone = ringPct !== null && ringPct > 90 @@ -1745,6 +1769,47 @@ export const ChatInput = memo(forwardRef(function ChatIn if (!thinkingDropdownOpen) return; requestAnimationFrame(() => thinkingDropdownRef.current?.querySelector('[role="menuitemradio"]:not([disabled])')?.focus()); }, [thinkingDropdownOpen]); + useLayoutEffect(() => { + const wrap = contextWrapRef.current; + if (!contextOpen || isMobile || !wrap) return; + // --ui-scale zooms : rects are in painted pixels, styles are not. + const scale = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--ui-scale")) || 1; + const room = wrap.getBoundingClientRect().top - getMenuBoundary(wrap).top; + setContextMaxHeight(Math.max(160, room / scale - 16)); + }, [contextOpen, isMobile]); + useLayoutEffect(() => { + const panel = contextWrapRef.current?.querySelector('[role="dialog"]'); + if (contextOpen && panel) { + const active = document.activeElement; + contextReturnFocusRef.current ??= active instanceof HTMLElement ? active : null; + panel.focus(); + } else if (!contextOpen) { + const previous = contextReturnFocusRef.current; + contextReturnFocusRef.current = null; + const active = document.activeElement; + if (previous?.isConnected && (active === document.body || contextWrapRef.current?.contains(active))) { + previous.focus(); + } + } + }, [contextOpen, contextContainer]); + useEffect(() => { + if (!contextOpen) return; + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key !== "Escape" || event.isComposing || event.defaultPrevented) return; + const panel = contextWrapRef.current?.querySelector('[role="dialog"]'); + if (!panel?.getClientRects().length) return; + const popup = event.target instanceof Element + ? event.target.closest('[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]') + : null; + if (popup && !contextWrapRef.current?.contains(popup)) return; + // Capture Escape before the composer or global shortcut can stop a run. + event.preventDefault(); + event.stopPropagation(); + setContextOpen(false); + }; + document.addEventListener("keydown", onKeyDown, true); + return () => document.removeEventListener("keydown", onKeyDown, true); + }, [contextOpen]); // Close dropdowns on outside click useEffect(() => { @@ -1775,9 +1840,8 @@ export const ChatInput = memo(forwardRef(function ChatIn const attachmentMenuItemStyle: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8, width: "100%", padding: "7px 10px", border: 0, borderRadius: 5, - background: "transparent", color: isStreaming ? "var(--text-dim)" : "var(--text-muted)", - cursor: isStreaming ? "not-allowed" : "pointer", fontSize: 12, textAlign: "left", - opacity: isStreaming ? 0.5 : 1, + background: "transparent", color: "var(--text-muted)", + cursor: "pointer", fontSize: 12, textAlign: "left", }; return ( @@ -1820,7 +1884,6 @@ export const ChatInput = memo(forwardRef(function ChatIn // only hide files the app can attach (code, config, logs, ...). accept="*/*" multiple - disabled={isStreaming} style={{ display: "none" }} onChange={(e) => { const files = Array.from(e.target.files ?? []); @@ -2603,12 +2666,12 @@ export const ChatInput = memo(forwardRef(function ChatIn previewDuration={previewDuration} transcribeError={transcribeError} onPauseResume={togglePauseDictation} - onConvert={stopAndInsertDictation} + onConvert={stopDictation} onRetry={retryDictation} onPlayPreview={isPlayingPreview ? pausePreviewDictation : playPreviewDictation} onSeekPreview={seekPreviewDictation} onConfirmTranscribe={confirmTranscribeDictation} - onDiscard={cancelDictationAndReset} + onDiscard={cancelDictation} /> ) : (
@@ -2739,7 +2802,6 @@ export const ChatInput = memo(forwardRef(function ChatIn role="menuitem" type="button" onClick={() => { setPlusMenuOpen(false); fileInputRef.current?.click(); }} - disabled={isStreaming} title={t("chatInput.attachFile")} style={attachmentMenuItemStyle} > @@ -2751,7 +2813,6 @@ export const ChatInput = memo(forwardRef(function ChatIn role="menuitem" type="button" onClick={() => void pasteClipboardImage()} - disabled={isStreaming} title={t("chatInput.pasteImage")} style={attachmentMenuItemStyle} > @@ -2952,6 +3013,8 @@ export const ChatInput = memo(forwardRef(function ChatIn
)} +
+ {/* Thinking selector — compact, expressive, and consistent with models */} {onThinkingLevelChange && (
@@ -2973,11 +3036,11 @@ export const ChatInput = memo(forwardRef(function ChatIn onMouseEnter={(e) => { if (!isStreaming) { e.currentTarget.style.background = "var(--bg-hover)"; e.currentTarget.style.color = "var(--text)"; } }} onMouseLeave={(e) => { e.currentTarget.style.background = thinkingDropdownOpen ? "var(--bg-hover)" : "none"; e.currentTarget.style.color = "var(--text-muted)"; }} > - - {thinkingDisplayLabel} + {/* TUI-style level glyph; wide toolbars also show the level + name (label hidden by CSS on narrow toolbars) and the + label always stays in title/aria. */} + + {thinkingDisplayLabel}