[pull] main from xintaofei:main - #216
Open
pull[bot] wants to merge 80 commits into
Open
Conversation
The in-position image card always said Image generation, even for a Read of a screenshot or a fetched docs page. Codex generation still uses that label. Everything else uses the tool title, URL slug, or filename.
A task in review reported a change set it had not produced, so the board offered the wrong acceptance. Two causes, both in what the counters were measured from. A task triggered from a pull request is checked out ON that pull request's head while its recorded base is the merge base, so the whole contribution sat in the worktree before the agent started. Counters and the landable-changes probes now measure from the point the worktree was checked out at — which is also the point an acceptance delivers from: the pull request's head there, the recorded base otherwise, advanced to the live merge base when the branch has absorbed base content. The drawer keeps the merge-base view and says when it holds the pull request's own changes. Nothing makes an agent commit before its task reaches review, and `git diff` enumerates paths from the index, so work left in new, never-added files read as no work at all. The measure now runs against a throwaway index — a copy of the worktree's own, plus intent-to-add entries — so counters, the acceptance probes, the drawer's list and its patch all see uncommitted work, ignored artifacts stay out, and the real index is never touched. Also: a push back carrying nothing is refused in favour of completing; a boot pass re-measures every task still in review, since counters are written once at settle; and the engine subscribes to the event bus before that boot work, so a task started under it cannot lose its TurnComplete and hang in `running`.
The clone dialog built its preview as `${targetDir}/${repoName}`, so a
Windows target rendered `C:\work/codeg` — a native prefix with a stray
forward slash bolted on. Seven more sites shared that shape or its
mirror image (a `/`-only split that never finds a segment boundary in a
backslash path):
- clone dialog: the preview and the path actually cloned into
- project-boot "project will be created at" hints (shadcn, hyperframes)
- skills settings "skills directory" draft hint
- branch dropdown's prefilled worktree path, which came out as the bare
relative "/C:\work\repo-main-abc123" because `lastIndexOf("/")` is -1
- tool-call titles, which showed a whole absolute path instead of its
last two segments
- the working-diff overview tab, titled after the folder path
- automation's per-run worktree, whose sibling path degraded to a
relative name git would have planted inside the repo
Route the joins through the existing `joinFsPath`, which follows the
base path's own separator, and add `fsSeparator` / `fsBaseName` /
`siblingFsPath` beside it for the basename and sibling cases.
Roots need their own handling: `C:\`, `/`, and `\\server\share` have no
parent to hang a sibling off, so the derived name lands inside the root
instead, keeping the result absolute. A relative one would make git
resolve a worktree inside the repository and then register that
unresolved string as the folder's working directory. `basename` reports
no name at a root rather than feeding a colon into a directory name.
(cherry picked from commit 9810b1a)
Delivering a forge-sourced task — opening its pull request, or pushing back onto the one it came from — was the only acceptance that could not take its checkout with it, leaving a worktree the user had to find on the card and remove by hand. Both shapes of the delivery dialog now offer the same checkbox the merge and complete dialogs do, seeded from the folder's `delete_worktree_default`. The cleanup rides on the delivery rather than gating it: it runs only after the settle, and a removal that fails flags a retryable `cleanup_state` rather than turning a pull request that was already pushed into a reported failure. Two probes decide whether the checkout may go, because each is blind to exactly what the other sees: uncommitted files, which reached no forge, and a branch tip that outran the OID the delivery published — a commit made in that window leaves `git status` spotless and is just as unpublished. `has_landable_changes` cannot serve as the second probe here, being true of every delivery by construction. The tip then rides into the removal itself, so `update-ref -d <ref> <oid>` compares and deletes in one operation instead of leaving a window between the check and `branch -D`; every local-merge caller passes `None` and is unchanged. (cherry picked from commit 1bb6909)
The helper that drives git in these fixtures neuters the global and system config, so a run cannot depend on whatever the developer's own git happens to say. The code under test spawns its own git through `crate::process`, which inherits the real environment — and Git for Windows ships `core.autocrlf=true` in its system config. One worktree then means two different things: the engine checks a file out as CRLF while a `git add` from the helper stores those bytes verbatim, leaving the index naming blobs the commit it was checked out from never had. A diff measured from that index counts files nobody touched. It cost a pull-request task's own-work stat exactly that — every file in the tree where only the one the agent added belonged, which is the class of answer that measure exists to prevent. Repo-local config is the one layer both sides read, so `init` and `clone` now pin `core.autocrlf=false` on the repository they create. It rides in the helper rather than at each fixture: a new fixture has no reason to suspect this, and the answer only ever comes out wrong on Windows.
## Why
A user can put a reverse proxy in front of a codeg-server. The proxy can
require extra HTTP headers, for example a Cloudflare Access service token.
Codeg could not send those headers, so the connection failed.
## What
Each remote workspace connection can now carry custom HTTP headers. The
desktop client attaches them to every request it sends to that connection.
The manage dialog gets a "Custom headers" section below the access token.
The section stays collapsed unless the connection already has headers. Each
row holds a name and a value. Values are masked, because a header usually
carries a credential.
## How
- A new migration adds a `headers` column. It stores a JSON array of
`{"name","value"}` objects in a TEXT column, which follows the convention
of the other JSON blobs in this schema. Existing rows default to `[]`.
- An array, not a map: HTTP allows a repeated header name, and the array
keeps the order the user typed.
- `RemoteWorkspaceHeader` lives in `models/`. It converts to an
`http` pair through `to_header_pair`, and a slice of rows converts to a
`HeaderMap` through the `ToHeaderMap` trait.
- The service validates each row before it writes: it drops the blank rows
the editor leaves, rejects a value with no name, rejects a name or value
that `http` cannot parse, and rejects 10 reserved names. The client sets
those names itself, so a custom one would break the request. The save
fails rather than silently drop what the user typed.
- `commands/remote_proxy.rs` attaches the headers at all five request
sites: the HTTP call, the attachment upload, the workspace file upload,
the download ticket, and the ticket download. The last one sends no
bearer token, because the ticket in the URL is the credential — but a
fronting proxy still inspects it, so it needs the headers too.
- The WebSocket handshake carries the same headers.
- The health check sends the headers as well, so the test and the save
exercise the same configuration as a later request.
## Tests
Rust, in `remote_workspace_connection_service`:
- `validate_headers_trims_and_drops_empty_rows`
- `validate_headers_keeps_repeated_names_and_order`
- `validate_headers_rejects_reserved_names`
- `validate_headers_rejects_invalid_name_value_and_missing_name`
- `to_header_map_keeps_repeats_and_skips_unparsable_rows`
- `create_list_update_delete_roundtrip`, extended to store and read back
the headers
Frontend, in the new `remote-workspace-manage-dialog.test.tsx`:
- `keeps the editor collapsed when the connection has no headers`
- `opens the editor when the connection already has headers`
- `masks every header value`
- `sends the added header on save and drops a removed one`
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mark every custom header value sensitive, so it stays out of the HTTP/2
HPACK dynamic table and out of any `{:?}` of the request, the same way
reqwest treats its own bearer token.
Reserve `transfer-encoding` next to `content-length`: hyper frames the
body itself and picks chunked for the streaming workspace upload, and it
is the header a request-smuggling attempt rides in on — the one thing not
to hand to a fronting proxy. The reserved list is a slice now, so its
length is no longer a second thing to remember to bump.
Cover the upgrade path the migration exists for: a row written before the
`headers` column has to read back as `[]`, not as a NULL that fails to
deserialize and takes the whole connection list with it.
The pill still clipped into the file path bar when a selection was anchored on the second visible line: one line of room (20px on macOS, 18px on Windows) is less than the pill's 25px box, so ABOVE was kept and overflowed the editor's top edge by 5-7px. Replace the first-visible-line proxy with the test Monaco itself applies to non-overflowing widgets — `fitsAbove = anchor.top >= height` — fed with `getTopForPosition() - getScrollTop()` (Monaco's own `anchor.top`) and the pill's measured height. This also makes the decision exact under word wrap and fractional scroll offsets, where a model-line comparison is only an approximation. Monaco keeps the widget node at `display: none` until after it asks for a position, so cache the last real measurement and re-lay out once when the first-ever show finally yields one.
…host The custom headers are credentials, and two paths let the remote decide who receives them. `absolute_remote_ticket_url` returned a server-supplied absolute URL verbatim, and the download that follows it is the one request carrying the headers with no bearer token gating them. `codeg-server` only ever answers with a path, so pin the resolved URL to the connection's origin and refuse the rest. `reqwest` drops `Authorization` once a redirect crosses hosts but knows nothing about these headers, so a redirect carried them anywhere the remote pointed. Refuse that hop instead, on the same host-and-port test reqwest itself applies — the hop could only have come back 401 anyway. Same-host redirects still follow, under reqwest's own bound. The health check gets the same policy, so "test succeeded" means what "save succeeded" means. Surface the reason: reqwest's Display stops at "error following redirect for url (…)", which is the layer that says nothing, so flatten the source chain into the detail the UI renders.
Port the rest of Monaco's `_layoutBoxInViewport` decision, not just its above-edge half, so every way the pill could land outside the editor is covered: - room below the anchor now decides too, from `getLayoutInfo().height` (Monaco's own `viewportHeight`) and the anchor line height. An editor dragged shorter than one pill plus one line takes the roomier side. - an anchor line scrolled wholly out of view withholds the pill. Monaco renders widgets a little past the viewport, and from out there both placements land in the surrounding chrome. - every layout path now carries the measure-and-correct handshake. A live zoom resizes the pill without touching the selection, so the paths that only re-laid out were re-using a stale height and put the pill back behind the path bar. `ContentWidgetPositionPreference.EXACT` looks like the answer for a short editor — it is the one preference that cannot leave the viewport — but for an overflowing widget Monaco positions it at `anchor.left + contentLeft` with no `scrollLeft` term and no clamp, which throws the pill outside the editor once it is scrolled horizontally. Keep `allowEditorOverflow`: dropping it lets Monaco decide placement from viewport geometry for free, but the editor's own scrollbar then paints over the pill near the right edge and the node shrink-to-fits to min-content.
…ecrets
tungstenite TRACEs the serialized handshake in full — `trace!("Request:
{:?}", …)` over the raw bytes. For a remote workspace connection those bytes
carry the bearer token, which travels as a `Sec-WebSocket-Protocol` value, and
every custom header the user configured, which for the case the feature exists
to serve is a Cloudflare Access service token. Marking the values sensitive
does not reach this: that governs HPACK indexing and the value's own `Debug`,
not bytes already written out. So raising the level to trace to diagnose a
connection, then attaching the log to a bug report, shipped the secrets with it.
Add the standing ceiling the codebase already uses for firehoses of this shape.
`Debug` keeps the crate's useful lines and drops only trace, which is where both
the dump and the per-frame flood live.
Also tighten `same_remote_origin` to compare scheme as well: on a connection
configured as `https://box:8443`, a hop to `http://box:8443` left host and port
identical, so the host-and-port rule borrowed from reqwest would have allowed
the credentials onto the wire in plaintext.
…crate A crate-level ceiling is meant to be re-openable by naming a submodule — that is what keeps `tungstenite::protocol` pinnable to trace for frame debugging. That escape hatch is right for a firehose and wrong for a credential, and the Settings UI accepts a per-target directive, so the handshake dump could still be asked for by name. Give it its own backstop entry. Backstops are appended last and win at equal specificity, and no target is more specific than the module the `trace!` lives in, so neither `CODEG_LOG` nor the Settings UI can reopen it. Asking for less is still honored — the clamp never raises verbosity. Also build the redirect tests' clients with `no_proxy`. A developer box with HTTP_PROXY set routes loopback through the proxy, which answers 502; the cross-host test would then have passed on an error its own policy never raised.
The level table cannot close this on its own. `EnvFilter` ranks a
field-qualified directive above a plain target one, and the dump is a formatted
event, so `CODEG_LOG='tungstenite::handshake::client[{message}]=trace'`
outranks the backstop entry and reopens it — the comment claiming otherwise was
wrong.
A ceiling is the wrong shape here anyway. There is no verbosity to trade off:
the only tungstenite client in this codebase is the remote-workspace WebSocket,
so every time that line prints at all it prints a bearer token and whatever
custom credential headers the connection carries. Drop the target before the
level filter is consulted, as a second global filter an event must also clear.
The backstop entry stays: it keeps the level story coherent and it is what
`tungstenite::protocol=trace` is measured against, which remains reachable.
…headers feat(remote-workspace): add custom HTTP headers to connections
fix: keep add-to-chat pill visible for first-line selections
The repository panel's drawer showed an issue/PR's description but not its
comments, because the list payload carries no discussion and folding one in
would spend a request per row to draw a page whose reader opens at most one.
Ask for it when the panel opens instead — one request for the item actually
being read. On GitHub that lands on `/issues/{n}/comments` (which serves pull
requests too) and on the CORE quota, so it cannot starve the 30-per-minute
search budget the list itself lives on; on GitLab it is the item's own notes
collection with the system events ("changed the milestone") filtered out, so
the thread matches the `user_notes_count` the row already shows.
The panel renders each comment through the same Markdown renderer as the body,
with the author, a relative timestamp, an "edited" mark only when the forge
really edited it, and a permalink. "Load more" follows the forge's own
pagination header rather than the row count — GitLab drops its system notes
after paginating, so a page can arrive empty with the discussion continuing
behind it — and a failed page keeps what is already on screen, retrying the
page that actually failed rather than the load-more cursor.
The side-by-side view laid both columns on one grid inside a single scroller, so reading to the end of a long line on one side dragged the other side's text off screen with it. Each side now scrolls in its own container, split by a draggable divider that starts even, and the two are kept in step on both axes so the row under the cursor stays the row under the cursor. A side whose content is shorter no longer hauls its neighbour back to its own end, and the rows stay level down the whole file — including where a deletion has nothing to face on the other side. The line numbers, and inline the +/- marker, now hold against the left edge while the code scrolls under them. They ride an opaque rail that carries its row's colour, so a tinted row reads as one band from the digits through to the code and nothing shows through from underneath. The file header carries the whole summary: the +N -N counters sit with the path they describe, and one button on the right switches layout, showing the view it takes you to rather than spending two buttons and twice the width on a single choice. The diff body pins itself to LTR. Code reads left-to-right whatever the interface language, and under the RTL document Arabic switches on, the panes swapped sides, the divider dragged the wrong way, `scrollLeft` counted down into negatives — stranding the horizontal sync at zero — and the number rail stuck to the edge the numbers were no longer on. The direction has to sit on the scroll container itself, which is what decides both the layout and the sign of `scrollLeft`, so `ScrollArea` takes an optional `dir`. The header still follows the interface, with the counters kept LTR so their signs stay in front of their numbers.
Streamdown's diagram component zooms with `transform: scale()` under an
inline `will-change: transform` and a 150ms transform transition. A drag
restarts that transition on every pointermove, so the diagram spends the
whole gesture on a composited layer the GPU stretches from a single 1x
raster — blurred and smeared exactly while it is being moved. None of it
is reachable from a prop, and dropping `will-change` from a stylesheet
only ever covered half of it.
A mermaid fence now renders through `components.pre`. Streamdown builds
its component map as `{...defaults, ...props.components}` and its own
`pre` does nothing but stamp `data-block` on the child, so claiming that
one element takes the diagram and leaves every other fence — shiki, plain
code, indented blocks — on Streamdown's path, with no remark plugin and
no change to the sanitize schema. The engine is still the same lazily
imported singleton, so nothing is loaded that was not loaded before.
Zoom is the width of the host element rather than a transform: the
browser lays the SVG out at its real size and repaints the vectors, so a
diagram is as sharp at 8x as at 1x. Panning is a whole-pixel translate
carrying neither a transition nor a `will-change`, which is what keeps
the drag off the compositor. Mermaid's own `max-width` comes off the
rendered root, since it would otherwise cap every zoom above the fit.
The palette follows the interface. The block reads the resolved dark mode
from context, which reaches it through the memo around the message and
the one inside Streamdown; neither compares anything theme-shaped, so a
theme handed down as a prop would leave every already-drawn diagram
behind on a switch.
Fullscreen is a dialog — a framed panel with its own header, zoom
readout, and toolbar over a dimmed overlay — and it fits the whole
diagram rather than the column width. Inline, a diagram is capped at 60vh
and fades at the cut instead of ending mid-stroke, the rest one drag or
one click away. Saving as SVG, PNG, or .mmd goes through the system save
dialog, and the PNG takes its pixel size from the viewBox: Mermaid writes
`width="100%"` on the root, which decodes to nothing an `<img>` can draw.
Also name LQ router 中转站 in the English README, the way the other Chinese-branded sponsors already read there.
The Recent section opens at fifteen conversations and reveals another page per click on its footer row, but the limit only ever grew: once expanded, the list stayed long for the rest of the session and pushed the sections under it off the screen. The footer now carries a reset back to that first page. While pages remain it sits at the row's right edge as an icon, revealed on hover and on the same axis as the section headers' own actions, with the room for it reserved so the label never reflows. Once the last page is out the row survives for the reset alone and gives it the whole row — retiring the row there would drop the only way back exactly when the list is longest. The reset is offered only when more than a page is really on screen, so it never proposes a collapse that would hide nothing; a limit raised earlier outlives the conversations it revealed. The row lights as one unit, the fill following the row rather than the label button so that crossing onto the icon does not read as leaving. Activating the reset hands focus to the footer button that survives it, which keeps a keyboard user on the row instead of back at the top of the document.
OpenCode moves to 1.18.23, Cline to 3.0.60, CodeBuddy to 2.139.0, DeepSeek Harness to 0.7.0, and Qoder to 1.1.31. Nothing on the wire moves with them. deepseek-acp 0.7.0 holds its handshake and the three upstream layouts codeg mirrors, so its registry entry records the two behaviour changes that do land: `session/load` and `session/fork` now compare workspaces by resolved path rather than by spelling, which is a loosening that stops a resume failing over `/var` against `/private/var` or a Windows short name; and the model-facing shell on Windows becomes pwsh, whose tool presentation is shaped exactly like the bash one the terminal card already reads. The Qoder config-dir resolver is unchanged at 1.1.31 down to the `.qoder` directory name, but its minified identifiers were renamed again, so the parser's re-verification note carries the current pair alongside the older ones it already warns about.
A custom model cloned a GPT catalog entry whole, and the entries codex
ships now carry `tool_mode: "code_mode_only"`, `multi_agent_version:
"v2"` and `use_responses_lite: true`. That combination sends an empty
`tools` array, moves the system prompt into a non-standard
`additional_tools` developer item, and drops `instructions` entirely —
nothing a third-party gateway implementing only the public API can
serve. Custom models now come in two shapes: a native GPT one, and an
OpenAI-compatible one that clears those three keys along with
`apply_patch_tool_type` and `supports_image_detail_original`. The same
request then goes out as plain Responses — `instructions`, a `function`
tool array, `reasoning: {effort}` — and without the `type: "custom"`
tool. The template is a preset over ordinary overrides rather than a
stored mode, so it reads off the effective values and can be switched
either way on a model that already exists, and every key it touches
stays editable on its own under a new wire-protocol group. The
`tool_search` and `web_search` tools that remain come from codex's
global feature flags, not from a model entry.
The field list follows codex 0.147. `supports_reasoning_summaries` was
renamed to `supports_reasoning_summary_parameter`, which defaults to
true and is skipped while it holds that default, so the old switch wrote
a key codex ignores and read as off when the real answer was on.
`web_search_tool_type`, `supports_image_detail_original` and the three
`include_*_usage_instructions` flags join it. Sanitization now knows
each key's nullability and type: a `null` in `default_reasoning_summary`,
`shell_type` or `web_search_tool_type`, or a string where a boolean
belongs, makes codex reject the entire catalog and every model vanish
from the picker, so those values are dropped before they can reach the
file. The bundled fallback snapshot is regenerated from the same version.
Codex retires a model by flipping it to `hide` and keeping it as a
migration stub rather than deleting it, which is what it did to
`gpt-5.4` and `gpt-5.4-mini`. A removal recorded while one of those was
still listed lingered as a customization with nothing in the interface
to show for it, holding the notice up and keeping codeg in charge of the
whole model table for no gain. A removal now applies only to a model
codex still lists, so hidden stubs and codex's own internal entries
survive the rewrite; the notice and the decision to manage
`model_catalog_json` follow the removals that still apply, and stale
ones are dropped on the next edit. A list that no longer deviates from
codex's own hands the key back and takes the generated files with it —
together, since codex refuses to start on a reference to a file that is
gone, and only when the key points at the catalog codeg generated rather
than one the user wrote.
…llapse feat(chat): collapse completed turn progress
fix(claude): coalesce adjacent thinking fragments
Hand-rolling `cmd /C <shim> <path>` gave the target path the standard CommandLineToArgvW quoting, which leaves `&` live for cmd to parse — a workspace file named `a&calc` ran `calc` on launch. Passing the `.cmd` shim as the program instead lets std build the cmd.exe line with batch-specific quoting. Spawning through tokio so the handle lands in the runtime's orphan queue: a dropped `std::process::Child` is never waited on, leaving a defunct entry per launch on unix. Disable the row in remote-desktop windows, where Code would open on the far host and read as a no-op here.
WebKit resolves `rem` on the root element against `font-size`'s initial value of 16px rather than the root's own font-size, and does so for every property, not just `font-size` itself. Window zoom is one declaration — the appearance provider writes `16 * zoom / 100` px onto `<html>` — so `:root`'s `line-height: 1.5rem` was pinned at 24px forever on the engine the desktop app ships on. At 300% a 42px CJK glyph carries a 50px content area into that 24px line box, and `truncate`'s `overflow: hidden` cut 13px off the top and bottom of the sidebar nav labels, conversation titles and the folder select alike. It is not a stale invalidation either: a cold start at 300% computes 24px too. `em` on the root element is its own font-size, which both engines resolve live — 24px at 100%, identical to the value it replaces, and 72px at 300%. `calc(1.5 * 1rem)` and a `var()` holding `1.5rem` are pinned the same way, so em is the only spelling that can stay on `:root`. Chromium follows the spec here, which is why server mode in a browser never showed any of this. A guard test holds `:root` to declaring no rem-valued regular property, and pins the 16px baseline that the zoom level is read back out of. Custom properties are exempt and stay in rem: their values are substituted at the use site and resolve against the element that consumes them, not against the root.
The row already prints one of the two timestamps — its `timeLabel` is `formatRelative` over `updated_at` or `created_at`, whichever the active sort mode is keyed on — so spelling both out again in absolute form was the one part of the bubble that repeated the row instead of completing it. They were also its only half-width cells, every other field spanning both columns, so the two-column grid and the locale-aware `Intl.DateTimeFormat` behind them go with them. Created and updated stay in the right-click Session Details dialog, which is what a full record is for, and the shared translation keys with them. Branch moves above path. Both answer "where", but a branch is short and exact, and it is the value that differs between two worktrees of one repo — the case the bubble was built for — while an absolute path is the long wrapping line that belongs under it. The `dir="ltr"` test reads its values in document order, so it pins the new order; a second one holds the timestamps out.
`open in code & canary` has spaces, so the quoting the hand-rolled `cmd /C` wrapper would have gotten quotes it anyway and the assertion could not tell the two implementations apart. A whitespace-free name falls through `append_arg` unquoted, which is the case that leaks. Also walk back the reaping comment: tokio documents orphan cleanup as best-effort with no timing guarantee.
Gemini CLI 0.55.1 to 0.57.0, OpenCode 1.18.23 to 1.18.25, Hermes 0.20.5 to 0.20.6, CodeBuddy 2.139.0 to 2.141.0, Kimi Code 0.39.0 to 0.39.1, Qoder 1.1.31 to 1.1.33. Every launch contract the registry encodes still holds under the new pins: `--acp` and `--skip-trust` remain registered options on Gemini, `--acp` on CodeBuddy, `acp` a subcommand on OpenCode, and each package's declared `engines.node` still matches its `node_required` floor. Hermes rides a community npm bridge, so its pin is exact and re-audited every bump: the whole tarball except `package.json` is byte-identical to 0.20.4, `postinstall.js` matches by digest, and the new upstream tag v2026.8.27 dereferences to the pinned commit 5fc308a7. Kimi stays clear of the 0.37.0-0.38.0 hole, where any stdio MCP server killed the session outright — its converter still reads an absent transport as stdio and supplies the runtime_id itself, so the codeg-mcp companion rides every session. OpenCode publishes all six pinned platform assets under the new tag and each archive still holds a bare `opencode`, so `dir_entry` stays `None`. Qoder's config-dir resolver and Kimi's mcp.json schema are unchanged beneath the new pins; the comments mirroring them now name the version they were last checked against.
Preserve Codex parent thread metadata in list and detail summaries so the generic importer can reject delegation children before creating root rows.
feat: add Open in VS Code actions
fix(codex): skip native subagent session imports
Review fixes on top of the numbered-jump / reopen-closed-tab bindings. closeTab now takes recordForReopen. The delete paths pass false: deleting a conversation (detail header) and the sidebar / manage-dialog bulk delete both close the tab right after deleteConversation, and closeTabsByFolder runs when the folder itself stops existing. Without the opt-out, one press of the reopen binding minted a tab — and an opened_tabs row — pointing at a conversation that is gone. A diff tab is no longer recorded. It carries the path it compares, but reopening goes through openFilePreview, so restoring one silently swapped the branch comparison or conflict view for the source editor. The digit-row positional fallback now declines a key that typed a character another binding owns by name. matchesDigitRowCode already documented this hazard as "latent while nothing binds digits 1-9"; binding them makes it live, because AZERTY puts `-` on Digit6 and `_` on Digit8 — one Ctrl+- press matched mod+- by key and mod+6 by code, and the zoom listener preventDefaults without stopping propagation, so the window zoomed out AND the app jumped to tab 6. shortcutsConflict cannot see the pair, so Settings warned about nothing. Ctrl+Shift+6 still reaches tab 6 there, via the surplus-Shift tolerance digits already have. pushClosedTab keys on the tab id and moves an existing entry to the top. The file-tab closers record from inside a setFileTabs updater, which React double-invokes under StrictMode and may replay on a discarded render; move-to-top makes any number of extra passes land on the same stack, including for a close-all loop. Also: "close other file tabs" now records, matching its conversation twin; and Ctrl+<digit> defers to a focused terminal, where it is a control code (Ctrl+6 is vim's alternate-file Ctrl+^), the same carve-out the zoom listener makes for Ctrl+-/Ctrl+=. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-up. Declining to record at close time only covers a tab that is still open. A conversation closed BEFORE it was deleted is already on the stack: closeConversationTab finds no tab and returns early, so the entry survived and Ctrl/Cmd+Shift+T reopened the deleted conversation. Same for entries recorded before their folder was removed. applyConversationRemove and applyFolderRemove now purge the stack. They are the right funnels — the backend broadcasts Deleted to every client including the one that asked, so this covers a local delete, another window's, and a bulk delete where only some requests succeeded (each success emits on its own). The close-time opt-out stays: the broadcast can land before or after the delete path's own closeTab, and only the opt-out stops that closeTab from putting the entry straight back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`test_credential_helper_e2e_server_mode` overrode CODEG_DATA_DIR with a bare `set_var` while holding only STATE_LOCK, which is private to this module. `forge::auth`'s token-resolution test guards the same variable with `temp_env::async_with_vars`. Two locks that don't know about each other, and `keyring_store::tokens_file_path` re-reads the variable on every call: within the window one test's `set_token` wrote under its data dir while the other's reader looked under a different one. That is the "no stored token for account acc-alt" failure that has been landing on the ubuntu server CI cell and reading as though whichever PR was in flight had caused it. The racer is `cfg(all(unix, not(tauri- runtime)))`, which is why only the unix server cells ever showed it. Both temp_env entry points take the one SERIAL_TEST mutex, so routing this test through `with_vars` makes the pair mutually exclusive. It also restores the variable on unwind, which the hand-rolled save/restore did not. Running the two together reproduced it 3/3 before and passes 5/5 after: cargo test --no-default-features --bin codeg-server --lib -- forge::auth git_credential
Review follow-up. Purging on applyConversationRemove only sees entries that are on the stack when the delete lands. A remote delete does not close an open tab — it only drops the sidebar row — so the user can close that tab by hand afterwards, recording a conversation this client already knows is gone. The reopen path now consults `deletedIds`, the permanent tombstone the store already keeps for exactly this hazard (it is what stops a late upsert resurrecting a deleted row). A deletion seen at any point wins, whichever side of it the entry was recorded on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codex after `codex archive`: reconnecting a conversation fails
session/load with an Internal error whose body names the archived
session and the unarchive command. That error fell outside
classify_session_load_failure, so it took the raw "Failed to load
session, starting new" path — the history was silently orphaned even
though a one-command fix exists.
Classify it as a third known case ("session_archived"). It now rides
the same SessionLoadFailed banner as resource_not_found /
session_unavailable (Reload or start new), and the banner text names
the exact unarchive command — the id is extracted from the error body
on the frontend; a body without a parseable id falls back to the raw
message so nothing actionable is lost. Custom agents keep their
silent local recovery (their history is codeg's own transcript).
i18n: backendErrors.sessionArchived x 10 locales, key parity kept.
PR #598 shipped the frontend `session_archived` case and its i18n keys, but not the backend classification they depend on. Nothing in classify_session_load_failure ever returned "session_archived", so the new switch arm was unreachable and `codex archive` still fell through to the raw "Failed to load session, starting new" path — the exact bug the PR set out to fix. Its claimed Rust test did not exist either. Classify "is archived" as the third known case, checked after the structured -32002 verdict (which keeps its documented precedence) and before the crash/ended family, so the more specific and recoverable verdict wins if a body ever carries both signals. Frontend, two corrections to the now-live path: - Take the session id from the event instead of regex-scraping the RPC body. `session_id` IS the session the load failed for, so it is exact by construction, while the body only spells it out by convention — recovering it from there drifts the moment codex rewords the error. - Only name `codex unarchive` when codex is actually the agent. The classification is matched on the wire message, so it is not codex-exclusive by construction, and prescribing a codex command to a Claude user is worse than showing the agent's own text. Tests: the Rust classifier case the PR described, plus three frontend cases covering the command, the reworded-body path, and the non-codex fallback. Verified failing without their fixes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `code` field is documented in four places; the archived case updated two of them. Bring the Rust event definition and the runtime store's field comment in line too, so none of them still claims -32002 is the only value a consumer can see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The banner's whole promise is a command the user can run to get their history back, but the surface it lands on is a one-line strip (`text-ellipsis whitespace-nowrap`) sharing its row with two shrink-0 buttons. With the agent name and session id substituted in, the message runs ~178 chars and the 36-char id sits at chars 57-109 — cut mid-UUID at any realistic panel width, and unselectable from the `title` tooltip. The one thing the user had to copy was the one thing they could not. Carry the command on the connection beside the localized message (`loadErrorCommand`), and give the banner a copy action for it. The message text is unchanged; this adds a way to actually obtain what it names. Null for the failures with no way back, so the button is absent rather than offering a recovery that does not exist. The button sits outside `canShowDetailErrorActions` on purpose: that gate is there because Reload refetches the DB detail and New session opens a tab in the folder. Copying needs neither. i18n: messageList.errorActionCopyCommand / errorActionCommandCopied x 10 locales, key parity kept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two problems with the copy action as first written. The banner is a single non-wrapping flex row whose every action is shrink-0, so the message is the only child with any give. Adding a third action therefore did not shrink anything — it crushed the message to zero width and pushed "New conversation" out past the banner edge, which the tab wrapper clips. Measured in headless Chrome across en/fr/de at 320/375/384px: 34-172px of overflow, worst in French. Adding a recovery action was breaking the two recovery actions already there, at exactly the narrow widths that motivated the change. Give the row `flex-wrap` and the message a `min-w-40` floor. Measured after: no overflow at any tested width, and at >=700px the layout is byte-identical to before (one row, 39px, same message width) — the second row appears only when the panel really is too narrow. This also fixes a pre-existing clip: French at 320px overflowed by 7px with just the original two buttons. Second, the command is a string we invite the user to paste into a shell, so the id must be the whole of what gets interpolated. Taking it from the event (correct, and exact) dropped the implicit guard the original regex had by accident — a scrape for a UUID could only ever yield a UUID. Shape-check `session_id` against an anchored UUID pattern and fall back to the raw message otherwise, so a value carrying a space and a second word can never become a second command. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(chat): jump to a tab with Ctrl/Cmd+1-9 and reopen with Ctrl/Cmd+Shift+T
fix(acp): surface archived sessions as the known load-failure banner
Every assistant reply now carries a header: "Working..." while the agent is writing, "Worked for 1m 9s" once it settles, and "Finished working" for the window before the post-turn reparse backfills the duration. That header is the only place a per-turn duration lives now — the footer's Timer chip showed the same number behind a hover. The fold state moved off the turns and onto the view. A reply's identity changes twice on its way into history (the stream settling into a promoted local turn, then the authoritative detail refetch renaming it), so keying the expansion to the adapted `parts` dropped it at exactly the wrong moment and a reply folded itself up as soon as it finished. `advanceReplyFold` tracks the newest run positionally instead: it arms on the running edge, holds that round expanded through settlement, and folds the thread behind the next user send — which is also the state a historical conversation opens in, nothing being live. Older replies keep an epoch-stamped override, so one epoch bump folds everything above a new message without walking the thread. A round is identified by the live message rather than by a render item, which keeps a fresh reply that merges behind a settled one from reading as that one resuming. An id that moves while the same reply keeps running — a viewer attaching mid-turn, or a reconnect rebasing the client-minted id onto the backend's — is latched rather than treated as a boundary; the one sequence that still fools it, a boundary falling entirely inside a reconnect gap, is documented at the branch. Replies that end on a tool call with no trailing prose remain un-foldable, so no send can hide a stopped turn or a card that IS the answer. The header reads as a section head: full-width rule, chevron beside the label, no hover treatment, no rail down the expanded body. The body slides on a `grid-template-rows: 0fr <-> 1fr` keyframe instead of cross-fading, so the answer below is pushed down and pulled back up. Animating the track rather than a measured height keeps the promise `instant-collapsible` exists for — nothing here reads layout — and a keyframe rather than a transition is what its `animationend` presence check needs to unmount closing content. The enter half is opt-in per toggle: this component re-mounts already open all the time (the row key flips at settlement, the virtualizer recycles rows), and every one of those would otherwise replay the unfold.
The hover bubble named the agent and then the status, which is what the row underneath already badges where it matters — a spinner while a session runs, a cross once it is cancelled. The model takes that place instead, lifted out of the field list to sit beside the agent behind a middle dot. Agent and model are one answer to what a session is, and the model had been a labelled row of its own carrying a single short token, in a list otherwise made of folders, paths and branches. The dot rides inside the model chip rather than between two of them. A chip row has no width of its own to truncate against and a model id's tail is what distinguishes it, so a long one has to wrap — and a separator that is its own flex item stays behind on the line above when it does, orphaned at the end. `SessionIdentityChips` is shared with the Session Details dialog, so both chips are opt-in now and each surface takes the one it lacks: the dialog has a labelled field for the model and a title worth qualifying with the status, and answers the bubble exactly. The row gap tightens so the separator hugs the two names it divides, with the status chip carrying the difference as its own inline-start margin, so the dialog reads as it did.
The hover bubble's model chip only ever appeared for imported sessions. `conversations.model` is written once, at insert: from the parsed session file on import, and NULL for a row the app creates itself — which it has to be, since the row exists before the agent has named a model. Nothing updated it afterwards, so it stayed NULL for the life of every session started in-app. The details dialog hid this by parsing the transcript on open and patching the summary it returns; the sidebar is served from the rows themselves and had nothing to show. An interactive open now seeds the column with the model that parse already found, in the same shape as the auto-title seed beside it: one UPDATE with the guards as SQL filters, writing the `model` column and nothing else. `updated_at` must not move, or merely reading a conversation would float it to the top of a recency-sorted sidebar. First value wins, and the sidebar upsert that was already there carries it to the row, so the chip appears without a refresh and without a frontend change. A title and a model landing on the same fetch now broadcast once between them rather than twice. Storing it at all has two consequences, and both are handled. The parse now wins over the column when the returned detail is built, rather than filling it only when NULL: with a value persisted, the old precedence would have pinned the details dialog — which reads the summary ahead of the turns — to the first model a session ever used, and a mid-session `/model` switch would never have shown. And an unrelated rebind clears it, because `bind_external_id` re-points a row from one session to another: the outgoing model travels to the row that keeps that history, and a copy left behind would name the old session's model on a row that is now the new one, uncorrectably, since the seed only ever fills an empty column.
Read a pull request and merge it without opening a browser. Every assistant reply now sits under a header you can fold away. ## New - **A pull request opens as three tabs, and there's a Merge button.** Conversation, Checks and Files changed; click a file to see its diff; the merge menu lists only what the repository allows — merge commit, squash, or rebase. - **Write a comment, close or reopen an issue, and file a new one — all in the repository panel.** These used to mean going to GitHub or GitLab in a browser. - **Every reply has a header: "Working…" while the agent writes, "Worked for 1m 9s" when it's done.** Click it to fold the whole reply away; sending your next message folds the one above it. A reply that stops on a tool call keeps everything on screen. - **Hover a session and a card floats out to the right** with its full title, agent and model, folder, branch and path. Sessions started inside codeg now show a model too — before, only imported ones did. - **Ctrl/Cmd+1 through 9 jump to a tab, and Ctrl/Cmd+Shift+T reopens the one you just closed.** (#502, @Adam-Dalloul) - **VS Code joins the "Open in" menu**, for folders, directories and files. (#591, @markos-ttl) - **Antigravity can sign in on a machine with no browser.** codeg gives you a link, you sign in to Google on your phone or another computer, then paste the address it lands on back into codeg. - **A conversation whose session was archived says so, and hands you the command to get it back.** One click copies `codex unarchive <id>`; before, it just said "Failed to load session, starting new". (#598, @asteroida123) - **Codex now says why it needs your approval**, instead of a fixed title like "Edit files". Comes with the codex-acp 1.7.0 upgrade. ## Improved - **Updated bundled agents:** Gemini CLI 0.57.0, OpenCode 1.18.25, Hermes 0.20.6, CodeBuddy 2.141.0, Kimi Code 0.39.1, Qoder 1.1.33. - **Kimi Code takes MCP servers again**, which is what unblocked the jump from 0.36.1 to 0.39.1; its login hint now includes the `--region global` a kimi.ai account needs. - **Saving MCP settings leaves fields codeg has no UI for exactly as they were**, instead of dropping them. - **The pull request detail panel lines its description, comments and composer up on one left edge**, keeps the author's avatar next to you down a long comment, and is wider. ## Fixed - **The interface grows when you zoom the window.** At 150% the text got bigger, but column widths, small labels and popovers stayed at their 100% size. - **Chinese, Japanese and Korean text isn't clipped at high zoom** in the sidebar, conversation titles and dropdowns. - **One stretch of thinking stays one card** while sub-agents are streaming beside it, instead of being cut into several. (#494, #586, @dawNotPoi) - **A reply you're watching in a sub-agent transcript stops being stuck on "responding".** Its timing stats and artifacts card show up. - **An image card keeps its filename or page name after a reload**, rather than reverting to "Image generation". (#504, @Adam-Dalloul) - **An OpenCode tool card shows the result, not a blob of raw JSON.** Watching live, a question you'd already answered read as "no selection". - **Sessions Codex delegates to sub-agents no longer show up as separate sessions in your history.** (#593, @TroyMitchell911) - **When a turn produces nothing, nothing comes back** — not the previous turn's answer. Thanks to @Adam-Dalloul, @asteroida123, @dawNotPoi, @markos-ttl and @TroyMitchell911 for contributing to this release. ----------------------------- # 发布版本 0.29.0 读 PR、合并 PR,不用再打开浏览器。 每一轮回复都收进一条可以折叠的标题栏里。 ## 新增 - **PR 打开后分成三个标签页,右边还有「合并」按钮。** 「对话」「检查项」「文件更改」各占一页,点开文件就能看 diff;合并方式只列这个仓库允许的那几种——创建合并提交、压缩后合并、变基后合并。 - **在仓库面板里就能发评论、关闭或重新打开议题,也能新建议题。** 以前这些都得跳去 GitHub、GitLab 的网页上做。 - **每一轮回复顶上都有一条标题栏:智能体在写时显示「正在工作中…」,写完变成「工作了 1 分 9 秒」。** 点它就能把整轮回复折起来,发出下一条消息时上一轮自动折叠。停在工具调用上、还没给出回答的回合不会被折走。 - **鼠标停在会话上,右边会浮出一张卡片**,写着完整标题、智能体和模型、所在文件夹、分支和路径。在 codeg 里新建的会话现在也能看到模型名了,以前只有导入的会话才有。 - **Ctrl/Cmd + 1 到 9 直接跳到第几个标签,Ctrl/Cmd + Shift + T 重新打开刚关掉的那个。**(#502,@Adam-Dalloul) - **右键菜单的「打开于」里多了 VS Code**,文件夹、目录、文件都能用。(#591,@markos-ttl) - **Antigravity 可以在没有浏览器的机器上登录。** codeg 给你一个链接,你用手机或另一台电脑打开、登录 Google 账号,再把跳转后的地址粘回来就完成了。 - **会话在 Codex 那边被归档时,横幅会直接说明,并给出找回历史的命令。** 点一下就复制 `codex unarchive <id>`;以前只会显示「加载会话失败,正在新建会话」。(#598,@asteroida123) - **Codex 会说明它为什么要请求这次授权**,不再只显示「编辑文件」这种固定标题。随 codex-acp 1.7.0 升级一起到来。 ## 改进 - **内置智能体版本更新:** Gemini CLI 0.57.0、OpenCode 1.18.25、Hermes 0.20.6、CodeBuddy 2.141.0、Kimi Code 0.39.1、Qoder 1.1.33。 - **Kimi Code 重新支持 MCP 服务器**,卡在 0.36.1 的版本这才升到 0.39.1;登录提示里也补上了 kimi.ai 账号要加的 `--region global`。 - **保存 MCP 设置时,codeg 界面上没有的那些字段会原样留着**,不会被清掉。 - **PR 详情面板的描述、评论和输入框对齐到同一条左边线**,读长评论时作者头像一直停在旁边,面板整体也更宽了。 ## 修复 - **调窗口缩放时,整个界面跟着一起变大。** 之前调到 150%,字是变大了,但栏宽、小号标签和弹出层还是 100% 的尺寸。 - **缩放调高后中文不再被切掉上下半截**,侧边栏、会话标题、下拉框都恢复正常。 - **旁边有子智能体在输出时,一段思考仍然是一整张卡片**,不会被切成好几段。(#494、#586,@dawNotPoi) - **打开子智能体的转写去看,回复不会一直卡在「响应中」**,耗时统计和产物卡片也能正常显示。 - **图片卡片刷新页面后还是原来的名字**(文件名或页面名),不会变回「Image generation」。(#504,@Adam-Dalloul) - **OpenCode 的工具卡片显示的是结果,不是一段原始 JSON。** 之前实时看的时候,已经回答过的提问会显示成「未选择」。 - **Codex 委托给子智能体的会话,不会再作为独立会话出现在历史列表里。**(#593,@TroyMitchell911) - **某一轮什么都没输出时,返回的就是空**,不会把上一轮的回答顶上来。 感谢 @Adam-Dalloul、@asteroida123、@dawNotPoi、@markos-ttl 与 @TroyMitchell911 为本次发布做出的贡献。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
See Commits and Changes for more details.
Created by
pull[bot] (v2.0.0-alpha.4)
Can you help keep this open source service alive? 💖 Please sponsor : )