* fix(ui): make timeline dialog fit small screens
On phones the timeline dialog squeezed the message list to a couple of
rows: header, search box, and the fixed Actions/help block consumed
almost all of the 70vh dialog height, and the description text could end
up underneath the search box.
The dialog no longer scrolls as a whole. The header and search stay
fixed (shrink-0), the message list is the only scrollable region and
takes the remaining height (min-h-0 flex-1), the dialog is taller on
small screens (85dvh), the Actions/help footer collapses to a single
action row on phones, and the description is hidden on phones only.
Message timestamps no longer wrap either: the time column grows from a
fixed 64px minimum and forces nowrap, so "11:14 PM" stays on one line.
* fix(ui): keep dialog-level scroll fallback in timeline
overflow-y-visible removed the reusable dialog's built-in scrolling
fallback: the timeline's header, search, and footer are all shrink-0,
so under constrained layouts (very short desktop windows, enlarged
text) the fixed controls could leave the popup with no way to reach
them once the message list collapsed to zero.
Revert to overflow-y-auto. The list is still the only flex-1 min-h-0
child and absorbs space first, so normal mobile and desktop layouts do
not scroll; only when fixed content exceeds the dialog height does the
popup scroll again, keeping every control reachable.
* fix(ui): open app deep links from chat after confirmation
DOMPurify's default URI policy stripped href from anchors with custom
application schemes (obsidian://, vscode://, ...), so every app link
rendered in chat was dead across web, desktop, VS Code, and mobile.
- Classify safe app-link schemes in lib/url.ts (browser-handled,
scriptable, webview-internal, network, and self-deep-link schemes
stay excluded) and let openExternalUrl accept them
- Keep app-link hrefs through the markdown sanitize hook
- Intercept app-link clicks in the markdown renderer and route them
through a confirmation dialog (Trust and open / Open once, dismiss
to cancel) mounted in the desktop/web app root and the mobile shell
- Persist per-device trusted schemes in a zustand store; trusted
schemes open without asking again
* feat(settings): manage trusted app link schemes in General
Add an App links section to Settings > General listing the application
schemes trusted on this device with a delete action; removing a scheme
restores the confirmation dialog for it. Register the section in
settings search.
* fix(ui): enforce app link confirmation
* fix(ui): handle app links by runtime
* fix(vscode): keep app links unsupported
* fix(settings): clarify trusted app links
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
The streaming status line and the finished turn footer are the same visual
line, but the swap used to jump: different font/color, a 2px left inset, a
20px row vs the footer's 32px (its h-8 action buttons set the height), and
the bottom-anchored chat pulling the line up because the finished message
carries more structure below its footer than the status row had.
Match the footer exactly: text-sm at muted-foreground/60, no left inset,
h-8 row, and mb-6 reserving the missing space below. Verified against the
live DOM: the footer appears at the exact pixel position the status row
occupied.
* feat(chat): /btw command — side questions in a temporary forked session
/btw <question> forks the current session (full context inheritance) and
opens a compact peek panel docked above the composer. The composer itself
becomes the btw input while the panel is open: sends route to the fork,
the placeholder and a mode chip reflect the target, and the stop button
aborts the fork's turn. Closing the panel (or the chip's ✕) destroys the
fork, leaving the main conversation untouched.
The panel shows only the fork's own tail (messages at/after the fork
creation time) and live permission/question cards scoped to the fork.
- chat/btw/BtwPanel: peek sheet (desktop + mobile), fork-tail view,
auto-close on disappearance, Esc to close
- lib/btw: startBtwSession (fork + rename + routed send), closeBtwPanel
(close = destroy), filterBtwTailMessages
- ChatInput: btw-mode send routing via SendMessageOptions.sessionId,
btw-aware activity (stop/abort), placeholder + mode chip
- useSessionActivity: exported for per-session activity reads
- i18n: btw keys across all 11 locales
* fix(chat): keep btw sends isolated
* refactor(chat): rework /btw into a metadata-scoped peek panel
- Link the active btw fork through the parent session's metadata
(openchamber.btwSessionID) so the panel exists only in the session that
invoked /btw, follows parent navigation, and survives reloads; the fork
carries a kind:'btw' marker with its originalSessionID.
- Replace the wall-clock history boundary with the id of the newest cloned
message (server-generated ascending ids), stored in fork metadata.
- Derive panel identity in useBtwPanelState; useBtwStore shrinks to
transient per-parent UI state (collapsed/creating/destroying).
- Panel UX: dropdown-style glass surface, chat ScrollShadow, single
title+chevron collapse toggle, muted header controls, promote action
(keep as a full session and navigate to it), Esc collapses instead of
destroying, reserved Working indicator row, streaming auto-follow via
ResizeObserver keyed on content readiness.
- Add a 'peek' chat surface mode that suppresses per-message controls and
turn footers inside the panel; user bubbles keep a small gap below.
- Hide btw forks from the sidebar, session switcher, and command palette
until promoted; mark the fork before inserting it into local stores.
- Delete/archive lifecycle: removing the fork unlinks the parent; removing
the parent also removes its temporary fork.
- patchSessionMetadata now mirrors updated sessions into live stores.
- Localize new strings across all 12 dictionaries; add unit tests for
metadata helpers, the btw flow, and the UI store.
* fix(chat): clamp the btw panel below the app header when the keyboard is open
Reuse useMobileAutocompleteMaxHeight (the composer autocomplete precedent)
on the panel's scroll body, reserving the panel header and bottom spacer
height, so the sheet adapts to the visual viewport instead of riding under
the app header on mobile.
* fix(lint): drop unused destructured bindings in sessionBtwMetadata
CI eslint has no underscore ignore pattern; strip metadata keys with typed
copies and delete instead of discard-destructuring.
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
Neither failure came from the change that surfaced them; both suites were
asserting behavior the product had already moved.
issue-2039 mocks `session-actions`, and session creation's authoritative
steps live there: the created session becomes current under the directory
the server confirmed, and that directory enters sync's routing index. The
mock did neither, so every assertion about current session and directory
routing read an empty store. It now performs both, which is what makes
those assertions test the store's real resolution order again.
issue-2903 pinned `<ChatEmptyState />` inside ChatContainer's empty-and-idle
branch. The draft transition animation moved that surface to the draft, and
the branch now returns a neutral placeholder. What the test is actually for
— the idle branch not rendering a status row of its own — is kept.
The visibility hook looked the context panel up under the directory the
card reports about. A managed Chat reports about none, so the lookup ran
against an empty key and answered "closed" while the panel was open.
It now resolves the key the way the rail and the panel do, through
useEffectiveDirectory. The reporting directory is no longer an input to
this hook at all.
Plain Enter now inserts a newline in the expanded composer
Cmd/Ctrl+Enter still sends the message
Helps avoid accidental sends while writing long prompts
Truncation was measured once on mount and watched through a ResizeObserver
on the clamped element, whose own height stops changing under line-clamp.
A message measured before its content settled never regained the expand
affordance, and a just-sent one measured before its turn finished laying
out could not be expanded at all.
Measure at click time so expanding depends on whether the text is clipped
right now, re-read once on the next frame after mount, and observe the
children (which keep their natural height) plus subtree swaps.
Preserve layout with invisible placeholders while worker highlighting loads
Reuse cached highlighted lines on first render when available
Differentiate loading from failed highlighting results for better fallback behavior
The server echo of an optimistic user message carries different part
ids, so the reducer dropped the optimistic part and appended the server
one at the end, and MessageBody keyed user parts by part id. The key
change remounted the text subtree (blank frame, markdown re-parse,
truncation state reset) and the append reordered text against file
parts.
Replace the optimistic part in place and key user parts positionally.
Prevent managed Chat drafts and sessions from inheriting an active project's repository context in the Work Status panel while retaining directory-independent status sections.
* test: add regression test for stat directory scoping (#3019)
* fix: scope file-reference stat probes to the session directory (#3019)
The /api/fs/stat probe sent by the markdown renderer carried no
directory hint, so the server resolved the workspace from
settings.lastDirectory. When the browsed directory differs from the
session directory the stat returns 400 and the renderer treats it as
file-does-not-exist, silently disabling file-reference links.
Send effectiveDirectory as x-opencode-directory on the probe and
qualify the stat cache key by directory so a rejection under one
directory cannot leak into another. The probe logic moves from
MarkdownRendererImpl into fileReferenceStat with unit coverage.
Create projectless chat sessions under a managed, date-scoped Chats directory and clean abandoned or deleted session folders.
Add Chats to sidebar state, startup cache, shared context, and Electron Mini Chat while keeping VS Code project-only. Resolve managed chat directories to one server-side memory owner and document the runtime contracts.
The panel stored notes, todos and plans inside one shared JSON file that
six unrelated domains also wrote to, synchronised itself through window
CustomEvents, and could only read plans. It is now Project knowledge:
server-owned storage with explicit routes, a store with rollback, a
section sidebar, plans that open and edit in place, and search across
all of it.
Notes and plans the user pins travel with every message sent in that
project. Pinning is project state, not an attachment to one message, so
it holds until unpinned and the work status panel names what is riding
along and can detach it.
Agent memory is added alongside, in two scopes: what is true about the
user, and what is true about this codebase. The split is not cosmetic —
a wrong project fact costs one project and is noticed, while a wrong
global fact quietly shapes every session everywhere and the user has no
code to check it against. It stays separate from notes so an agent
mistake cannot land in what the user wrote. Sessions receive an index of
titles only; bodies are read on demand, because an index carrying full
text grows until it crowds out the conversation.
Deciding what a session must be told, and whether it has been told, now
lives on the server. The client owned it before, which meant sessions
started without a UI — scheduled tasks, sessions the agent dispatches —
received nothing at all, and a tab's record of what it had sent outlived
the conversation: after compaction the agent no longer held the block
while the tab went on believing it did. What was delivered is recorded
in the session's own metadata, and compaction restores it through the
runtime that already restores pinned messages, in the same turn.
Agent memory ships dark behind OPENCHAMBER_MEMORY_ENABLE: unset, there
is no tool, no routes, no session index, no settings row and no panel
tab. Absent rather than switched off, so nothing invites turning on a
feature that has not been announced. Pinned notes and plans are
unaffected and ship as normal.
Review follow-up on the #2769 highlight caches.
Fingerprint strength. The block/highlight caches are now global and
content-addressed, so a hash collision no longer mis-colors a block — it returns
a *different* block's rendered HTML and shows the user source they never wrote.
Length + one 32-bit FNV-1a is not enough key space for that failure mode at
session scale. `contentFingerprint` now combines two independent 32-bit
multiplicative hashes with a final avalanche (~64 bits); two multiplies per
character are free next to Shiki tokenization.
Streaming churn. Content addressing made every streaming step of the trailing
`live` block insert a new cache entry, so one long message evicted the settled
`full` blocks the fix exists to keep warm. `full` and `live` blocks now use
separate caches; the live cache is small (32 entries / 2MB) because it only has
to absorb repeat renders of the same step.
Redundant worker-side caches. `markdown-worker.ts` is the only sender to the
Shiki worker, and its client cache is larger than the worker-side ones, so the
worker caches could not serve a hit the client had not already served — they
only duplicated up to 48MB of payloads in a second heap. Removed; the reason
memoization belongs on the client is now documented there, along with why only
`highlightTokens` carries a theme in its key.
Dead `cacheKey` plumbing. `renderMarkdownBlocks` kept a `cacheKey` parameter it
only `void`-ed. Removed it and the now-unused `useMorphdomMarkdown` prop; the
remaining call-site local is renamed `fadeKey` for what it actually keys.
Tests: image-mode cache identity, streaming-does-not-evict-settled-blocks,
live-cache reuse, and a 20k same-length-source fingerprint collision check.
Each new guard was verified to fail without its fix.
Conflict: packages/ui/src/components/chat/markdown/markdownCore.ts
main added per-image-mode markdown parsers (`imageMode` threaded through
`parseBlock` and into the block cache key); this branch replaced the
identity-keyed block cache with a content-addressed LRU. Resolution keeps the
content-addressed cache and folds `imageMode` into the content key, so the
`inline` and `label` renderings of the same source cannot answer for each other.
* fix(chat): defer composer value writeback during IME composition
The controlled-writeback effect compared the value prop against the
CodeMirror document and, on mismatch, dispatched a wholesale replacement
with the caret forced to the end. While the browser composes (pinyin,
kana, hangul) the uncommitted text lives in the DOM, not in the document,
so the mismatch is expected and the dispatch interrupted the IME session
and jumped the cursor. Skip the writeback while the view is composing,
using CodeMirror's public compositionStarted getter; the composition
commits through its own pipeline and reports via onChange.
Fixes#2527
* fix(chat): preserve external composer writes during IME
* fix(chat): restore composition-wide writeback guard
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
The token breakdown of an assistant message accumulates across every API
round-trip inside the turn: each tool call re-reads the whole cached
prompt, so input/cache.read add up to several times the context window.
Every context-usage surface summed those fields, which is why the meter
could read 330% of a 1M window whose real fill was 232,872 tokens
(23.3%), and why reopening an older session jumps the readout (#2562).
The server reports the final round-trip's window as tokens.total
(optional in the message schema; opencode 1.18.18 returns it, verified
against its live /session/:id/message API). Prefer it everywhere the
window fill is displayed and fall back to summing only when the server
did not send it: contextTokensFromBreakdown in tokenUtils now owns that
rule, and the context store extractor, sync store getter, work status
panel, context sidebar, VS Code layout, mini chat, and mobile metadata
all use it instead of their own inline sums.
Fixes#2562
Keep session-message loads and retries on messagesEnabled so a mounted
session-chat panel can materialize history even before the visibility
handshake, and cover the enabled-gate with the real hook.
Co-authored-by: serkraser <serkraser@gmail.com>
The helper reimplemented `if (!enabled) return []` locally, so those
cases never exercised the real hook. Keep the snapshot-builder and
source-contract coverage instead.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
Replace the fake enabled-gate helper's store fixture with
buildSessionMessageRecordsSnapshot so the regression covers the same
record shape ChatContainer renders.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
Busy context-panel session chats could render only the working-status row
when the iframe booted inactive or lost its visibility handshake, because
message reads shared the composer/background-work gate. Keep message
subscriptions enabled in the mounted session-chat panel so materialized
history remains visible (#2903, #2892).
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
The opencode-claude integration registers its provider as `claude-code`, which
never matched the `claude` quota provider, so the collapsed Usage section in
the work status panel showed no limit for a model from that integration.
Claude windows also reported no duration, leaving the headline to fall back to
whichever row came first instead of the limit that runs out soonest. The
session and weekly windows now carry their length; extra usage stays without
one because it is a monthly spend cap.
The preview panel worked by proxying a dev server through OpenChamber's own
origin and rewriting the HTML that came back. Anything the rewriter did not
anticipate broke, and pages that refuse to be embedded never loaded at all.
This deletes the proxy (-1604 lines and its tests) and merges the preview and
browser panels into one surface backed by a real Chromium view.
What the panel is now
- A `<webview>` in its own session partition: logins and cookies persist, hot
reload works because nothing is rewritten, DevTools are one click away.
- Annotation: pick one element, drag a region, or draw freehand, write a note,
and it reaches chat with a screenshot of the visible page with the marks on it.
- Toolbar: hard reload, page zoom, device sizes, a light/dark switch that
applies to the page rather than the app, and cookie/cache clearing scoped to
the panel alone.
- Several pages at once, each tab showing the page's own favicon, and an address
bar that suggests pages already visited in this project.
- Dev servers are listed from what is actually listening on the machine, checked
against what a project announced, so a server is offered no matter how it was
started. One that is still starting is waited for instead of failing.
Remote dev servers
The desktop app binds a local port and pipes raw bytes to the OpenChamber host
over the existing authenticated connection, so the page keeps its own origin at
the root of its own host. The reachable set is exactly what discovery reports
and is re-checked per connection, so an authenticated client cannot dial
arbitrary local services on the host. Links and redirects to another loopback
port stay on the machine that served the page. A tunnel that cannot be opened is
reported; it is never replaced by the plain loopback URL, which would answer
from the user's own machine under a remote address.
Agent control
Browser actions are a separate `openchamber_web` tool: open, snapshot, click,
type, scroll, inspect computed styles, resize between mobile/tablet/desktop, and
capture a screenshot into `.openchamber/screenshots/` in the project. The
existing `openchamber` tool keeps sessions, worktrees and scheduled tasks. Each
has its own setting in the new Settings -> General -> OpenChamber Tools section,
and the plugin is not injected at all when both are off.
Capability belongs to the connected client, not to configuration: a client
declares on its event stream that it can drive a page, which only a Chromium
host does. Exactly one client performs each request — it claims the request
before acting, and the first claim wins — because deciding by whose result
arrives first would be too late for a click that already happened. No client
listening is answered immediately with an explanation rather than a timeout.
Runtime boundaries
Web tabs get a plain iframe that can display a page but not inspect one. The
VS Code extension no longer offers the surface at all, since nothing that makes
the panel worth having works there. Mobile is unaffected.
Native boundary
Camera, microphone, location and device-picker requests from panel pages are
denied — Electron grants them by default when no handler is set, and the panel
loads whatever address the user types. Page capture, appearance emulation and
storage clearing verify that their target belongs to the panel's own session
instead of trusting a web-contents id from the renderer.
Persisted state
Stored `preview` tabs migrate to `browser` (v13 -> v14). Context panel tab
limits are now per surface, so filling one surface no longer evicts another's
tabs. Address history is stored per project and per runtime.
Documentation
`preview.mdx` and `desktop-browser.mdx` rewritten across all locales, the agent
tool settings path corrected, new `DOCUMENTATION.md` for the browser-control
broker and the dev tunnel, and the `ui-api-decoupling` skill updated where it
still described the deleted proxy.
Pass initial subagent prompting settings into embedded chat URLs and views
Prevent inactive embedded chats from stealing focus on load
Treat review sessions as independent conversations when resolving prompt read-only state
* chore: remove verified dead declarations
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* chore: narrow unused internal exports
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* chore: remove newly exposed dead helpers
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* chore: remove unused deep-link serializer
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* test: drop two tests that assert on copies of the code
mainLayoutMobileSidebarMount read MainLayout.tsx and SessionSidebar.tsx as
strings and asserted on source substrings down to exact indentation, so it
failed on formatting rather than behaviour. useProjectSessionSelection.test
reimplemented the hook's visitNodes logic inside the test file and asserted
against that copy, so it could not observe the hook at all.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* test: repair sync suites that had rotted while unrunnable
No runner executed packages/ui, so these drifted from the source unnoticed:
two imported helpers that are no longer exported, one directory-store stub
predated the session field routeMessage reads, and the WebSocket fake missed
the mandatory url-token mint plus the close event the socket wrapper reads.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* test: stop the web suite failing on timeouts and a hand-copied mock
The Git suites drive a real git binary, so the 5s default made a valid suite
fail differently per run. The gitApiHttp mock listed ~70 export names by hand
and fell behind the source; it now derives every stub from the real module,
which the added shared-UI aliases make resolvable.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* test: run every suite from one command and in CI
packages/ui (232 files) and packages/vscode (22) had no test script at all, CI
ran neither, and 9 vscode files could never run because Node cannot resolve
their extensionless TypeScript imports. Three electron files sat outside every
script list, one of them importing vitest, which that package does not depend
on. A runner gives each file its own process, since these suites keep
module-level singletons and fail by load order when sharing one.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* chore: delete a superseded repro harness and a completed plan
The issue-2638 harness needed lsof, overrode process.platform and spawned real
servers, and nothing referenced it; event-stream/rebind.test.js now covers the
same hub-pinned-to-the-old-port behaviour. The pairing v2 plan described relay
and the pairing UI as out of scope, both of which shipped.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* docs: point at the theme tools and record the github barrel invariant
convert-vscode-theme and harmonize-theme were referenced nowhere, so the
theme-authoring reference now names them. The github barrel is loaded through
await import('./index.js') and destructured per route, which no static report
can see; documenting that is what stops the next cleanup from deleting it.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* test: repair merge drift in bridge and route-registry mocks
upstream/main gained upsertProviderConfig on bridge-system-runtime and a
PATCH scheduled-task route after this branch forked. Their test doubles
were never updated to match:
- bridge-system-runtime.test.js: add upsertProviderConfig to the
opencodeConfig mock so the import resolves.
- sse-routes.test.js: add app.patch to the route registry stub.
---------
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>