The Windows-aware path matching added for project ownership was a second
copy of what the file tree's IPC layer already had — same Windows test, same
containment check, one of them carrying a trailing-slash branch its own
normalisation made unreachable. Both now share lib/path-compare.
On a remote backend a new chat starts in the remembered workspace, and
setCurrentCwd persisted that key on every call — including the six paths
that merely follow a conversation (resume settling, warm switch, stored-row
preview, agent relocation, boot seed, resolved new-chat default). So opening
a chat inside a project quietly made that project the destination for the
next "New session", which is the half of the report the resolver fix does
not reach: a Windows desktop driving a WSL gateway is a remote connection.
setCurrentCwdTransient already meant "move the path, claim nothing" — the
following paths now use it, and setCurrentCwd is reserved for a workspace
the user actually named.
Project ownership compared paths literally, so a nested cwd failed to match
its project whenever the separator or drive-letter case differed — which on
Windows is routine. Normalise both sides for comparison only, folding case
for drive and UNC paths.
resolveNewSessionCwd() inherited the focused chat's workspace, so every
"New session" landed in whatever project you were last looking at — and
after a restart the focused session's stored cwd is often a home-dir
fallback, which shadowed the configured default project dir entirely.
The boot seed had a second failure mode: ensureDefaultWorkspaceCwd() only
seeds while no session is active, and it ran after gateway.connect() — the
same event that un-gates route-resume. On a slow start the resume won and
the seed silently skipped. Seed before connect instead, where no session
can be active yet, and keep both seeds non-fatal.
Three desktop UI tests froze en-US-formatted strings while the
implementation formatters deliberately use the runtime locale
(new Intl.DateTimeFormat(undefined, ...) / Intl.NumberFormat(undefined,
...)) — runtime-locale output is the intended behavior for a localized
UI. On any non-en-US dev machine the tests fail even though the code is
correct:
# zh-CN host:
time.test.ts -> expected '三月' to be 'March'
billing -> Unable to find text 'Threshold: minimum is $10.'
(zh-CN renders USD as 'US$10')
billing -> Unable to find text '$25 added. Balance is refreshing.'
Assert the behavior contract instead of the frozen snapshot, per the
repo's testing guidance (behavior contracts over snapshots):
- time.test.ts: same-year month buckets render via fmtMonth, prior-year
via fmtMonthYear — assert sessionBucketLabel(bucket) equals the shared
formatter's output for bucket.at, with bucket-kind narrowing.
- billing/index.test.tsx: interpolate formatMoney(10) / formatMoney(25)
into the expected strings.
No production code changes.
Verified: zh-CN host 40/40, LANG=C.UTF-8 40/40, tsc clean, eslint clean.
The frost is native vibrancy, which is the window's content view rather than
an element — it fills the whole rectangle and nothing in the page can clip it
to the sheet. That was only ever right while the sheet covered the window;
anywhere it falls short, the difference is frost over empty space. On a fresh
thread the sheet is zero and the difference is the entire window, which is the
grey slab that appears the moment you put the caret in the composer.
Gating the caller's `engaged` was not enough, and is why the first attempt at
this missed: the hook turns the frost on for a focused composer by itself,
independent of what the caller passes. The veto belongs inside, next to that
check.
A fresh thread has nothing to show, but the HUD showed a slab of frosted
glass above the bar anyway. Vibrancy is the window's whole content view, so
it frosts the full rectangle — fine while the band always filled the window,
wrong the moment there is no transcript to fill it. It stays off until there
is something to back.
The sheet had a 12px floor for the same reason: the breathing room above the
first row was added in CSS, so a zero-row transcript still measured 12. It is
folded into the measured height now and only applies when there are rows.
The published tarball ships lib/binding/napi-9-darwin-unknown-arm64 on every
platform, so a real Windows host has both it and the downloaded win32 binding
— the classify-everything gate threw on the darwin dir and killed every
Windows pack. Stage only bindings naming the target platform (classify still
rejects impostors), stop copyGlobByExt from recursing into lib/binding, and
add a version tripwire so a get-windows bump fails the build until the
lib/windows.js rewrite is re-verified.
Also from review: the renderer answers window.read.respond with empty text
when the IPC invoke rejects (older shell / main-side throw) instead of
stalling the tool's 30s timeout; the tool schema discloses that sibling
Hermes windows are skipped; docs gain read_window_below in both references.
The desktop_ui and post-hook ownership contract tests enumerate their tool
sets exactly — add read_window_below to both (plus the executor-path
parametrize case). Lint: sorted type import, explicit GetWindowsModule type
instead of an import() annotation, curly + blank-line style.
get-windows@9.3.0 (MIT, zero runtime deps on macOS/Linux) is external to the
esbuild bundle and staged into dist/node_modules per target platform: the
universal Swift helper on macOS, the prebuilt N-API binding on Windows
(fail-closed magic-byte validation), nothing on Linux (xprop at runtime).
The staged lib/windows.js is rewritten to load the binding directly so
@mapbox/node-pre-gyp's tree stays out of the package.
New electron/window-below.ts: pure z-order picker (walks past our own pid,
first other-process window whose bounds overlap ours) over get-windows'
front-to-back enumeration, with the Linux xprop stacking order reversed to
match (EWMH _NET_CLIENT_LIST_STACKING is bottom-to-top). Main answers the
hermes:window:readBelow IPC; on macOS other apps' titles pass through only
when Screen Recording is already granted — never prompted for.
The window decided nothing was there whenever focus left the composer, which
is exactly what opening a dialog or clicking a link does — a portalled overlay
lives outside the shell, so `:focus-within` goes with it and the window turned
mouse-transparent underneath the thing you had just opened. The old hit test
only knew about the bar's rectangle, too.
It asks the document instead. Everything the HUD deliberately doesn't catch is
already `pointer-events: none`, so whatever comes back under the cursor is
something real, and focus is read at the document rather than the shell.
The band is real macOS vibrancy now rather than backdrop-filter, which
reaches nothing in a transparent window — its backdrop root is the document,
and the desktop was never in it. Vibrancy composites below the web contents,
so it can see the desktop, and it can't be masked or clipped from the page.
That rules out the gradient the band used to carry and settles it as a flat
panel: uniform tint, uniform frost.
The fade does the work the gradient was doing. A landing turn brings the
transcript half way up to be glanced at, focus promotes it to properly
readable, and the hold takes it back down and then away — the panel sliding
behind the bar as the last of the text goes.
It only fades from an idle transcript. A running turn or a question waiting
on you holds it open, because a prompt that fades out is one you can neither
read nor answer, and the hold timer alone would expire through a long tool
call that prints nothing.
HUD mode is a layout choice, so it belongs with the other one. The
keyboard-shortcuts button goes away with it — it was a second door to a
settings tab that the command palette and the keybind itself already open.
Asking for HUD mode from another tab used to just raise whatever the HUD
already had, so the conversation you were looking at never arrived. Main
now retargets the window and tells every renderer where it is pointed, so
the toggle keeps reading "switch" rather than "dismiss".
The salvage's getLatestSessionMessages/getAllSessionMessages split left
two desktop test files mocking the old getSessionMessages name (vi.mock
partial-mock let the real function through, so calls hit the un-mocked
path). Also braces the new single-line if guards per the curly lint
rule.
The remote interceptor rebuilt session/messages requests from pathname
only, silently dropping limit/offset/order. Against a paginating remote
backend, getAllSessionMessages would refetch the same default page until
the safe-load guard threw, breaking export/artifacts/branch for remote
sessions over one page.
Both entry points read $selectedStoredSessionId, which is the WORKSPACE pane's
session — so whichever tile was fronted, the main tab went into the HUD. Tabs
exist precisely so those aren't the same question.
`getActiveComposer()` already answers it for the focus bus, healing to the
visible surface when its cached claim is buried, and a tile's routing key is its
stored session id. One resolver now, shared by the titlebar button and ⌘⇧H.
Coming back re-resumes through the tile delegate when the target is an open
tile. The ordinary resume path enforces "a session is either main or a tile,
never both" and would have closed the tile to take it into main, quietly
rearranging tabs the user opened on purpose.
The sheet is sized to the transcript, so it changed height on every reply with
no transition — the panel twitched to each new size instead of growing into it.
Animated on the reveal timing.
The edge flip was reading the window, which stopped being the same thing as the
HUD once the sheet started sizing itself: a tall window's top edge reaches the
screen top while the visible bar is still well down the display, so it flipped
far too early. It now measures the visible panel, against a threshold
proportional to the display rather than an exact-flush rule — the HUD hugs its
bar, so its visible top can never actually touch the screen edge and a
zero-tolerance test would never fire at all.
Parked at the top the whole gradient flipped with the layout, which inverted the
thing it exists to do. The transcript reads oldest-to-newest downward wherever
the bar is, so the newest turn is at the bottom either way — flipping the mask
faded the newest reply and left the oldest solid, leaving a stray bubble hanging
over the desktop with the reply dissolved beneath it.
Only the anchor flips now: the sheet hangs from the bar instead of standing on
it, the thread's padding moves, and the exit chip drops clear. The fade always
runs away from the newest message.
Four things the band's content-sizing exposed, all of them stale assumptions
from when it was a full-window box.
A leftover `:focus-within` rule still painted the band's own background, so
focusing an empty session filled the whole window with a solid slab even though
the sheet had correctly collapsed to nothing.
The exit chip anchored to the band rather than the sheet, so on a short
transcript it floated in empty space at the window's corner. It now rides the
sheet, and drops clear of the bar when the layout is flipped.
Bottom clearance read `--composer-measured-height`, a surface var that never
reaches here, so it silently fell back to the root estimate and reserved ~20px
more than the bar occupies. The bar's real height is measured alongside the
band's.
Top-edge mode was never re-checked after the sheet started sizing itself: the
sheet stayed pinned to the bottom while the transcript hung from the top. Both
the anchor and the measurement now flip with the layout.
The model pill shed its label at 440px and the row stacked at almost the same
width, so the chevron bought nothing — which is the one thing a progressive
collapse is meant to avoid.
Sized off what the controls actually cost rather than a guess. With the full
pill they take ~284px, so at 440 the inline input was ~156px against a 128px
minimum: a few words wrapped, and wrapping is what stacks the row. Compacting
at 560 sheds the label while the input still has ~276px, and spends the ~110px
the chevron frees on keeping the row single for another stretch.
Global, not HUD-only.
An empty session painted a full-height slab of glass with nothing in it. The
sheet now grows up from the bar to fit the transcript and caps at the window, so
a fresh chat is just the bar.
Measured from the topmost row down to the bar and written straight to the
element — it changes on every stream flush, and the sheet resizing must not
re-render the tree.
The renderer half. HUD mode reuses the app's own chat surface and only changes
the frame around it: no titlebar, no statusbar, no pane tree. The band and the
bar tile the window between them with no dead margins, and the band runs the
window's full height with the opaque bar sitting on its bottom edge, so there
is no seam to compute and none to drift as the composer grows.
Visibility is a WoW chat frame: the transcript shows while a turn is recent or
the composer has focus, then holds and fades. A bottom-anchored gradient mask
carries the fade on both the sheet and the text, and flattens away on focus so
nothing is dimmed while you are reading. Only the composer never fades — it is
the interface.
The window is mouse-transparent everywhere it isn't really there, so clicks over
the faded band reach the app behind it; `pointer-events` can't do this, since
the click never reaches the page at all.
A transparent, frameless, always-on-top window that renders the real chat
surface, so its composer is the app's composer rather than a lookalike that
drifts. Main owns the window, its remembered geometry, and click-through.
Leaving is a handoff, not a window close. The gateway binds a session's event
stream to exactly one socket, so a turn started in the HUD streams only there
and the app window hears nothing — no deltas, no turn-complete, no draft clear,
and nothing to poll for mid-turn. So the app re-resumes the session the HUD
ended on, which rebinds the transport, reconciles the transcript, and picks up
an in-flight turn. Main carries the session id across, since it is the only
party that outlives the HUD's renderer.
Drafts are per-renderer state backed by shared localStorage, and the map is
read once at module load, so two windows on the same session diverge the
moment either types. Adds the two verbs a handoff needs: `reloadPersistedDrafts`
to merge another window's writes in (keeping local attachments, which are never
persisted), and a draft-sync bus so a composer can be told to flush its live
text down to the stash or repaint from it.
Dispatched synchronously, unlike the focus bus — a flush has to complete before
the window that will read it is created.
Resuming a session that is mid-turn somewhere else collapsed the thread down
to the in-flight prompt, and the user's own message never appeared at all
until a reload.
Two wrong premises in the resume path, neither specific to any one surface:
`omit_messages` was read as "empty transcript" rather than "no transcript in
this response". Desktop asks session.resume/activate to omit messages because
REST is the transcript authority, so mid-turn the live projection got
reconciled against an empty list and rebuilt the thread out of itself. The
response already carries `messages_omitted`; nothing read it. It now grafts
the projection onto the cache (or the REST prefetch) instead.
The settle path skipped hydration whenever the window had streamed the reply,
on the assumption that streaming a reply means owning the whole turn. True for
a turn you started, false for one you adopted: it arrives reply-first with no
prompt row, and nothing ever backfilled it. Sessions now carry
`adoptedRunningTurn`, set when a resume lands on an already-running turn and
consumed when it settles.
Table-driven across every session-scoped RPC that can hit a dead runtime
id, plus the invariants the scattered copies disagreed on: resume targets
the session-owning profile (never forks into the active one), the recovered
id publishes exactly once, drift during the resume aborts instead of
retrying, a resume that itself 404s rethrows the ORIGINAL error, and
recovery is bounded to a single retry.
These run without touching the REST layer because profile resolution is
injected — the pre-consolidation helper reached through resolveStoredSession
-> getSession(), which made its coverage depend on store state left behind
by whichever test file ran first (passed alone, timed out at 15s alongside
index.test.tsx).
Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
Co-authored-by: rapsealk <rapsealk@users.noreply.github.com>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Same bug class, remaining call paths. runRewindSubmit handled only
"session busy" — a rewind runs right after cancelRun, and interrupting can
drop the gateway's session, so "Restore checkpoint" after a stop hit a dead
runtime id and surfaced the raw error. The session-tile delegate's
interrupt/submit had no recovery either, so a tile left open across sleep
was dead until reopened.
Both now use the shared resolver. The tile delegate resolves its durable id
by reversing the stored->runtime cache and repoints that mapping on
recovery, so later tile actions use the live binding instead of recovering
on every call.
Co-authored-by: JonthanaHanh <JonthanaHanh@users.noreply.github.com>
Co-authored-by: akivavh <akivavh@users.noreply.github.com>
After sleep/wake, a long idle, or a remote backend restart, the gateway
drops its in-memory runtime session while the desktop still holds the old
id. prompt.submit already recovered, so plain text kept working and the
failure looked selective: attaching an image or running /compress died
with a bare "session not found" and a new chat was the only workaround.
Attach runs BEFORE prompt.submit, so submit's recovery never got a
chance — image.attach_bytes / image.attach / file.attach failed first.
Route them (and session.compress) through the shared resolver, and thread
the recovered id back to the caller so the follow-up submit targets the
live session instead of the dead one.
Attachment bytes are read once, outside the retry, so recovering a large
upload doesn't re-read it. /compress deliberately does NOT opt into
timeout recovery: it is legitimately LLM-slow and retrying a timeout
would double a minutes-long call.
Reported-by: bapemonkey (Discord)
Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
Co-authored-by: zzz163519 <zzz163519@users.noreply.github.com>
Co-authored-by: luxles <luxles@users.noreply.github.com>
main carried three hand-rolled copies of the same recovery policy —
prompt.submit (submit.ts), session.interrupt (cancelRun) and
session.redirect (steering) each open-coded
isSessionNotFoundError -> resolveSessionProfile -> session.resume ->
retry. Three copies of one policy is how call sites drift apart, and it
is why the RPCs that were never given a copy (attach, /compress,
checkpoint restore) still surface a raw "session not found" after
sleep/wake while plain text silently recovers.
Introduce withSessionNotFoundResume() as the single resolver and move
all three existing copies onto it. Profile resolution is injected rather
than imported so the helper is unit-testable without reaching through
resolveStoredSession -> getSession(); a drift callback lets each caller
keep its own abort semantics via SessionRecoveryAborted.
Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
Eight cases across the whole class, not just the reported path:
- cold resume rebinds from the selected row before resume settles
- an empty runtime cwd releases ownership (the permanent-staleness half)
- releasing leaves the PATH intact, so panes don't collapse and the persisted
workspace survives
- a session row outside the loaded sidebar page doesn't blank the pane
- a non-git workspace with a null git_repo_root still uses its row cwd
- the branch label clears so the previous project doesn't leak
Verified as a real barrier: reverting utils.ts fails 5 of these.
Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Co-authored-by: ZHJay <ZHJay@users.noreply.github.com>
Co-authored-by: worlldz <worlldz@users.noreply.github.com>
`session.info` claimed the cwd for whatever the selection happened to be, so a
background tile's payload could re-point a fresh draft at the tile's workspace.
Treat a nonempty `stored_session_id` as non-matching when no primary session is
selected; only an ABSENT id uses the selected-session fallback (the backend
omits it on a lazy session, and refusing there would leave the workspace
un-owned for the rest of the conversation).
Matching goes through the lineage rather than raw string equality: the backend
id is the live session_key, which auto-compression rotates to the continuation
tip, while a selection made from a pinned row holds the stable lineage root.
Comparing those literally reads one conversation as two.
Co-authored-by: ZHJay <ZHJay@users.noreply.github.com>
Two defects left the Files pane showing the previous project's tree:
- `applyStoredSessionPreviewRuntimeInfo` reset every composer atom EXCEPT cwd,
and runs before the `session.resume` RPC. The sidebar row already knows the
conversation's workspace (`cwd` is in the compact row projection), so mirror
it on the same tick the selection changes.
- `if (info.cwd)` was truthy-only, so a detached session reporting `cwd: ''`
never cleared and the pane stayed pinned to the last project for the rest of
the session — the "not always" in the report. Empty is now authoritative.
Empty routes through ownership release rather than a persisted `''`:
`setCurrentCwd` writes to localStorage and seeds `$currentCwd` on next boot, so
blanking would also wipe the remembered workspace.
Only `cwd` is consulted, never `git_repo_root` — the latter is documented null
for non-git workspaces and not-yet-backfilled rows, so falling back to it reads
as "no workspace" and blanks a pane that was correct. A session outside the
loaded sidebar page (no row at all) releases ownership instead of blanking, for
the same reason.
Also claims ownership on the warm-cache path (its missing-RPC compat branch
returns before `applyRuntimeInfo`) and for a center tile, whose Project "+"
create left the right rail on the previous session's folder.
Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Co-authored-by: worlldz <worlldz@users.noreply.github.com>
Co-authored-by: ZHJay <ZHJay@users.noreply.github.com>
`$currentCwd` is a global singleton, but a conversation switch publishes the
new stored session id immediately while the new workspace only arrives when
`session.resume` settles. For that whole window the path still names the
PREVIOUS conversation, and every workspace-derived surface treats it as
authoritative.
Track WHICH conversation the live path describes instead of trying to keep the
path itself in lockstep. Ownership — not emptiness — is what makes the switch
atomic: clearing the path would collapse the workspace/review panes and drop
file-tree state on every switch, so the path stays put and is simply marked
not-yet-owned.
The released marker is deliberately not `null`: `null` MATCHES a fresh draft
(whose selected id is also null), so releasing to it would hand a leftover path
to the draft as its own workspace.
Co-authored-by: ZHJay <ZHJay@users.noreply.github.com>