Compare commits

...
220 Commits
Author SHA1 Message Date
YHH c48ee0f80d Merge pull request #12852 from esengine/docs/release-2.38.0
docs(release): Studio 2.38.0 notes
2026-10-12 17:45:52 -07:00
esengine 10e929f3d5 docs(release): Studio 2.38.0 notes 2026-10-12 17:37:03 -07:00
YHH a750f83859 Merge pull request #12850 from esengine/fix/selection-from-data
fix(studio): hold the blocks a long selection passes through as plain text
2026-10-12 17:27:13 -07:00
YHH ff77cb75ff Merge pull request #12849 from esengine/fix/mcp-tool-name-128
fix(mcp): keep a model-visible tool name within the provider's 128-character limit
2026-10-12 17:26:10 -07:00
esengine 780d5e04fa fix(studio): hold the blocks a long selection passes through as plain text
A selection reaching into blocks that are not mounted mounted every block in
between in one synchronous render, so a drag across a long conversation froze
the window (22s at 1200 turns). A block the selection only passes through, with
neither end inside it, is now held as the conversation's own text at its
placeholder height; blocks with an end inside stay whole.
2026-10-12 17:14:37 -07:00
esengine b4060aa4fd fix(mcp): keep a model-visible tool name within the provider's 128-character limit
A tool whose prefixed name exceeded 128 characters made the provider refuse
every request in the session. toolName now shortens only the tool part,
keeping the server prefix and a hash of the full name so siblings stay
distinct and the name is deterministic for permission and hook rules.
2026-10-12 16:42:25 -07:00
YHH ce84fe9ff8 Merge pull request #12848 from boscocp/fix/readiness-12567-unrecognized-command-write
fix(readiness): an unrecognised command clears the check only when it did not write / 未写入时才清除待验证
2026-10-12 15:03:15 -07:00
Adalberto Pereira 03aeb27e4e test(readiness): turn the shell jail off so the effect turn's commands run
The unrecognised-write effect test asserts on what the turn's commands
changed, but left the sandbox at its default. A runner that has no bwrap
refuses to run them at all, so the case that keeps the waiver owed the check
for a reason the test is not about: green on the `test` jobs, which install
bubblewrap, and red on `coverage`, which does not.

Every other effect test that needs a shell writes `[sandbox] bash = "off"`;
this one now does too. Reproduced with a PATH that hides bwrap, which fails
with the CI's exact line and passes with the fix.
2026-10-12 18:42:27 -03:00
Adalberto Pereira ea8f6c9ca6 fix(readiness): an unrecognised command clears the check only when it did not write / 未写入时才清除待验证
A turn that wrote code and then ran any command the classifier could not read
as a check was released as verified. The waiver was keyed on one fact — "not a
check" — while the receipt carried a second one it never read: whether the host
watched the command write. `echo … > generated.txt` after a code write is
graded MutationProven from the redirect itself, and the turn still finished
with the change unverified.

The waiver now also requires the grade the host already recorded, and requires
it of every unrecognised command after the write rather than the most recent
one: a quiet `ls` beside a writing command says nothing about that command. A
turn whose unreadable commands all left the tree as the host found it keeps the
waiver — that miss is the classifier's blind spot, not the turn's. A write the
host watched, and a change whose extent it could not establish, leave the check
owed: an incomplete observation never establishes scope, and unproven_mutation
is settled by observation rather than by a check that ran afterwards.

Ordinary turns only: Delivery gates verification through a corroborated
citation and never had this waiver.

Refs #12567
2026-10-12 17:37:55 -03:00
YHH 217702190c Merge pull request #12839 from esengine/takeover/12660
fix(theme): make wallpaper transcript glow configurable
2026-10-12 12:33:45 -07:00
YHH 71164ebf3c Merge pull request #12846 from xxoingr/test/session-viewed-late-heal
test(session): let the torn-sidecar view test outlast a late heal
2026-10-12 11:06:49 -07:00
YHH 057894edec Merge pull request #12847 from xxoingr/test/name-unsealed-evidence-cause
test: name the store's cause when a turn's evidence was not sealed
2026-10-12 11:04:48 -07:00
YHH 9f78a77e1c Merge pull request #12842 from XTLine/fix/studio-file-reference-boundaries
fix(studio): guard ambiguous file reference copies
2026-10-12 10:36:45 -07:00
YHH f9548ec3dd Merge pull request #12843 from thehui13/codex/fix-memory-recall-audit-12784
fix(memory): preserve recall omission causes in trajectories
2026-10-12 10:34:40 -07:00
xxoingr 1b6929aad4 test: name the store's cause when a turn's evidence was not sealed
Two tests that read a turn's sealed bundle have failed on Windows CI with
nothing that says why the bundle was missing:

- TestContractCriterionIsSatisfiedByItsOwnCheck (runs 38185765331 and
  38199681271): "trusted state tampered: malformed digest """, because the
  test read the empty Record of an unsealed audit as a digest.
- TestEffectSupportingWritesWithoutChecksMayFinish/markdown_only (run
  38252045191): "missing sealed evidence".

The audit already carries the store's typed cause. sealedRecord fails with
FailureCode and ContractFailure when the bundle was not sealed, and every
agent test that reads a sealed record goes through it; the boot test prints
the audits it found.
2026-10-13 01:30:28 +08:00
YHH cad221e1d6 Merge pull request #12845 from esengine/fix/pinned-sessions-lead
fix(studio): lead a project with its pinned conversations and mark them
2026-10-12 10:23:23 -07:00
xxoingr 458fbbcabf test(session): let the torn-sidecar view test outlast a late heal
TestSessionViewedToleratesASidecarCaughtMidReplace failed in the coverage
job (run 38264909143) with "a view that met a torn sidecar failed instead
of waiting it out" in 0.20 s. The view retries a torn sidecar for 20, 50
and 100 ms; the test heals it from a goroutine after 30 ms, so the heal had
140 ms of slack, and on a loaded runner the goroutine started later than
that. Delaying the heal to 250 ms reproduces the failure locally.

The test now appends a 5 s last retry to the product's schedule, the way
the meta-ledger tests already replace it, so a late heal is still waited
out. A reader that does not retry at all still fails the test.
2026-10-13 01:23:09 +08:00
yhh d68d34c811 fix(studio): lead a project with its pinned conversations and mark them
Pinning a conversation only fed the Pinned filter and the menu label, so in the
full list nothing moved and nothing showed it was pinned. Pinned conversations
now come first within their project, the kernel's order holding inside each
group, and each carries a small pin.
2026-10-12 10:14:40 -07:00
YHH 510915ba51 Merge pull request #11822 from esengine/feat/fix-11734-c
fix(plugins): publish a replaced plugin directory without renaming a watched tree on Windows
2026-10-12 10:14:13 -07:00
yhh 6db92a8904 refactor(plugins): keep the superseded-copy cleanup beside the copy helpers
plugin_package.go was over the 800-line ceiling.
2026-10-12 10:06:46 -07:00
YHH 586b3068c4 Merge pull request #12841 from esengine/takeover/11744
fix(studio): make session tabs opt-in
2026-10-12 10:04:37 -07:00
hashem e3985f1971 fix(memory): preserve recall omission causes in trajectories
Refs #12784
2026-10-13 00:52:29 +08:00
XTLine d9884c795c fix(studio): guard trailing backslash reference boundaries
(cherry picked from commit a33d4a2fe7af32b00bfd924e5897191e94911a48)
2026-10-13 00:52:12 +08:00
XTLine 6cc59ec78c fix(studio): avoid ambiguous workspace reference copies
(cherry picked from commit 1f547d9738e8bd030e84c8d2734ed25f79dac5c8)
2026-10-13 00:51:36 +08:00
yhh fbcc83cdc6 test(studio): turn the session tabs on in the tests that switch panes by tab
The tab strip is opt-in now, so tests written against an always-present strip
enable the setting first.
2026-10-12 09:47:02 -07:00
yhh 174571c2a9 Merge commit 'refs/pr11744' into takeover/11744
# Conflicts:
#	desktop/frontend-next/src/i18n/en_settings.ts
#	desktop/frontend-next/src/state/prefs.ts
#	desktop/frontend-next/src/ui/Appearance.tsx
#	desktop/frontend-next/src/ui/prefsnav.tsx
2026-10-12 09:41:51 -07:00
YHH d1f096b206 Merge pull request #12837 from esengine/takeover/12069
feat(studio): add a shared send shortcut preference
2026-10-12 09:26:35 -07:00
yhh 3bce33bff3 Merge commit 'refs/pr12660' into takeover/12660
# Conflicts:
#	desktop/frontend-next/src/port/look.ts
#	desktop/frontend-next/src/styles/TOKENS.md
#	internal/ext/theme/theme.go
2026-10-12 09:25:15 -07:00
YHH 2f27112578 Merge pull request #12836 from esengine/takeover/12311
feat(studio): quote selected reply text from the context menu
2026-10-12 09:18:47 -07:00
yhh a2ae61b9c0 Merge commit 'refs/pr12069' into takeover/12069
# Conflicts:
#	desktop/frontend-next/src/i18n/en_settings.ts
2026-10-12 09:17:26 -07:00
YHH 17589e87d5 Merge pull request #12835 from esengine/takeover/12349
fix(studio): batch-check models before adding a source
2026-10-12 09:09:26 -07:00
yhh 0818a41038 fix(studio): merge the selection menu with the reply branch action and copy label
CopyButton gained both a menuitem role and an aria label on studio, the
actions list gained reply.branch, and the new selection tests build a reply
action set that now also needs the branch pair.
2026-10-12 09:08:38 -07:00
yhh 4473c23aa5 fix(studio): batch-check models on the protocol being picked
The edit form can change a connection's protocol, so the shared batch
hook is given the draft protocol rather than the stored entry's.
2026-10-12 08:59:48 -07:00
yhh 0a647e12ff Merge commit 'refs/pr12349' into takeover/12349
# Conflicts:
#	desktop/frontend-next/src/ui/AddProvider.tsx
#	desktop/frontend-next/src/ui/EditConn.tsx
2026-10-12 08:55:21 -07:00
YHH f43cc2af32 Merge pull request #12818 from esengine/takeover/12808
feat(studio): add file path and reference copy actions
2026-10-12 08:46:09 -07:00
yhh 101e82b5bb fix(plugins): removing a plugin reclaims the copies a replacement left beside it
A replacement published beside the old tree keeps the old tree, so an
uninstall that removed only the active root left the managed directory
behind. Removal now also deletes the canonical copy and earlier staged
copies, skipping any directory the registry still points at.
2026-10-12 08:42:16 -07:00
yhh 21219f6890 test(plugins): compare the publication fault with errors.Is 2026-10-12 08:38:14 -07:00
yhh 02a8abeec3 Merge remote-tracking branch 'origin/studio' into own/12818
# Conflicts:
#	desktop/frontend-next/src/ui/WorkbenchPanel.tsx
2026-10-12 08:36:07 -07:00
YHH 7fbcc02b92 Merge pull request #12832 from esengine/takeover/12824
fix(studio): open the focused session files from navigation
2026-10-12 08:34:04 -07:00
YHH 17af885a67 Merge pull request #12831 from esengine/takeover/12814
feat(studio): add persistent event-driven rewind notice
2026-10-12 08:22:35 -07:00
yhh a81212d745 Merge commit 'refs/pr12824' into takeover/12824
# Conflicts:
#	desktop/frontend-next/src/ui/WorkbenchPanel.tsx
#	desktop/frontend-next/src/ui/browser-missed-read.interaction.test.tsx
#	desktop/frontend-next/src/ui/browser-open.test.ts
#	desktop/frontend-next/src/ui/html-preview.interaction.test.tsx
#	desktop/frontend-next/src/ui/useColumnOpening.ts
2026-10-12 08:21:06 -07:00
yhh cf1ca1ff06 fix(studio): classify the rewind change event and register the dismiss action
rewind_changed is a content-free invalidation a reopened pane replaces with
a read of /rewind/undo, so the wire log skips it, and the new close button
declares its action id.
2026-10-12 08:13:21 -07:00
YHH 7ee7a7fe8e Merge pull request #12826 from XTLine/fix/studio-workbench-launcher
fix(studio): offer a launcher from the workbench new-tab button
2026-10-12 08:05:40 -07:00
yhh c430ae5fe3 Merge commit 'refs/pr12814' into takeover/12814 2026-10-12 08:03:47 -07:00
YHH 06ff93fda2 Merge pull request #12829 from esengine/feat/mcp-held-approval
feat(studio): approve MCP tools whose definition changed
2026-10-12 07:54:54 -07:00
XTLine 9ea570231a test(studio): cover missed and superseded browser observations 2026-10-12 22:44:43 +08:00
XTLine ba06cdd16f fix(studio): reconcile browser navigation read boundaries 2026-10-12 22:44:33 +08:00
yhh 470fcfea8b feat(studio): approve MCP tools whose definition changed
A server whose tool definitions differ from what the user approved has those
tools withheld from the model, and the kernel already had an approval route
for them, but the settings row never said so and offered no way to approve.
The row now carries the withheld tools with how each differs, and a button
that sends back the digest of exactly that set, then reconnects.
2026-10-12 07:42:27 -07:00
XTLine 79f5f1f90b fix(studio): follow fresh activation after a missed tab read 2026-10-12 22:38:57 +08:00
XTLine 5a1e3655d4 fix(studio): preserve the latest eligible browser follow 2026-10-12 22:25:34 +08:00
XTLine c58f236dd8 fix(studio): track browser navigation read boundaries 2026-10-12 22:25:09 +08:00
yhh 50014383fb style(plugins): iterate directories backwards with slices.Backward 2026-10-12 07:13:53 -07:00
YHH 47056a93ee Merge pull request #11761 from esengine/feat/web-search-model
feat(studio): web search model setting in model preferences
2026-10-12 07:08:21 -07:00
yhh 6d98fbe7dd Merge remote-tracking branch 'origin/studio' into own/11822
# Conflicts:
#	desktop/frontend-next/src/port/plugin.ts
#	internal/ext/installsource/plugin_package.go
#	internal/ext/installsource/types.go
2026-10-12 07:06:41 -07:00
yhh 4395969694 fix(config): match the provider removal signature in the web search model test 2026-10-12 07:00:57 -07:00
YHH d48ba5e65b Merge pull request #11762 from esengine/feat/browser-fullscreen
feat(studio): wide mode for the browser pane that keeps the conversation visible
2026-10-12 06:57:23 -07:00
yhh 8931d3e612 Merge remote-tracking branch 'origin/studio' into own/11761
# Conflicts:
#	internal/model/anthropic/anthropic.go
2026-10-12 06:54:13 -07:00
XTLine c51e648adf test(studio): cover preview ownership and real browser closing 2026-10-12 21:50:46 +08:00
XTLine 6dd1650dca test(studio): cover launcher choices and interrupted browser opens 2026-10-12 21:50:34 +08:00
XTLine 0ee194932c fix(studio): open the workbench launcher from the new-tab button 2026-10-12 21:50:23 +08:00
yhh bcfbe73bc0 test(studio): identify the browser page by its address bar in the wide-mode tests
The page's iframe now exists only after a navigation and the browser opens
from the workbench launcher, so the tests open the launcher and compare the
address bar, which stays mounted across the toggle.
2026-10-12 06:49:46 -07:00
XTLine 06009af71b fix(studio): track browser opening and preparation lifetimes 2026-10-12 21:49:38 +08:00
YHH a0a0763fdd Merge pull request #12819 from chuiwenwei/fix/memory-archive-index-consistency
fix(memory): keep the index and the archive consistent when a publish fails
2026-10-12 06:46:13 -07:00
YHH 9ea250b5bf Merge pull request #12816 from chuiwenwei/fix/memory-restore-archived-retryable
fix(memory): keep a failed archived-memory recovery retryable
2026-10-12 06:44:48 -07:00
yhh 68eaa5069e Merge remote-tracking branch 'origin/studio' into own/11762 2026-10-12 06:41:44 -07:00
XTLine 6a30c3e3c6 test(studio): cover operation preparation, observation and reuse lifetimes 2026-10-12 21:34:55 +08:00
XTLine 0461622f70 test(studio): cover tab-bearing refusals and hidden preview reconciliation 2026-10-12 21:34:39 +08:00
XTLine 67da7b1930 test(studio): cover event and explicit file selection races 2026-10-12 21:34:21 +08:00
XTLine d5b7dd6809 fix(studio): route Files entries into the focused session explorer 2026-10-12 21:34:06 +08:00
XTLine 4357242a98 fix(studio): keep explicit selection ahead of superseded browser requests 2026-10-12 21:33:50 +08:00
XTLine 26768e54d6 fix(studio): track full browser-opening operations with API controls 2026-10-12 21:32:16 +08:00
YHH 8734ee8d7a Merge pull request #12821 from esengine/takeover/12805
feat(studio): add per-model display aliases
2026-10-12 06:19:36 -07:00
yhh 38e66576fc Merge remote-tracking branch 'origin/studio' into takeover/12805 2026-10-12 06:11:30 -07:00
yhh 49d8c158bc Merge commit 'refs/pr12805' into takeover/12805 2026-10-12 06:01:33 -07:00
yhh 931dda7061 fix(studio): drop the import the file menu extraction left unused 2026-10-12 05:55:20 -07:00
YHH abc23f9fea Merge pull request #12815 from esengine/takeover/12804
fix(studio): make crowded timeline signals inspectable
2026-10-12 05:54:39 -07:00
yhh 0626f197ff Merge commit 'refs/pr12808' into takeover/12808
# Conflicts:
#	desktop/frontend-next/src/ui/WorkbenchPanel.tsx
2026-10-12 05:52:09 -07:00
yhh ee85dd3481 Merge remote-tracking branch 'origin/studio' into takeover/12804 2026-10-12 05:44:22 -07:00
YHH 75910feefc Merge pull request #12803 from esengine/takeover/12328
feat(studio): show the current plan beside conversation navigation
2026-10-12 05:43:35 -07:00
Will 4f548643b0 fix(memory): keep the index and the archive consistent when a publish fails
Archive moved the active file into .archive and left the index flush to its
caller, so a failed flush kept the fact archived while MEMORY.md still named it,
with no way for a retry by stable id to tell which state the store was in.

The move is now undone when the flush fails, and a retry by stable id finds the
archived copy and drops the stale line. The test seam this needs is the one the
rename fix already added to the mainline.
2026-10-12 20:43:23 +08:00
YHH 113a01e1dc Merge pull request #12813 from esengine/fix/phone-quiet-keeps-branch-chip
fix(studio): keep the branch chip when the phone composer is quiet
2026-10-12 05:42:59 -07:00
yhh 9a26301d83 Merge commit 'refs/pr12804' into takeover/12804 2026-10-12 05:41:29 -07:00
XTLine 2e2b580bd8 merge(studio): retain aliases with browser and session controls 2026-10-12 20:41:01 +08:00
Will fa818864ee fix(memory): keep a failed archived-memory recovery retryable
RestoreArchived wrote the active file and reindexed it before removing the
archived copy, so a failure in either later step left the same id active and
archived at once, and the identity guard refused every retry.

Both steps now undo the restored file, so a failed recovery never leaves a double
identity and the same call succeeds once the obstruction is gone. If the undo's
own index write fails, a line naming the fact can remain; the retry rewrites it.
2026-10-12 20:39:50 +08:00
yhh c67fc94eb9 fix(studio): keep the branch chip when the phone composer is quiet
The quiet phone composer hid the branch pop along with the refine entry,
so the no-repository chip, which explains why git features are missing,
was unreachable at 390px and the branch-tooltip browser guard failed on
studio. Only the refine entry is hidden now; the composer guard asserts
the branch pop stays.
2026-10-12 05:35:31 -07:00
yhh 7e71b2dce4 fix(studio): translate the plan peek goal label
The English catalogue test fails on the untranslated PaneNav key.
2026-10-12 05:33:55 -07:00
YHH 2140e55491 Merge pull request #12809 from esengine/chore/perf-harness-styles
chore(perf): load the same stylesheets as the app
2026-10-12 05:27:55 -07:00
Guan09 7c93b587bf feat(studio): add persistent event-driven rewind notice 2026-10-12 20:26:26 +08:00
YHH 53a9bb6dc1 Merge pull request #12807 from esengine/takeover/12347
feat(studio): select local sessions for batch actions
2026-10-12 05:12:21 -07:00
yhh 0fbcd55ebd chore(perf): load the same stylesheets as the app
The perf page imported fewer stylesheets than main.tsx, so controls such
as the changed-files pill and the session row actions rendered unstyled
in anything captured from it.
2026-10-12 05:09:39 -07:00
XTLine fa278aedd5 test(studio): guard copied references and workspace changes 2026-10-12 20:09:13 +08:00
XTLine 567816d7d3 test(studio): cover file copy menu behavior 2026-10-12 20:09:04 +08:00
XTLine cfa255cc70 feat(studio): add file context copy actions 2026-10-12 20:08:36 +08:00
XTLine 459bc7f4f7 fix(studio): preserve activity duration while zooming 2026-10-12 20:06:11 +08:00
yhh be06337aa0 fix(studio): select the rows the paged sidebar actually shows
The sidebar no longer slices each project to a fixed 30 rows: it asks the
kernel for pages and draws every loaded row. Batch selection now takes
all displayed rows, the selection pruning moves into a hook beside the
toolbar so Workspaces.tsx stays under its ceiling, and the two tests that
assumed the old 30-row cut assert the loaded rows instead.
2026-10-12 05:04:28 -07:00
YHH d21d91f6b3 Merge pull request #12806 from esengine/takeover/12067
feat(studio): close browser pages individually or in bulk
2026-10-12 05:02:54 -07:00
yhh 6766cc0042 Merge commit 'refs/pr12347' into takeover/12347
# Conflicts:
#	desktop/frontend-next/src/actions.ts
#	desktop/frontend-next/src/ui/Workspaces.tsx
2026-10-12 04:59:51 -07:00
XTLine abbd487b95 feat(studio): edit and show model display aliases 2026-10-12 19:51:17 +08:00
XTLine 62ddce6804 test(studio): guard alias identity and persistence boundaries 2026-10-12 19:51:04 +08:00
XTLine f9e3f3a5e5 feat(serve): expose scoped model aliases in provider editing 2026-10-12 19:50:52 +08:00
XTLine a3518cc60a feat(config): persist exact per-model display aliases 2026-10-12 19:50:18 +08:00
XTLine b92becac71 fix(studio): make crowded timeline signals inspectable 2026-10-12 19:45:58 +08:00
yhh 91ceafc038 Merge commit 'refs/pr12067' into takeover/12067 2026-10-12 04:42:31 -07:00
yhh 8cb7d82c01 fix(studio): carry the plan peek rules in their own stylesheet
The rules pushed studio.css over its css-size budget, so they move to
planpeek.css, imported after studio.css.
2026-10-12 04:36:49 -07:00
YHH 1682ab7fad Merge pull request #12802 from esengine/takeover/12310
feat(studio): fold feedback conversations and preserve unread state
2026-10-12 04:36:37 -07:00
yhh a4f1729c22 Merge commit 'refs/pr12328' into takeover/12328 2026-10-12 04:34:00 -07:00
yhh 0ea0c0e0fc fix(studio): open feedback conversations that wait on the reporter
A folded thread hid the maintainer's question while the status said
the report needed an answer. A report with needsInput now starts open
and is acknowledged as seen like any opened thread. The existing
entry-badge test opens the threads it expects to be read, and the
needs-input test no longer clicks a thread that is already open.
2026-10-12 04:29:05 -07:00
YHH dfe972034d Merge pull request #12801 from esengine/takeover/12327
fix(studio): put the current model first in the composer picker
2026-10-12 04:24:16 -07:00
yhh d7b42cc072 Merge commit 'refs/pr12310' into takeover/12310 2026-10-12 04:23:49 -07:00
YHH 95822af03a Merge pull request #12800 from esengine/takeover/12331
fix(studio): compact the untouched phone composer
2026-10-12 04:15:49 -07:00
yhh a1e598fee9 Merge commit 'refs/pr12327' into takeover/12327
# Conflicts:
#	desktop/frontend-next/src/ui/modelmenu.ts
2026-10-12 04:12:34 -07:00
yhh 2ea1d8e01c fix(studio): carry the phone composer rules in their own stylesheet
The six rules move out of studio.css into phonecomposer.css, without
the two !important the budget does not have room for: the attach
button's display no longer forces itself, so the quiet-state hiding wins
by specificity. The quiet toolbar also stops wrapping its model group.
The composer guard clears the draft left by its earlier steps before the
phone section and the guard page imports the new stylesheet.
2026-10-12 04:07:10 -07:00
yhh 2b97f139d8 Merge commit 'refs/pr12331' into takeover/12331 2026-10-12 03:59:16 -07:00
YHH 9c371349a1 Merge pull request #12798 from esengine/fix/rail-toggle-in-settings
fix(studio): keep the sidebar toggle usable while a settings page is open
2026-10-12 03:51:55 -07:00
yhh 3478a1dc6e fix(studio): keep the sidebar toggle usable while a settings page is open
The toggle was disabled whenever the settings sheet was open, so after
pressing any icon in the rail the top-left button looked live and did
nothing. It now opens and closes the workspace column in settings too.
2026-10-12 03:40:37 -07:00
YHH 55d3ca45c1 Merge pull request #12785 from chuiwenwei/fix/memory-rename-active-identity
fix(memory): retire the old name before publishing a renamed fact
2026-10-12 03:35:45 -07:00
YHH fddc6512a8 Merge pull request #12793 from esengine/takeover/12466
fix(studio): refine default light theme hierarchy
2026-10-12 03:03:14 -07:00
yhh 5fb8925ca2 chore: keep the before/after screenshots out of the repository 2026-10-12 02:51:27 -07:00
yhh c8d2bac6fb Merge commit 'refs/pr12466' into takeover/12466 2026-10-12 02:51:26 -07:00
YHH f14e75f9b5 Merge pull request #12783 from esengine/fix/compaction-live-line
fix(studio): stop the transcript jumping while a conversation is being compacted
2026-10-12 02:51:06 -07:00
yhh 5b4f8f073a fix(studio): keep the live compaction progress line on one reserved line
The streaming summary line wrapped to a different height on every token
while the transcript was pinned to the bottom, so the card and everything
above it moved up and down for the length of the fold. The line is now
always drawn, one line high, truncated with an ellipsis.
2026-10-12 02:41:53 -07:00
YHH 7340ac3a68 Merge pull request #12782 from esengine/fix/tabs-blend
fix(studio): let the view tabs share the page's tone
2026-10-12 02:38:46 -07:00
yhh 22491271c0 fix(studio): let the view tabs share the page's tone
The conversation, run analysis and workbench tabs sat on their own lighter
band with a divider under it. The row now takes the page's tone and drops the
divider; with a wallpaper the existing wallpaper rule still gives it a stable
surface.
2026-10-12 02:28:11 -07:00
YHH 191abea93c Merge pull request #12778 from esengine/fix/browser-view-repaint
fix(electron): attach a parked browser page again when it is shown
2026-10-12 02:08:54 -07:00
YHH 2e46375e4f Merge pull request #12776 from esengine/takeover/12340
feat(studio): add project folders by dropping onto the sidebar
2026-10-12 02:03:13 -07:00
yhh 75a08f128c fix(electron): attach a parked browser page again when it is shown
Switching away from a built-in browser tab and back drew a white page until the
window was resized: a view parked offscreen and moved back with setBounds alone
keeps a widget that does not repaint. The shell already knows that attaching a
view again wakes its widget (it does so on minimize and restore); a page that
was parked now gets the same when it is shown.
2026-10-12 01:57:50 -07:00
esengine cbd9038b07 Merge studio into takeover/12340 2026-10-12 01:55:46 -07:00
YHH fe7c6c44e6 Merge pull request #12777 from esengine/takeover/12343
feat(studio): compact context from the ring and inspector
2026-10-12 01:54:05 -07:00
YHH 2874af8b0e Merge pull request #12775 from esengine/takeover/12318
feat(studio): offer quick archive and delete on session rows
2026-10-12 01:38:31 -07:00
esengine 6b9e50246a Merge branch 'pr12343' into verify/12343 2026-10-12 01:38:04 -07:00
esengine 091c1b2865 feat(studio): keep the folder-drop styles in their own stylesheet
#12340 added 18 lines to studio.css, which must not grow. The drop-hint rules
move to styles/sidebardrop.css, imported with the other feature sheets, and
the sidebar-drop fixture gains the Sidebar props that became required after the
branch point (navShown, paging) so the file typechecks again.
2026-10-12 01:32:44 -07:00
esengine bf0505e70e Merge branch 'pr12340' into takeover/12340 2026-10-12 01:30:21 -07:00
esengine 5851b802ad Merge studio into takeover/12318 2026-10-12 01:29:12 -07:00
YHH 970c91d138 Merge pull request #12774 from esengine/refactor/workspaces-extract
refactor(studio): draw the session menu in its own component
2026-10-12 01:22:10 -07:00
yhh 859ef4782c feat(studio): keep the quick-action styles in their own stylesheet
#12318 added 26 lines to studio.css, which must not grow. The quick-action
rules move to styles/sessionquick.css, imported with the other feature
sheets, and the paging test finds its filter chips inside the filter group so
the new archive and delete buttons, whose names also start with 归档, are not
mistaken for them.
2026-10-12 01:18:55 -07:00
verify 7f79763026 Merge branch 'pr12318' into takeover/12318 2026-10-12 01:15:47 -07:00
yhh 6d98474fdf refactor(studio): draw the session menu in its own component
Workspaces.tsx held the session row's menu inline, 63 lines of JSX that only
report which action was chosen. It is its own component now, with the same
markup and the same handlers, so the sidebar file has room for the quick
actions, folder drop and batch selection that are waiting to land in it.
2026-10-12 01:14:03 -07:00
YHH 8d1f46cb50 Merge pull request #12324 from KHG420/codex/studio-project-context-menu
feat(studio): open project actions from context menus
2026-10-12 01:10:58 -07:00
Will 58aef50d47 fix(memory): retire the old name before publishing a renamed fact
SaveWithOptions wrote the new active file, then removed the old one, then
repaired the index. A failure in either later step left one id readable as two
active facts or left MEMORY.md naming a file that no longer existed, and a retry
could not tell which state it was in.

The rename path now stages the new content, retires the old name out of the
active namespace, flushes the old index line, publishes the staged file, and
reindexes; every step before publication is undone from the retired copy. The
helper that removed the old file in place has no callers left and is deleted.
2026-10-12 16:04:52 +08:00
YHH f3287f7dfc Merge pull request #12772 from esengine/feat/write-lease-optimistic
feat(config): an optimistic write lease for sessions sharing a workspace
2026-10-12 00:46:21 -07:00
yhh 25c6d4c10d feat(config): an optimistic write lease for sessions sharing a workspace
Every writer that declared no extent (bash, MCP, a sub-agent given no
write_paths) held the whole workspace, so two sessions on one project queued
behind each other even when nothing they wrote overlapped. [agent] write_lease
= "optimistic" in the user file makes such a writer claim nothing; a writer
that declared paths, including paths that cannot be resolved, still excludes
other sessions. The mode is carried by each session's lease owner, so sessions
in one process keep their own, and a project file cannot change it.
2026-10-12 00:35:29 -07:00
YHH 00d7ece65a Merge pull request #12559 from XTLine/fix/studio-restore-undo
fix(studio): preserve restore undo across reload / 保留重载后的还原撤销入口
2026-10-12 00:33:10 -07:00
YHH fba020d9bb Merge pull request #12770 from esengine/feat/branch-from-reply
feat(studio): branch a conversation from any reply into its own session
2026-10-11 23:44:32 -07:00
yhh 4f4b43978b feat(studio): branch a conversation from any reply into its own session
Studio had no click entry for branching: /branch copied only the current tip and
swapped the pane onto it. A finished reply now carries a 'Branch into a new
conversation' button that copies the conversation up to the end of that reply's
turn into a new session, titled '<title> - branch' so it never reads as a second
copy of its source, and opens it while the original stays as it was.

Controller.BranchAt cuts at a live turn boundary and shares the file-and-meta
writing with Branch. POST /branch/at answers with the new path and refuses with
typed codes (running, unavailable, failed).
2026-10-11 23:36:04 -07:00
YHH e67f256d5b Merge pull request #12769 from esengine/feat/withdrawn-version-notice
feat(update): tell a user whose running version was withdrawn
2026-10-11 23:24:29 -07:00
yhh ee40a3b90e feat(update): tell a user whose running version was withdrawn
The catalog can now pull a published release, but a user already running it
saw an ordinary update. The version hub carries a withdrawn note for the
running version, mirrored on the desktop side, and the version panel leads
with it: which version, the publisher's reason, and where to move to.
2026-10-11 23:15:05 -07:00
YHH 0968731518 Merge pull request #12768 from esengine/feat/open-logs-menu
feat(studio): open the logs folder from the feedback form
2026-10-11 23:12:35 -07:00
yhh 3b131d66a4 feat(studio): open the logs folder from the feedback form
A report that needs a log (it will not start, the kernel keeps restarting) had
no way to say where the log is: Studio writes shell.log and host.log under the
user data folder but nothing opened it, and the Windows and Linux builds have
no menu bar to put an entry in. The feedback form now offers 'Open logs
folder' when the shell can open it; the shell names the folder, the page does
not.
2026-10-11 23:03:29 -07:00
YHH b71837c1e5 Merge pull request #12765 from xxoingr/test/goal-resume-notice-race
test(control): fix the resumed-goal notice before the goal can complete
2026-10-11 22:36:47 -07:00
YHH b88596a4fc Merge pull request #12763 from chuiwenwei/fix/e2ebench-analysis-failure-reporting
fix(e2ebench): restore before checking the base side and name what is missing
2026-10-11 22:34:49 -07:00
YHH 9290b3bc0d Merge pull request #12761 from xxoingr/fix/tui-esc-menu-cancels-turn
fix(tui): Esc in the completion menu also stops a running turn
2026-10-11 22:30:12 -07:00
YHH fb77904a9f Merge pull request #12762 from xxoingr/test/split-jump-resolve-real-ssh
test(remote): split jump resolution from the installed ssh
2026-10-11 22:29:46 -07:00
YHH 0522cd92e6 Merge pull request #12757 from chuiwenwei/feat/contextbench-batch-manifest
feat(bench): persist what a context-retrieval batch was beside its results
2026-10-11 22:22:13 -07:00
YHH 50e25025a0 Merge pull request #12563 from chuiwenwei/fix/e2ebench-tape-integrity
fix(e2ebench): record and report incomplete tape recordings
2026-10-11 22:21:06 -07:00
YHH 0c9d2455be Merge pull request #12767 from esengine/perf/rail-thin-marks
perf(studio): draw one conversation-rail mark per distinguishable position
2026-10-11 22:13:11 -07:00
YHH dc921776c3 Merge pull request #12682 from esengine/dependabot/go_modules/go-412d5d9764
build(deps): bump the go group with 2 updates
2026-10-11 22:10:50 -07:00
yhh 912659f0dd perf(studio): draw one conversation-rail mark per distinguishable position
A 2000-turn conversation drew 2000 buttons per rail, 0.27 px apart, and every
pointer move rewrote all of them. Marks closer than 2 px are not told apart by
eye or pointer, so only one of each cluster is drawn; the rest stay addressable
through their positions, so the pointer and the keys still land on every
message.
2026-10-11 22:06:15 -07:00
YHH 4ef274d02e Merge pull request #12766 from esengine/perf/noto-studio-slice
perf(studio): draw the first screen from a 1.9 MB core CJK face
2026-10-11 22:03:20 -07:00
yhh 79af107700 perf(studio): draw the first screen from a 1.9 MB core CJK face
The first screen fetched the 7.8 MB full Noto Studio font because every Chinese
character resolved to it. A core face with the common simplified-Chinese set,
Latin, punctuation and symbols now comes first, and the full font stays as a
later family limited to CJK ranges, so a character the core lacks still finds
its glyph there and only then pays for the download.
2026-10-11 21:55:36 -07:00
xxoingr c6a134cfa1 test(control): fix the resumed-goal notice before the goal can complete
TestGoalResumeCommandContinuesTheGoalAndSaysSo failed in the race job on
studio (run 38188087939) with "resuming the goal said nothing" in 0.00s,
while the turn ran and the goal completed. The test built the expected
notice from c.Goal() as it read each event, and completing a goal clears
its text; the buffered events channel lets the controller send the notice,
finish the turn and complete the goal before the test reads anything, so
the real notice was compared with one built from an empty goal.

The expected line is now built once, before /goal resume is submitted.
2026-10-12 12:46:20 +08:00
YHH 97b21b6ed7 Merge pull request #12759 from esengine/feat/withdraw-version
feat(update): withdraw a published Studio version from the catalog
2026-10-11 21:42:48 -07:00
xxoingr 8e9227ceff test(remote): split jump resolution from the installed ssh
TestResolveJumpHostsUsesConfiguredAliasesAndSSHConfig bound two things:
that ResolveJumpHosts reads ssh_config aliases and configured hosts, and
that this machine's real `ssh -G` answers. The second failed on Windows CI
again, twice on studio (runs 38179453323 and 38193303449), both at 15.0 s
with `ssh -G "bastion": exit status 1`: the product's own subprocess
timeout. The test's comment asked to split the two if it recurred rather
than shorten that timeout.

The resolution test now supplies the `ssh -G` answer through the source's
resolveOpenSSH seam, as the sshconfig tests already do, and keeps every
assertion. TestResolveJumpHostsThroughTheInstalledOpenSSH runs the same
alias through the real client, so a starved ssh fails under its own name.
2026-10-12 12:40:12 +08:00
Will 3b1a88db12 fix(e2ebench): restore before checking the base side and name what is missing
Reviewing #12562 left three points open in this code.

differentialPerTest checked the base side before registering the defer that
restores the source, so a failure from that check returned with the tree still on
base - the state the restore exists to prevent. The defer is now registered
before the check, and treeMatches is a variable so a test can inject the failure;
TestDifferentialPerTestRestoresWhenTheBaseCheckFails asserts the PR's content is
back after that failure, and deleting the reorder makes it fail.

The report printed one sentence for every analysis failure, claiming the pin and
mutation figures were both absent even when the pin table had already been
produced and only the mutation sweep failed. analysisFailureLine now says which
figures are missing, and a unit test pins both cases.

The comment above the restore helpers described what the code used to do; it now
states only the constraint it enforces.
2026-10-12 12:35:39 +08:00
YHH 900aa3aa3a Merge branch 'studio' into dependabot/go_modules/go-412d5d9764 2026-10-11 21:32:30 -07:00
YHH d18b248992 Merge pull request #12684 from esengine/dependabot/npm_and_yarn/desktop/electron/npm-c28431c45a
build(deps-dev): bump the npm group in /desktop/electron with 2 updates
2026-10-11 21:31:38 -07:00
YHH a6facc0adf Merge pull request #12758 from esengine/chore/update-acknowledgments
Update acknowledgments contributors / 更新致谢贡献者
2026-10-11 21:29:58 -07:00
xxoingr c9d1517846 fix(tui): Esc in the completion menu also stops a running turn
1.x closed an open slash or @ menu on Esc and, while a turn ran, cancelled
the turn as well. 2.x's menuKey consumed the key after closing the menu, so
a user who had started typing a follow-up during a run had to press Esc a
second time to stop it, while the thinking line said Esc stops it.

menuKey now cancels the running turn after closing the menu, except in vi
mode, where Esc never interrupts a stream. Idle, Esc on the menu still only
closes it and keeps the draft.
2026-10-12 12:27:58 +08:00
YHH 2e2c9248f5 Merge pull request #12760 from esengine/docs/sponsor-register-links
docs: add an inline registration link to each sponsor's paragraph
2026-10-11 21:21:30 -07:00
yhh 36d69bf9e1 docs: add an inline registration link to each sponsor's paragraph
The sponsor logos link out, but the paragraph beside them read as plain text,
so the entry point was easy to miss. Each paragraph now ends with a visible
link to the same registration address its logo uses.
2026-10-11 21:17:15 -07:00
yhh 8a4281abfb feat(update): withdraw a published Studio version from the catalog
A release found broken after publication could only be fixed by shipping a
newer one, and installs kept being offered it meanwhile. The catalog now
carries a withdrawn list: a withdrawn version leaves the list old clients
read, a new client never offers or installs it and names it when it is the
one running, and a rerun of its own publication cannot list it again.
scripts/withdraw-version.sh edits the catalog; a manual workflow runs it
against the Studio catalog and keeps the GitHub release off the latest slot.
2026-10-11 20:57:15 -07:00
SivanCola 268ccef0a8 docs: update acknowledgments contributors 2026-10-12 03:52:27 +00:00
Will 9eb14cc8cc fix(e2ebench): record and report incomplete tape recordings
A tape is recorded as three files per provider exchange, and every write
discarded its error. A recording that lost a file therefore looked recorded, and
replay could not tell the difference: with the recorded request missing,
requestDiff compared against nothing and reported a divergence, blaming the
harness for a tape that was never complete. Nothing in the run's own report
mentioned it either, so a run whose tape can never be replayed read as a success.

save now returns its error and writes request.json last, so that file's presence
marks an exchange complete; load reports a missing recorded request as an
incomplete tape instead of a divergence; and the meter counts failed writes and
names the first error, which the report prints.

Two review findings in the same boundary are fixed here too. The run path read
the meter through record, which snapshotted before the final handler's deferred
save could run; stopping the meter is what waits for those handlers, so record
now drains first and the last exchange cannot be missing from the report. And a
re-recording over an existing tape left that tape's completeness markers in
place, so a failure partway through read as an old request beside a new partial
response; recording now clears those markers before it starts.

The healthy path is pinned as well, so the failure cases are not the only ones
covered: a complete recording reports nothing incomplete and prints no line.
Deleting any of the four checks reproduces the old behaviour.
2026-10-12 11:45:41 +08:00
YHH 59b2b75e62 Merge pull request #12753 from esengine/fix/rail-icons-match-settings
fix(studio): draw each rail entry with the mark of the settings section it opens
2026-10-11 20:43:56 -07:00
Will 1724a154f8 feat(bench): persist what a context-retrieval batch was beside its results
A batch printed its own provenance to the terminal and kept the per-run records
on disk, so the two could not be read together afterwards. The fingerprint
(model, endpoint, window, generations, commit, start time) existed only in
scrollback, and every aggregate the report is read for — funnels, dose response,
pooled deltas — was printed rather than stored. Reconstructing a batch after the
fact, or attributing a figure in the README to the runs that produced it, meant
trusting a terminal transcript; the 24-run and 36-run totals in the last batch
are exactly that failure.

Each paying mode now writes batch.json beside results.jsonl: mode, start and
finish, the checkout's commit and dirty state, model, window, generations, the
task ids and arms the batch covered, planned against completed runs, the summed
usage of the runs that reported any, and the sha256 of both artifacts so a
manifest and the records it describes cannot drift apart.

Two things stay honest. Usage totals are absent when nothing reported any,
because an unmeasured cost must not read as a measured zero, while
usage_reported_runs is written even when it is zero, because that is a fact. And
a checkout git cannot describe is recorded as an unknown commit with no dirty
flag at all, rather than as a clean tree.
2026-10-12 11:38:25 +08:00
yhh 4c3eabc0ab fix(studio): draw each rail entry with the mark of the settings section it opens
The icon rail kept its own icon set, so the same destination showed one mark
on the rail and another on the page it opens. Entries that open a section now
read that section's mark from the settings table; only the conversation item
keeps its own.
2026-10-11 20:33:09 -07:00
YHH 4a0b3888cb Merge pull request #12734 from jf200s/fix/studio-win11-osversion
fix(feedback): report the Windows 11 product line in the environment version
2026-10-11 20:25:16 -07:00
YHH 6f29d3d960 Merge branch 'studio' into codex/12026-browser-close 2026-10-11 17:04:14 -07:00
YHH b7ee3207da Merge branch 'studio' into codex/studio-feedback-thread-folding 2026-10-11 17:04:00 -07:00
YHH f808837d43 Merge branch 'studio' into codex/studio-plan-peek 2026-10-11 17:03:34 -07:00
XTLine a201497806 fix(studio): preserve restore through transcript updates
Merge Studio 0ac9826a9e while retaining Pane-owned undo and upstream work folding, running-tail handling and repeated-speaker grouping. Adapt three imported transcript fixtures to the existing undo ownership contract and cover folding, attachments and restore/undo together through the real Pane.

Ordinary upstream integration: 238 files, 7968 insertions and 704 deletions. Manual resolution and regression delta: 41 insertions and 9 deletions. Both histories are preserved.
2026-10-12 02:06:39 +08:00
XTLine 92339c8580 merge(studio): align restore branch with CI fixes
Import Studio 71650fe7, including upstream desktop lint fix 85460ae and test-only sandbox portability fix 2b5f6448. Keep the running-jobs fixture deterministic by selecting its scripted provider explicitly and asserting the background launch.
2026-10-11 23:07:03 +08:00
F 1ca349830d fix(feedback): report the Windows 11 product line in the environment version
Windows 11 kept the kernel's 10.0 major, so a build like 10.0.26200 read
as Windows 10 (issue #12696). Builds from 22000 report as 11.0.build,
matching the product version the darwin path already reports.
2026-10-11 22:06:05 +08:00
XTLine beb1b0082e Merge studio into fix/studio-restore-undo
Preserve pane-owned restore undo with canonical message attachments. Add a regression for attachment previews through restore and undo.
2026-10-11 21:44:54 +08:00
yhh 76552fb5a4 build(desktop): tidy go.mod and go.sum for the root module's dependency bump 2026-10-11 05:22:05 -07:00
dependabot[bot] beeb2865f0 build(deps-dev): bump the npm group in /desktop/electron with 2 updates
Bumps the npm group in /desktop/electron with 2 updates: [electron](https://github.com/electron/electron) and [playwright](https://github.com/microsoft/playwright).


Updates `electron` from 44.5.1 to 44.7.0
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v44.5.1...v44.7.0)

Updates `playwright` from 1.63.0 to 1.64.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.63.0...v1.64.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 44.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: playwright
  dependency-version: 1.64.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-11 11:54:59 +00:00
dependabot[bot] ded403ab37 build(deps): bump the go group with 2 updates
Bumps the go group with 2 updates: [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) and [golang.org/x/sys](https://github.com/golang/sys).


Updates `github.com/mattn/go-runewidth` from 0.0.30 to 0.0.31
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.30...v0.0.31)

Updates `golang.org/x/sys` from 0.48.0 to 0.49.0
- [Commits](https://github.com/golang/sys/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.31
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
- dependency-name: golang.org/x/sys
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-11 11:53:35 +00:00
Guan09 1d3267a44c fix(studio): make session tabs opt-in
Add a default-off Appearance preference for session tabs, persist the choice locally, and show the existing strip only when multiple session panes are open. Preserve single-session behavior and cover preference changes, persistence, and unread tab marks with interaction tests.
2026-10-11 17:00:56 +08:00
XTLine 95de31ed20 style(studio): keep restore actions visibly clickable at rest 2026-10-11 16:29:04 +08:00
par73e 9add93a143 fix(theme): make wallpaper transcript glow configurable 2026-10-11 12:59:54 +08:00
par73e 1c1c18bd39 feat(studio): add a shared send shortcut preference 2026-10-11 12:45:59 +08:00
yhh 46efe8eba4 feat(studio): web search model setting in model preferences 2026-10-10 08:36:00 -07:00
yhh 248071332a feat(studio): wide mode for the browser pane that keeps the conversation visible 2026-10-10 07:56:47 -07:00
par73e 6646758aad feat(studio): close browser pages individually or in bulk 2026-10-10 12:59:17 +00:00
XTLine 8d5655f96d Merge studio into fix/studio-restore-undo
Preserve upstream committed-file counts at the Pane-owned restore receipt.
2026-10-10 20:58:14 +08:00
XTLine 2ca8e60a01 style(studio): theme restore actions and bound error details 2026-10-10 17:06:53 +08:00
XTLine ac3673ff72 fix(studio): recover failed rewind presentation refreshes 2026-10-10 17:06:32 +08:00
XTLine 7613fd5d3e fix(studio): separate rewind mutation and receipt lifetimes 2026-10-10 17:06:18 +08:00
XTLine 172d61bba6 test(studio): cover restore reload and lifecycle boundaries 2026-10-10 14:53:14 +08:00
XTLine 0d85a11055 fix(studio): keep restore undo outside transcript cards 2026-10-10 14:52:40 +08:00
XTLine 29fb53efca fix(studio): own rewind receipts at conversation scope 2026-10-10 14:51:36 +08:00
yhh 987f44cafa test(serve): locate the installed plugin through its recorded root 2026-10-09 04:48:27 -07:00
yhh 0eb5a7ca2c test(plugins): cover the re-check after a blocked rename without a new exported seam 2026-10-09 04:38:49 -07:00
yhh c32b46c9fd fix(plugins): re-check the approved bytes before every publication attempt and keep the in-place swap off Windows 2026-10-09 04:28:55 -07:00
yhh 51f3100546 test(plugins): assert the planning-stage refusal of an unmaterializable symlink and an unchanged install after a refused replace 2026-10-09 03:45:57 -07:00
yhh 8aed0bc8ab Merge studio into feat/fix-11734-c 2026-10-09 01:53:14 -07:00
SivanCola 467707ce4b fix(studio): clarify default light theme surfaces and reading weight
Problem: Default light Studio uses nearly identical frame and reading surfaces,
medium-weight prose, and a broad composer shadow, making long answers feel dense.

Root cause: Reading panes share the page token, replies inherit the small-UI
weight, and the composer uses the generic light surface and shadow recipe.

Fix: Separate the default light reading and composer surfaces, use regular
weight for default reply prose, and shorten the composer shadow. Preserve
explicit weight choices, installed theme packs, wallpaper surface overrides,
dark styling, and layout. Consolidate the duplicate light palette and document
the tokens. Include mock-fixture before/after screenshots at both viewport sizes.

Verification: 68 focused frontend tests; TypeScript/Vite build; browser theme,
weight, wallpaper, draft-input, and geometry checks; gofmt; go vet ./...;
make lint; make check; go test ./internal/tools/builtin/ ./internal/assembly/boot/.
2026-10-09 15:02:36 +08:00
xiexian de06daf75a feat(studio): compact context from the ring and inspector 2026-10-08 17:06:23 +08:00
xiexian fd6b917c80 fix(studio): batch-check models before adding a source 2026-10-08 14:53:59 +08:00
xiexian 22ca8f3f17 feat(studio): select local sessions for batch actions 2026-10-08 09:12:57 +08:00
xiexian bb7d51e7a9 feat(studio): add project folders by dropping onto the sidebar 2026-10-08 07:45:55 +08:00
xiexian 8c644fa972 fix(studio): compact the untouched phone composer 2026-10-07 23:52:54 +08:00
xiexian 504101cb17 feat(studio): show current plan beside conversation navigation 2026-10-07 23:20:11 +08:00
xiexian 2a383830b6 fix(studio): put the current model first in the composer picker 2026-10-07 22:58:53 +08:00
xiexian 119dbd72ce feat(studio): open project actions from context menus 2026-10-07 22:32:30 +08:00
xiexian 2b23801457 feat(studio): offer quick archive and delete on session rows 2026-10-07 21:36:48 +08:00
xiexian 69a046fed3 feat(studio): quote selected reply text from the context menu 2026-10-07 20:54:23 +08:00
xiexian ec6ffb4887 feat(studio): fold feedback conversations and preserve unread replies 2026-10-07 20:36:51 +08:00
esengine 78069f638a fix(plugins): re-verify the approved bytes at publication and keep non-Windows replacement semantics 2026-10-03 02:22:21 -07:00
esengine f54da89d15 fix(plugins): bind the published plugin bytes to the approved preview and keep the publication failure typed 2026-10-03 00:31:46 -07:00
esengine 0b76ca0f63 fix(plugins): publish a replaced plugin directory without renaming a watched tree on Windows 2026-10-02 21:58:24 -07:00
398 changed files with 16858 additions and 1246 deletions

No files matched your search

+1 -1
View File
@@ -88,7 +88,7 @@ jobs:
with:
go-version-file: go.mod
cache: true
- run: node --test scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
- run: node --test scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/withdraw-version.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
# A matrix job skipped at job level reports no per-leg checks at all, so
+1
View File
@@ -136,6 +136,7 @@ jobs:
python3 -m http.server 4399 --directory dist-perf >/dev/null 2>&1 &
for _ in $(seq 30); do curl -sf http://localhost:4399/perf.html >/dev/null && break; sleep 1; done
node perf/intrinsic.mjs
node perf/firstscreen-fonts.mjs
node perf/contrast.mjs
node perf/wheel.mjs
node perf/branch-tooltip.mjs
@@ -0,0 +1,84 @@
name: Withdraw a Studio version
# Pulls a published Studio release out of the update catalog (or puts it back),
# so installs stop being offered it without waiting for a new release. Only the
# catalog changes: the release's files stay where they are, and a withdrawn
# release can be restored with the same run.
on:
workflow_dispatch:
inputs:
version:
description: "Version to withdraw or restore, for example 2.36.0"
required: true
type: string
reason:
description: "Why it is withdrawn (shown to users on that version); ignored when restoring"
required: false
type: string
restore:
description: "Put a withdrawn version back instead of withdrawing it"
required: false
type: boolean
default: false
concurrency:
group: studio-catalog
cancel-in-progress: false
jobs:
withdraw:
name: ${{ inputs.restore && 'restore' || 'withdraw' }} ${{ inputs.version }}
runs-on: ubuntu-latest
timeout-minutes: 10
environment: studio-release
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: studio
persist-credentials: false
- name: Update the Studio catalog
env:
VERSION: ${{ inputs.version }}
REASON: ${{ inputs.reason }}
RESTORE: ${{ inputs.restore }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
run: |
if [ "$HAS_R2" != "true" ]; then
echo "::error::R2 credentials are not configured, so the catalog cannot be changed"
exit 1
fi
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
echo "::error::version must look like 2.36.0"
exit 1
fi
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
aws configure set default.region auto
endpoint="https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com"
at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
current=/tmp/studio-versions-current.json
next=/tmp/studio-versions-next.json
aws s3 cp "s3://${R2_BUCKET}/studio/versions.json" "$current" --endpoint-url "$endpoint"
if [ "$RESTORE" = "true" ]; then
bash scripts/withdraw-version.sh "$current" --restore "$VERSION" "$at" > "$next"
jq -e --arg v "$VERSION" '.versions | map(select((.version | ltrimstr("v")) == $v)) | length == 1' "$next" >/dev/null
else
bash scripts/withdraw-version.sh "$current" "$VERSION" "$REASON" "$at" > "$next"
jq -e --arg v "$VERSION" '(.versions | map(select((.version | ltrimstr("v")) == $v)) | length == 0) and (.withdrawn | map(select((.version | ltrimstr("v")) == $v)) | length == 1)' "$next" >/dev/null
fi
aws s3 cp "$next" "s3://${R2_BUCKET}/studio/versions.json" \
--endpoint-url "$endpoint" \
--content-type "application/json; charset=utf-8" \
--cache-control "public, max-age=300, stale-if-error=86400"
echo "Studio catalog updated for $VERSION (restore=$RESTORE)"
- name: Keep the GitHub release off the latest slot
if: ${{ !inputs.restore }}
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: gh release edit "studio-v${VERSION}" --prerelease --latest=false --repo "$GITHUB_REPOSITORY"
+4 -4
View File
@@ -59,7 +59,7 @@
## ❤️Sponsor
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">Thanks to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support.</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code.</td></tr></table>
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">Thanks to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support. Register via <a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code. Register via <a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr></table>
## What it is
@@ -278,9 +278,9 @@ A small list of folks whose work has shaped Reasonix the most — the current to
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
<!-- reasonix-top-contributors:end -->
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
+4 -4
View File
@@ -57,7 +57,7 @@
## ❤️赞助商
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。</td></tr></table>
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。通过<a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。通过<a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr></table>
## 它是什么
@@ -260,9 +260,9 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
<!-- reasonix-top-contributors:end -->
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
+145
View File
@@ -0,0 +1,145 @@
package main
import (
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
// batchInfo is what the mode itself knows before any run happens. Everything
// else in the manifest is derived from the runs that were written, so a figure
// in it cannot drift from the artifact beside it.
type batchInfo struct {
Mode string
Planned int
Started time.Time
Dry bool
// Repo is the checkout the batch ran from, captured before the harness
// isolates its workspace: git cannot describe a directory that is gone.
Repo string
}
// batchManifest is written beside results.jsonl so a later reader does not have
// to reconstruct what the batch was from a terminal scrollback.
type batchManifest struct {
SchemaVersion int `json:"schema_version"`
Mode string `json:"mode"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
GitCommit string `json:"git_commit"`
GitDirty *bool `json:"git_dirty,omitempty"`
Model string `json:"model"`
Window int `json:"window"`
Generations int `json:"generations"`
TaskIDs []string `json:"task_ids"`
Arms []string `json:"arms"`
PlannedRuns int `json:"planned_runs"`
CompletedRuns int `json:"completed_runs"`
// Usage totals are absent when nothing reported any: an unmeasured cost must
// not read as a measured zero. UsageReportedRuns stays, because zero there
// is a fact about the batch.
UsageReportedRuns int `json:"usage_reported_runs"`
UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
PromptTokens int `json:"prompt_tokens,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"`
CacheHitTokens int `json:"cache_hit_tokens,omitempty"`
CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
UsageEstimated bool `json:"usage_estimated,omitempty"`
ResultsSHA256 string `json:"results_sha256"`
TrajectoriesSHA256 string `json:"trajectories_sha256"`
}
// gitCommitAndDirty reports the checkout the batch ran from. Unknown is written
// rather than omitted, so an absent field is never mistaken for a clean tree.
func gitCommitAndDirty(repo string) (commit string, dirty *bool) {
commit = "unknown"
if repo == "" {
return commit, nil
}
in := func(args ...string) ([]byte, error) {
cmd := exec.Command("git", args...)
cmd.Dir = repo
return cmd.Output()
}
if out, err := in("rev-parse", "--short", "HEAD"); err == nil {
commit = strings.TrimSpace(string(out))
}
if out, err := in("status", "--porcelain"); err == nil {
state := strings.TrimSpace(string(out)) != ""
dirty = &state
}
return commit, dirty
}
func fileSHA256(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", sha256.Sum256(data)), nil
}
func sortedKeys(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}
// writeBatchManifest records the batch beside the runs it describes. It runs
// last, so its digests are of what is already on disk.
func writeBatchManifest(root string, info batchInfo, all []contextMetrics) error {
ids, arms := map[string]bool{}, map[string]bool{}
var prompt, completion, cacheHit, cacheMiss, requests, reported int
estimated := false
for _, m := range all {
ids[m.Task] = true
arms[m.Arm] = true
if m.UsageReportedRequests > 0 {
reported++
requests += m.UsageReportedRequests
prompt += m.PromptTokens
completion += m.CompletionTokens
cacheHit += m.CacheHitTokens
cacheMiss += m.CacheMissTokens
estimated = estimated || m.UsageEstimated
}
}
commit, dirty := gitCommitAndDirty(info.Repo)
model := runModel
if info.Dry {
model = "scripted"
}
manifest := batchManifest{
SchemaVersion: 1, Mode: info.Mode,
StartedAt: info.Started.UTC().Format(time.RFC3339),
FinishedAt: time.Now().UTC().Format(time.RFC3339),
GitCommit: commit, GitDirty: dirty,
Model: model, Window: fixtureWindow, Generations: fixtureGenerations,
TaskIDs: sortedKeys(ids), Arms: sortedKeys(arms),
PlannedRuns: info.Planned, CompletedRuns: len(all),
UsageReportedRuns: reported, UsageReportedRequests: requests,
PromptTokens: prompt, CompletionTokens: completion,
CacheHitTokens: cacheHit, CacheMissTokens: cacheMiss, UsageEstimated: estimated,
}
var err error
if manifest.ResultsSHA256, err = fileSHA256(filepath.Join(root, "results.jsonl")); err != nil {
return err
}
if manifest.TrajectoriesSHA256, err = fileSHA256(filepath.Join(root, "trajectories.jsonl")); err != nil {
return err
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(root, "batch.json"), append(data, '\n'), 0o644)
}
@@ -0,0 +1,91 @@
package main
import (
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func seedArtifacts(t *testing.T, root string) {
t.Helper()
for _, name := range []string{"results.jsonl", "trajectories.jsonl"} {
if err := os.WriteFile(filepath.Join(root, name), []byte("{}\n"), 0o644); err != nil {
t.Fatal(err)
}
}
}
func readManifest(t *testing.T, root string) (batchManifest, string) {
t.Helper()
data, err := os.ReadFile(filepath.Join(root, "batch.json"))
if err != nil {
t.Fatal(err)
}
var got batchManifest
if err := json.Unmarshal(data, &got); err != nil {
t.Fatal(err)
}
return got, string(data)
}
func TestBatchManifestRecordsWhatTheBatchWas(t *testing.T) {
root := t.TempDir()
seedArtifacts(t, root)
all := []contextMetrics{
{Task: "b", Arm: "index-off"},
{Task: "a", Arm: "index-off"},
{Task: "a", Arm: "index-half", UsageReportedRequests: 2, PromptTokens: 100,
CompletionTokens: 10, CacheHitTokens: 60, CacheMissTokens: 40},
}
if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 4, Started: time.Now().Add(-time.Minute)}, all); err != nil {
t.Fatal(err)
}
got, _ := readManifest(t, root)
if got.SchemaVersion != 1 || got.Mode != "run-index" || got.PlannedRuns != 4 || got.CompletedRuns != 3 {
t.Fatalf("manifest header = %+v", got)
}
if strings.Join(got.TaskIDs, ",") != "a,b" || strings.Join(got.Arms, ",") != "index-half,index-off" {
t.Fatalf("ids/arms = %v / %v, want sorted and unique", got.TaskIDs, got.Arms)
}
if got.UsageReportedRuns != 1 || got.UsageReportedRequests != 2 || got.PromptTokens != 100 ||
got.CompletionTokens != 10 || got.CacheHitTokens != 60 || got.CacheMissTokens != 40 {
t.Fatalf("usage totals = %+v", got)
}
// No repo means git could not describe the checkout: unknown commit, and no
// dirty flag at all, because "clean" is a claim this batch cannot make.
if got.GitCommit != "unknown" || got.GitDirty != nil {
t.Fatalf("git state = %q / %v, want unknown and no dirty flag", got.GitCommit, got.GitDirty)
}
want := fmt.Sprintf("%x", sha256.Sum256([]byte("{}\n")))
if got.ResultsSHA256 != want || got.TrajectoriesSHA256 != want {
t.Fatalf("digests = %q / %q, want %q", got.ResultsSHA256, got.TrajectoriesSHA256, want)
}
}
// A dry batch pays nothing, so its cost fields must be absent rather than zero:
// an unmeasured spend must not read as a measured one.
func TestBatchManifestLeavesUnreportedUsageAbsent(t *testing.T) {
root := t.TempDir()
seedArtifacts(t, root)
all := []contextMetrics{{Task: "a", Arm: "index-off"}, {Task: "a", Arm: "index-half"}}
if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 2, Started: time.Now(), Dry: true}, all); err != nil {
t.Fatal(err)
}
got, raw := readManifest(t, root)
if got.UsageReportedRuns != 0 {
t.Fatalf("usage_reported_runs = %d, want 0 reported", got.UsageReportedRuns)
}
if got.Model != "scripted" {
t.Fatalf("dry batch model = %q, want scripted", got.Model)
}
for _, absent := range []string{"usage_reported_requests", "prompt_tokens", "completion_tokens", "cache_hit_tokens"} {
if strings.Contains(raw, absent) {
t.Fatalf("a dry batch wrote %q:\n%s", absent, raw)
}
}
}
+3 -3
View File
@@ -54,9 +54,9 @@ func main() {
case "preflight":
os.Exit(runPreflight(tasks, root))
case "run-search":
os.Exit(runExperiment(experimentSearch, root, *dry, tasks))
os.Exit(runExperiment(*mode, experimentSearch, root, *dry, tasks))
case "run-boundary":
os.Exit(runBoundaries(root))
os.Exit(runBoundaries(*mode, root))
case "snippet":
os.Exit(runSnippetAudit(root, 100))
case "adversarial":
@@ -69,7 +69,7 @@ func main() {
// stays measurable on its own.
batch = efficiencyTasks()
}
os.Exit(runExperiment(experimentIndex, root, *dry, batch))
os.Exit(runExperiment(*mode, experimentIndex, root, *dry, batch))
default:
fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
os.Exit(2)
+18 -6
View File
@@ -216,7 +216,10 @@ func isolateWorkspace() (func(), error) {
// The two arms get separate nonces: reusing one would leave the first arm's
// answer in a host-readable history for the second to find.
func runBoundaries(root string) int {
func runBoundaries(mode, root string) int {
started := time.Now()
planned := 0
repo, _ := os.Getwd()
root, err := filepath.Abs(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -243,6 +246,7 @@ func runBoundaries(root string) int {
has bool
}{{cue, true}, {noCue, false}} {
name := "index-" + side.scale
planned++
m, err := runOne(p, t, armFor(side.scale, false), name, root, side.has, false)
if err != nil {
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, name, err)
@@ -260,7 +264,7 @@ func runBoundaries(root string) int {
}
reportDeltas(cueSide, noCueSide)
fmt.Print(reportStopping(all))
return writeResults(root, all)
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Repo: repo}, all)
}
// reportDeltas is the paired reading: each task against itself, then pooled.
@@ -302,7 +306,10 @@ func deltaBool(a, b bool) string {
// runExperiment runs one experiment's tasks across its arms. A dry run drives
// the same pipeline with a scripted provider: it proves fixture, run, scoring
// and report hold together before any of it is paid for.
func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
func runExperiment(mode, experiment, root string, dry bool, tasks []contextTask) int {
started := time.Now()
planned := 0
repo, _ := os.Getwd()
root, err := filepath.Abs(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -338,6 +345,7 @@ func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
// test: a bad minute would land entirely on one arm.
for _, arm := range shuffledArms(t) {
cueVisible := t.Experiment == experimentIndex && cueExpectedVisible(t, arm.scale)
planned++
m, err := runOne(p, t, armFor(arm.scale, arm.searchOff), arm.name, root, cueVisible, dry)
if err != nil {
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, arm.name, err)
@@ -359,7 +367,7 @@ func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
if experiment == experimentIndex {
reportBoundaries(all)
}
return writeResults(root, all)
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Dry: dry, Repo: repo}, all)
}
func cueExpectedVisible(t contextTask, scale string) bool {
@@ -414,7 +422,7 @@ func writeTrajectories(root string, all []contextMetrics) error {
return nil
}
func writeResults(root string, all []contextMetrics) int {
func writeResults(root string, info batchInfo, all []contextMetrics) int {
path := filepath.Join(root, "results.jsonl")
f, err := os.Create(path)
if err != nil {
@@ -432,7 +440,11 @@ func writeResults(root string, all []contextMetrics) int {
fmt.Fprintln(os.Stderr, "write trajectories:", err)
return 2
}
fmt.Printf("\n%d runs written to %s (with trajectories.jsonl)\n", len(all), path)
if err := writeBatchManifest(root, info, all); err != nil {
fmt.Fprintln(os.Stderr, "write batch manifest:", err)
return 2
}
fmt.Printf("\n%d runs written to %s (with trajectories.jsonl and batch.json)\n", len(all), path)
return 0
}
+18 -5
View File
@@ -290,8 +290,8 @@ func renderDiff(r diffReport) string {
if r.runErr != nil {
fmt.Fprintf(&b, "\n<sub>agent run note: %v</sub>\n", r.runErr)
}
if r.analysisErr != nil {
fmt.Fprintf(&b, "\n**Analysis did not finish: %v.** Pin and mutation figures are absent, not zero.\n", r.analysisErr)
if line := analysisFailureLine(r); line != "" {
b.WriteString(line)
}
fmt.Fprintf(&b, "\n<sub>Pass = the agent added ≥1 test, the affected packages are green, AND ≥1 new test fails when the PR's source is reverted. \"By assertion\" pins are strong (they check changed behavior); \"by compile only\" pins just need a PR-added symbol — and since Go compiles per package, one compile-coupled test marks every test in its package that way. Mutation is the behavioral signal for additive PRs: each changed function's return is replaced with zero values and the new tests are re-run; \"caught\" means a test asserts that output, \"survived\" means it doesn't. Read the generated tests above to judge the rest.</sub>\n")
return b.String()
@@ -342,9 +342,6 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) (
_ = os.Remove(filepath.Join(repo, filepath.FromSlash(f)))
}
}
if err := treeMatches(repo, base, srcFiles); err != nil {
return nil, err
}
// Restore source on the way out; a tree left on `base` would mask the PR for
// later steps, so a failed restore is this analysis failing, not a note.
restored := false
@@ -359,6 +356,9 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) (
err = terr
}
}()
if err := treeMatches(repo, base, srcFiles); err != nil {
return nil, err
}
out = make([]pinResult, 0, len(refs))
for _, r := range refs {
@@ -681,3 +681,16 @@ func passFail(ok bool) string {
}
return "fail"
}
// analysisFailureLine says which figures a failed analysis leaves absent. A pin
// table that was already produced still stands, so claiming both are missing
// would be wrong whenever only the mutation sweep failed.
func analysisFailureLine(r diffReport) string {
if r.analysisErr == nil {
return ""
}
if len(r.pins) > 0 {
return fmt.Sprintf("\n**Analysis did not finish: %v.** The mutation figures are absent, not zero; the pin table above still stands.\n", r.analysisErr)
}
return fmt.Sprintf("\n**Analysis did not finish: %v.** Pin and mutation figures are absent, not zero.\n", r.analysisErr)
}
+14 -1
View File
@@ -53,6 +53,10 @@ type meterUsage struct {
DivergedAt int `json:"diverged_at,omitempty"`
Divergence string `json:"divergence,omitempty"`
TapeMissing int `json:"tape_missing,omitempty"`
// TapeIncomplete counts recorded exchanges that could not be written, so a
// recording that cannot be replayed is reported rather than assumed.
TapeIncomplete int `json:"tape_incomplete,omitempty"`
TapeError string `json:"tape_error,omitempty"`
}
// faultScript decides which requests fail. Absolute indices pin a failure to
@@ -220,7 +224,16 @@ func (m *meter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
src := io.Reader(resp.Body)
if m.tape != nil {
src = io.TeeReader(resp.Body, &captured)
defer func() { m.tape.save(index, body, resp.StatusCode, resp.Header.Get("Content-Type"), captured.Bytes()) }()
defer func() {
if err := m.tape.save(index, body, resp.StatusCode, resp.Header.Get("Content-Type"), captured.Bytes()); err != nil {
m.mu.Lock()
m.TapeIncomplete++
if m.TapeError == "" {
m.TapeError = err.Error()
}
m.mu.Unlock()
}
}()
}
if strings.Contains(resp.Header.Get("Content-Type"), "event-stream") {
m.pipeStream(w, src)
+4 -2
View File
@@ -47,12 +47,14 @@ func (rm runMeter) close() {
}
}
// record attaches what the proxy observed. It stays separate from close so the
// snapshot is taken while the run's numbers are still being assembled.
// record attaches what the proxy observed. It stops the meter first, because a
// handler's deferred write can land after the last response is read and stopping
// is what waits for those handlers; a snapshot taken before that misses them.
func (rm runMeter) record(r *result) {
if rm.m == nil {
return
}
rm.close()
observed := rm.m.snapshot()
r.Meter = &observed
}
+1
View File
@@ -202,6 +202,7 @@ func renderBody(results []result) string {
b.WriteString(renderAuxiliarySpend(s.bySource, s.currency))
b.WriteString(renderMeterAccounting(results))
b.WriteString(renderTapeReplay(results))
b.WriteString(renderTapeRecord(results))
b.WriteString(renderFaultRecovery(results))
b.WriteString(renderTimeAttribution(results))
b.WriteString(renderSolveProfiles(results))
+6 -4
View File
@@ -8,13 +8,15 @@ import (
"strings"
)
// Restoring the source is I/O like any other step, but a failure used to read as
// success: the analysis went on to report a verdict about a tree that was still
// mutated. These helpers make it loud, and are variables so tests can inject one.
// Restoring the source is I/O like any other step, and an analysis must not
// report a verdict about a tree it failed to restore. These helpers make that
// failure loud, and are variables so a test can inject one.
var (
writeFile = os.WriteFile
readFile = os.ReadFile
// treeMatches is a variable so a test can inject the mismatch a restore guards.
treeMatches = treeMatchesTree
)
// restoreSource writes the original bytes back and proves they are back.
@@ -34,7 +36,7 @@ func restoreSource(abs string, src []byte) error {
// treeMatches checks files against a revision. A checkout that exited zero is
// not evidence the tree moved; the tree is.
func treeMatches(repo, rev string, files []string) error {
func treeMatchesTree(repo, rev string, files []string) error {
args := append([]string{"-C", repo, "diff", "--exit-code", rev, "--"}, files...)
out, err := exec.Command("git", args...).CombinedOutput()
if err != nil {
+67
View File
@@ -192,3 +192,70 @@ func TestDifferentialPerTestFailsWhenRestoreFails(t *testing.T) {
t.Fatal("a failed restore still produced pin results")
}
}
// The base side is checked before any pin runs, and that check can fail too: the
// tree must not be left on base just because the failure happened early. The
// defer that restores is registered before the check for exactly this path.
func TestDifferentialPerTestRestoresWhenTheBaseCheckFails(t *testing.T) {
repo := t.TempDir()
run := func(args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
}
run("init", "-q")
if err := os.WriteFile(filepath.Join(repo, "f.go"), []byte("base\n"), 0o644); err != nil {
t.Fatal(err)
}
run("add", "f.go")
run("commit", "-qm", "base")
base := strings.TrimSpace(gitOut(repo, "rev-parse", "HEAD"))
if err := os.WriteFile(filepath.Join(repo, "f.go"), []byte("head\n"), 0o644); err != nil {
t.Fatal(err)
}
run("commit", "-aqm", "head")
orig := treeMatches
treeMatches = func(r, rev string, files []string) error {
if rev == base {
return errors.New("injected: base side does not match")
}
return treeMatchesTree(r, rev, files)
}
t.Cleanup(func() { treeMatches = orig })
out, err := differentialPerTest(repo, base, []string{"f.go"}, nil)
if err == nil {
t.Fatalf("a failed base check reported success with %d pins", len(out))
}
got, rerr := os.ReadFile(filepath.Join(repo, "f.go"))
if rerr != nil {
t.Fatal(rerr)
}
if strings.TrimSpace(string(got)) != "head" {
t.Fatalf("the tree was left on base: f.go = %q, want the PR's content", strings.TrimSpace(string(got)))
}
}
// A pin table that was already produced still stands, so the failure line must
// not claim both figures are missing when only the mutation sweep failed.
func TestAnalysisFailureLineNamesWhatIsMissing(t *testing.T) {
only := analysisFailureLine(diffReport{analysisErr: errors.New("boom"), pins: []pinResult{{}}})
if !strings.Contains(only, "mutation figures are absent") || !strings.Contains(only, "pin table above still stands") {
t.Fatalf("with pins present the line reads %q", only)
}
if strings.Contains(only, "Pin and mutation figures are absent") {
t.Fatalf("with pins present the line still claims both are absent: %q", only)
}
both := analysisFailureLine(diffReport{analysisErr: errors.New("boom")})
if !strings.Contains(both, "Pin and mutation figures are absent") {
t.Fatalf("with no pins the line reads %q", both)
}
if analysisFailureLine(diffReport{}) != "" {
t.Fatal("a finished analysis produced a failure line")
}
}
+54 -6
View File
@@ -107,6 +107,18 @@ func (c tapeConfig) forRun(taskID string, trial int) (*tape, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
// This run replaces the tape. Drop the old completeness markers first, or
// a failure here would leave the previous request beside this run's
// partial response and read as a complete exchange.
stale, err := filepath.Glob(filepath.Join(dir, "*.request.json"))
if err != nil {
return nil, err
}
for _, path := range stale {
if err := os.Remove(path); err != nil {
return nil, err
}
}
} else if _, err := os.Stat(dir); err != nil {
return nil, fmt.Errorf("no tape for %s: %w", taskID, err)
}
@@ -122,11 +134,26 @@ func (t *tape) file(index int, suffix string) string {
return filepath.Join(t.dir, fmt.Sprintf("%04d.%s", index, suffix))
}
func (t *tape) save(index int, request []byte, status int, contentType string, body []byte) {
meta, _ := json.Marshal(tapeResponse{Status: status, ContentType: contentType})
_ = os.WriteFile(t.file(index, "request.json"), request, 0o644)
_ = os.WriteFile(t.file(index, "response.json"), meta, 0o644)
_ = os.WriteFile(t.file(index, "response.body"), body, 0o644)
// tapeWriteFile is a variable so a test can inject a write failure.
var tapeWriteFile = os.WriteFile
// save records one exchange. request.json goes last: its presence is what tells
// a later replay the exchange is complete rather than truncated.
func (t *tape) save(index int, request []byte, status int, contentType string, body []byte) error {
meta, err := json.Marshal(tapeResponse{Status: status, ContentType: contentType})
if err != nil {
return fmt.Errorf("tape %d response meta: %w", index, err)
}
if err := tapeWriteFile(t.file(index, "response.json"), meta, 0o644); err != nil {
return fmt.Errorf("tape %d response.json: %w", index, err)
}
if err := tapeWriteFile(t.file(index, "response.body"), body, 0o644); err != nil {
return fmt.Errorf("tape %d response.body: %w", index, err)
}
if err := tapeWriteFile(t.file(index, "request.json"), request, 0o644); err != nil {
return fmt.Errorf("tape %d request.json: %w", index, err)
}
return nil
}
// load returns the recorded answer to request index, and where the request
@@ -142,7 +169,12 @@ func (t *tape) load(index int, request []byte) (resp tapeResponse, body []byte,
if body, err = os.ReadFile(t.file(index, "response.body")); err != nil {
return resp, nil, "", err
}
recorded, _ := os.ReadFile(t.file(index, "request.json"))
recorded, err := os.ReadFile(t.file(index, "request.json"))
if err != nil {
// Not a divergence: without the recorded request there is nothing to
// compare against, and reporting one would blame the harness.
return resp, nil, "", fmt.Errorf("tape %d is incomplete: %w", index, err)
}
return resp, body, requestDiff(recorded, request), nil
}
@@ -249,3 +281,19 @@ func renderTapeReplay(results []result) string {
}
return line + "\n\n"
}
// renderTapeRecord reports recordings that did not finish, so a run whose tape
// cannot be replayed says so instead of looking recorded.
func renderTapeRecord(results []result) string {
var incomplete []string
for _, r := range results {
if r.Meter == nil || r.Meter.TapeIncomplete == 0 {
continue
}
incomplete = append(incomplete, fmt.Sprintf("`%s` (%d exchanges): %s", r.ID, r.Meter.TapeIncomplete, r.Meter.TapeError))
}
if len(incomplete) == 0 {
return ""
}
return "**Tape record incomplete**: " + strings.Join(incomplete, " · ") + "\n\n"
}
+180
View File
@@ -1,11 +1,15 @@
package main
import (
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
const tapedStream = "data: {\"choices\":[{\"delta\":{\"content\":\"fixed\"}}]}\n\n" +
@@ -103,3 +107,179 @@ func TestRequestDiffReadsStructureBeforeBytes(t *testing.T) {
}
}
}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
// A recording whose request file cannot be written is reported rather than
// treated as a tape: request.json is what marks the exchange complete.
func TestTapeSaveFailureIsReportedOnTheMeter(t *testing.T) {
root := t.TempDir()
orig := tapeWriteFile
tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
if strings.HasSuffix(path, "request.json") {
return errors.New("disk full")
}
return orig(path, body, mode)
}
t.Cleanup(func() { tapeWriteFile = orig })
var calls atomic.Int32
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
defer stop()
readAll(t, post(t, base, "/chat/completions", tapedRequest))
var got meterUsage
waitFor(t, "the failed write to be reported", func() bool {
got = m.snapshot()
return got.TapeIncomplete > 0
})
if !strings.Contains(got.TapeError, "disk full") {
t.Fatalf("incomplete recording = %+v, want the write error named", got)
}
if markers, _ := filepath.Glob(filepath.Join(root, "bugfix-slug", "trial-1", "*.request.json")); len(markers) != 0 {
t.Fatalf("the completeness marker was written although the request write failed: %v", markers)
}
line := renderTapeRecord([]result{{task: task{ID: "bugfix-slug"}, Meter: &got}})
if !strings.Contains(line, "Tape record incomplete") || !strings.Contains(line, "bugfix-slug") {
t.Fatalf("report line = %q", line)
}
if renderTapeRecord(nil) != "" {
t.Fatal("an empty run set produced a tape line")
}
}
// A tape with no recorded request is incomplete, not a divergence: there is
// nothing to compare against, and blaming the harness would be wrong.
func TestTapeMissingRequestIsIncompleteNotADivergence(t *testing.T) {
root := t.TempDir()
var calls atomic.Int32
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
readAll(t, post(t, base, "/chat/completions", tapedRequest))
dir := filepath.Join(root, "bugfix-slug", "trial-1")
var markers []string
waitFor(t, "the recorded request", func() bool {
markers, _ = filepath.Glob(filepath.Join(dir, "*.request.json"))
return len(markers) > 0
})
stop()
if err := os.Remove(markers[0]); err != nil {
t.Fatal(err)
}
m, base, stop := tapedMeter(t, tapeReplay, root, &calls)
defer stop()
resp := post(t, base, "/chat/completions", tapedRequest)
readAll(t, resp)
got := m.snapshot()
if resp.StatusCode != http.StatusBadGateway || got.TapeMissing != 1 {
t.Fatalf("incomplete tape = %d, missing %d; want 502 and counted", resp.StatusCode, got.TapeMissing)
}
if got.DivergedAt != 0 || got.Divergence != "" {
t.Fatalf("an incomplete tape was reported as a divergence: %d %q", got.DivergedAt, got.Divergence)
}
}
// The run path snapshots the meter through record, which must stop the meter
// first: stopping is what waits for the handlers whose deferred writes carry the
// tape counters. Snapshotting before that misses the last exchange.
func TestMeterRecordDrainsHandlersBeforeSnapshot(t *testing.T) {
root := t.TempDir()
var calls atomic.Int32
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
defer stop()
readAll(t, post(t, base, "/chat/completions", tapedRequest))
var r result
runMeter{m: m, stop: stop}.record(&r)
// No waitFor: the drain inside record is what makes this observable.
if r.Meter == nil {
t.Fatal("record attached no meter")
}
if resp, err := http.Post(base+"/chat/completions", "application/json", strings.NewReader(tapedRequest)); err == nil {
resp.Body.Close()
t.Fatal("the meter was still serving after record, so its handlers were not drained")
}
}
// Re-recording over an existing tape must not let the previous run's
// completeness marker make a partial new exchange look complete.
func TestTapeReRecordClearsStaleCompletenessMarkers(t *testing.T) {
root := t.TempDir()
var calls atomic.Int32
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
readAll(t, post(t, base, "/chat/completions", tapedRequest))
dir := filepath.Join(root, "bugfix-slug", "trial-1")
waitFor(t, "the first recording", func() bool {
m, _ := filepath.Glob(filepath.Join(dir, "*.request.json"))
return len(m) > 0
})
stop()
// The second recording fails while writing the body, after the request has
// already been replaced in memory.
orig := tapeWriteFile
tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
if strings.HasSuffix(path, "response.body") {
return errors.New("disk full")
}
return orig(path, body, mode)
}
t.Cleanup(func() { tapeWriteFile = orig })
var calls2 atomic.Int32
m2, base2, stop2 := tapedMeter(t, tapeRecord, root, &calls2)
defer stop2()
readAll(t, post(t, base2, "/chat/completions", tapedRequest))
var r result
runMeter{m: m2, stop: stop2}.record(&r)
if markers, _ := filepath.Glob(filepath.Join(dir, "*.request.json")); len(markers) != 0 {
t.Fatalf("a failed re-record left completeness markers: %v", markers)
}
// Replay must call the exchange incomplete, not compare a stale request with
// a partial response.
m3, base3, stop3 := tapedMeter(t, tapeReplay, root, &calls2)
defer stop3()
resp := post(t, base3, "/chat/completions", tapedRequest)
readAll(t, resp)
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("replay of a partial tape = %d, want 502", resp.StatusCode)
}
if got := m3.snapshot(); got.DivergedAt != 0 {
t.Fatalf("a partial tape was reported as a divergence: %d %q", got.DivergedAt, got.Divergence)
}
}
// The healthy path must stay quiet: a recording that completed reports no
// incomplete exchange and prints no line about one. Only the failure paths were
// pinned before.
func TestTapeCompleteRecordingReportsNothingIncomplete(t *testing.T) {
root := t.TempDir()
var calls atomic.Int32
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
defer stop()
readAll(t, post(t, base, "/chat/completions", tapedRequest))
var r result
runMeter{m: m, stop: stop}.record(&r)
if r.Meter == nil {
t.Fatal("record attached no meter")
}
if r.Meter.TapeIncomplete != 0 || r.Meter.TapeError != "" {
t.Fatalf("a complete recording reported %d incomplete exchanges (%q)", r.Meter.TapeIncomplete, r.Meter.TapeError)
}
// renderTapeRecord is the printer behind the report's summary block, so an
// empty string here is what "no line is printed" means.
if line := renderTapeRecord([]result{{task: task{ID: "bugfix-slug"}, Meter: r.Meter}}); line != "" {
t.Fatalf("a complete recording printed %q, want no line", line)
}
}
+2 -2
View File
@@ -20,9 +20,9 @@
"computer-live": "electron test/computer_live.js"
},
"devDependencies": {
"electron": "44.5.1",
"electron": "44.7.0",
"electron-builder": "^26.15.3",
"playwright": "1.63.0"
"playwright": "1.64.0"
},
"pnpm": {
"onlyBuiltDependencies": [
+18 -18
View File
@@ -9,14 +9,14 @@ importers:
.:
devDependencies:
electron:
specifier: 44.5.1
version: 44.5.1([email protected])
specifier: 44.7.0
version: 44.7.0([email protected])
electron-builder:
specifier: ^26.15.3
version: 26.15.3([email protected])([email protected])
playwright:
specifier: 1.63.0
version: 1.63.0
specifier: 1.64.0
version: 1.64.0
packages:
@@ -374,8 +374,8 @@ packages:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'}
electron@44.5.1:
resolution: {integrity: sha512-lx7AYoFIiyvEvgvpyWO9kOC60xtNPpiPZAth6Fmg9NutzpCAMLwpUlaBY2EXfZyIgUdzEmLMj9KvK0Vrokl1yQ==}
electron@44.7.0:
resolution: {integrity: sha512-aUq9/zLAAFYc+a9gy6cd0C3Y/N/RmovLCD8TV8LhIzNhuND6HEgsu96jJz9cbAxvckSPv+Trf2PKEjktoZgFkw==}
engines: {node: '>= 22.12.0'}
hasBin: true
@@ -762,13 +762,13 @@ packages:
resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==}
engines: {node: '>=16.0.0'}
[email protected]3.0:
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
[email protected]4.0:
resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
engines: {node: '>=20'}
hasBin: true
[email protected]3.0:
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
[email protected]4.0:
resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
engines: {node: '>=20'}
hasBin: true
@@ -1551,7 +1551,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron@44.5.1([email protected]):
electron@44.7.0([email protected]):
dependencies:
'@electron-internal/extract-zip': 1.0.5
'@electron/get': 5.1.0([email protected])
@@ -1696,7 +1696,7 @@ snapshots:
es6-error: 4.1.1
matcher: 3.0.0
roarr: 2.15.4
semver: 7.8.5
semver: 7.7.4
serialize-error: 7.0.1
optional: true
@@ -1883,11 +1883,11 @@ snapshots:
[email protected]:
dependencies:
semver: 7.8.5
semver: 7.7.4
[email protected]:
dependencies:
semver: 7.8.5
semver: 7.7.4
[email protected]:
dependencies:
@@ -1896,7 +1896,7 @@ snapshots:
graceful-fs: 4.2.11
nopt: 9.0.0
proc-log: 6.1.0
semver: 7.8.5
semver: 7.7.4
tar: 7.5.22
tinyglobby: 0.2.17
undici: 6.28.0
@@ -1942,11 +1942,11 @@ snapshots:
pvutils: 1.2.0
tslib: 2.8.1
[email protected]3.0: {}
[email protected]4.0: {}
[email protected]3.0:
[email protected]4.0:
dependencies:
playwright-core: 1.63.0
playwright-core: 1.64.0
[email protected]:
dependencies:
+4
View File
@@ -152,6 +152,10 @@ class BrowserViews {
if (id !== targetId) this.putAway(other.view, other);
}
this.shown = targetId;
// A page that was parked comes back as a view that is attached but whose
// widget never repaints until the window's geometry changes; attaching it
// again is what wakes it, the same as after a minimize.
if (!entry.at) this.win.contentView.removeChildView(entry.view);
this.win.contentView.addChildView(entry.view);
entry.view.setBounds(at);
entry.at = at;
+4 -1
View File
@@ -17,7 +17,7 @@ const { uiLanguage } = require("./uilang");
const { installFullScreenKey } = require("./fullscreen");
const { installReload } = require("./reload");
const { externalTarget } = require("./links");
const { reveal, revealWorkspace } = require("./reveal");
const { reveal, revealWorkspace, revealLogs } = require("./reveal");
const { appIcon } = require("./appicon");
const layout = require("./layout");
const { offerCleanup } = require("./legacy");
@@ -415,6 +415,9 @@ ipcMain.handle("shell:open-external", (event, raw) => {
ipcMain.handle("shell:reveal-workspace", (event, root) =>
fromWindow(event) && client ? revealWorkspace(client, shell, String(root)) : { code: "", error: "no window" },
);
ipcMain.handle("shell:reveal-logs", (event) =>
fromWindow(event) ? revealLogs(shell, logs?.dir) : { error: "no window" },
);
ipcMain.handle("shell:reveal", (event, base, rel) =>
fromWindow(event) && client ? reveal(client, shell, String(base), String(rel)) : { code: "", error: "no window" },
);
+1
View File
@@ -32,6 +32,7 @@ contextBridge.exposeInMainWorld("reasonixHost", {
revealPath: (base, rel) => ipcRenderer.invoke("shell:reveal", String(base), String(rel)),
// A project the sidebar lists, which may have no pane at all.
revealWorkspace: (root) => ipcRenderer.invoke("shell:reveal-workspace", String(root)),
revealLogs: () => ipcRenderer.invoke("shell:reveal-logs"),
// Where a dropped file lives. Resolved here rather than in the page: the
// renderer is handed a File and never a path, and a turn that has to work on
// the file itself cannot do it on a copy of the bytes.
+10 -1
View File
@@ -67,4 +67,13 @@ async function revealWorkspace(client, shell, root, platform = process.platform)
return null;
}
module.exports = { reveal, revealWorkspace };
// The shell's own logs folder. The shell names it, never the page, and it is
// opened rather than selected: it is a directory this app created. null means
// it was shown.
async function revealLogs(shell, dir) {
if (!dir) return { error: "the logs folder is unknown" };
const failed = await shell.openPath(String(dir));
return failed ? { error: failed } : null;
}
module.exports = { reveal, revealWorkspace, revealLogs };
+59 -1
View File
@@ -205,7 +205,7 @@ test("an unreachable kernel is an answer, not a crash", async () => {
assert.equal(await dead.trayState(), null);
});
const { reveal, revealWorkspace } = require("../src/reveal.js");
const { reveal, revealWorkspace, revealLogs } = require("../src/reveal.js");
// A kernel that answers /workspace/locate as the test says, and a shell that
// only records what it was asked to open.
@@ -2049,3 +2049,61 @@ test("tray: a refused close-to-tray save redraws the checkbox, and a menu that f
assert.equal(menus[1].template.find((item) => item.type === "checkbox").checked, false);
tray.close();
});
test("revealLogs opens only the folder the shell itself names, and reports why it could not", async () => {
const opened = [];
const shell = { openPath: async (target) => (opened.push(target), "") };
assert.equal(await revealLogs(shell, "/user/logs"), null);
assert.deepEqual(opened, ["/user/logs"]);
const refusing = { openPath: async () => "no application is associated with this folder" };
assert.deepEqual(await revealLogs(refusing, "/user/logs"), { error: "no application is associated with this folder" });
assert.deepEqual(await revealLogs(shell, ""), { error: "the logs folder is unknown" });
});
// The built-in browser drew a white page after switching away from a tab and
// back until the window was resized: a parked view moved onscreen with
// setBounds alone does not repaint. Bringing it back attaches it again.
test("a parked page is attached again when it is shown, and a page already on screen is left alone", () => {
const Module = require("node:module");
const load = Module._load;
Module._load = function (request, ...rest) {
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
return load.call(this, request, ...rest);
};
let BrowserViews;
try {
({ BrowserViews } = require("../src/browserviews.js"));
} finally {
Module._load = load;
}
const calls = [];
const win = {
on() {},
isDestroyed: () => false,
getContentBounds: () => ({ width: 1200, height: 800 }),
contentView: { addChildView: () => calls.push("add"), removeChildView: () => calls.push("remove") },
};
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const view = { setBounds: (b) => calls.push("bounds " + b.x), webContents: { isDestroyed: () => false } };
views.entries.set("t1", { view, at: null });
const rect = { x: 10, y: 20, width: 600, height: 400 };
views.show("t1", rect);
assert.deepEqual(calls, ["remove", "add", "bounds 10"]);
calls.length = 0;
views.show("t1", rect);
assert.deepEqual(calls, [], "the same rectangle on a page already shown does no work");
views.hide();
calls.length = 0;
views.show("t1", rect);
assert.deepEqual(calls, ["remove", "add", "bounds 10"], "a page put away and shown again is attached again");
});
test("rewind notice dismissal survives the desktop preference disk round trip", (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rewind-notice-prefs-"));
const file = path.join(dir, "prefs.json");
t.after(() => { fs.rmSync(file, { force: true }); fs.rmdirSync(dir); });
const record = { "rx-rewind-notice-dismissed:tx-saved": "1" };
savePrefs(file, record);
assert.deepEqual(loadPrefs(file), record);
});
+1
View File
@@ -61,6 +61,7 @@ node perf/concurrent.mjs # 多会话并发的守卫:9 个会话同时流式,
node perf/wheel.mjs # 滚轮轨迹(只报告、不判红):向上滚时 scrollTop 不倒走、单步不超滚轮、scrollHeight 不缩水;WHEEL_STRICT=1 判红
node perf/idle.mjs # 闲置的守卫:看不见的东西不许动,闲着的窗口不许烧 CPU
node perf/effects.mjs # Effects guard: a sky pack idles quietly, a hidden window draws no sky, Reduced removes blur, sky and grain
node perf/text-glow.mjs # Transcript glow, pack switching and prose contrast across schemes, wallpapers, widths, effects and motion
node perf/shipped.mjs # 产物的守卫:dist 里的东西必须比源码新(构建可以成功而编的是别的树)
node perf/reason.mjs # 内核拒绝的双语落地:同一个码,中英各说各的
node perf/budget.mjs # 预算的守卫:输入框上下堆满东西时,转录还剩多少
+43
View File
@@ -146,6 +146,49 @@ for (const state of ["idle", "running"]) {
}
check("扫到的对话列覆盖 360–700px", [...swept].some((w) => w <= 360) && [...swept].some((w) => w >= 700), [...swept].sort((a, b) => a - b).slice(0, 1).concat([...swept].sort((a, b) => b - a).slice(0, 1)).join("–"));
// An untouched phone composer leaves room to read; focus or a draft restores
// the editing controls without replacing the textarea or its caret.
const phone = () => page.evaluate(() => {
const box = document.querySelector('textarea[role="combobox"]');
const shown = (selector) => document.querySelector(selector)?.getBoundingClientRect().height > 0;
const tools = [...document.querySelectorAll(".turntools > *")]
.filter((el) => el.getBoundingClientRect().height > 0);
const rows = new Set(tools.map((el) => {
const r = el.getBoundingClientRect();
return Math.round((r.top + r.height / 2) / 2);
}));
return { height: box.getBoundingClientRect().height, line: parseFloat(getComputedStyle(box).lineHeight),
refine: shown(".studio-refine"), branch: shown(".studio-branch-pop"), rows: rows.size };
});
await page.fill(BOX, "");
await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); });
await page.setViewportSize({ width: 390, height: 760 });
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(BOX);
await page.click('[role="tab"][aria-selected="true"]');
await frame();
const quiet = await phone();
check("手机:空置输入只占一行", quiet.height <= quiet.line + 1);
check("手机:空置时收起优化提示,分支提示仍在", !quiet.refine && quiet.branch);
check("手机:空置工具栏只占一行", quiet.rows === 1);
await page.click(BOX);
await frame();
const focused = await phone();
check("手机:聚焦恢复编辑空间与优化入口", focused.height > quiet.height && focused.refine);
await page.fill(BOX, "保留草稿\n第二行\n第三行");
await page.click('[role="tab"][aria-selected="true"]');
await frame();
const draft = await phone();
check("手机:未聚焦草稿仍展开", draft.height >= draft.line * 3 - 1 && draft.refine);
check("手机:展开后草稿原样保留", await page.inputValue(BOX) === "保留草稿\n第二行\n第三行");
await page.fill(BOX, "");
await page.getByRole("button", { name: "引用到输入框", exact: true }).last().click();
await page.waitForSelector(".shots");
await page.click('[role="tab"][aria-selected="true"]');
await frame();
const quoted = await phone();
check("手机:仅有引用附件也保留编辑空间", quoted.height > quiet.height && quoted.refine);
await browser.close();
if (fails.length) {
console.error(`\n${fails.length} 项不合格:\n ` + fails.join("\n "));
@@ -0,0 +1,33 @@
// The first screen is drawn from the core CJK face (about 1.9 MB), not the 7.8 MB full font. The full font is a
// later family in the stack for characters the core lacks: it must not be fetched for ordinary UI text, and it
// must still load when a character only it has is shown.
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const browser = await chromium.launch();
const page = await (await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } })).newPage();
const fetched = [];
page.on("response", (r) => {
const m = /\/fonts\/(noto-studio[^/?]*)/.exec(r.url());
if (m) fetched.push(m[1]);
});
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 15000 });
await page.waitForTimeout(1500);
const fails = [];
if (fetched.includes("noto-studio.woff2")) fails.push("the first screen fetched the full 7.8 MB font");
if (!fetched.includes("noto-studio-core.woff2")) fails.push("the first screen did not use the core font");
console.log(fails.length ? `FAIL ${fails.join("; ")} (${fetched.join(", ")})` : ` ok first screen fetched ${fetched.join(", ")} only`);
await page.evaluate(() => {
const d = document.createElement("div");
d.style.cssText = "position:fixed;left:0;top:0;font:32px var(--ui)";
d.textContent = "龘";
document.body.append(d);
});
await page.waitForTimeout(1500);
const rare = fetched.includes("noto-studio.woff2");
console.log(rare ? " ok a character only the full font has still loads it" : "FAIL a rare character did not load the full font");
await browser.close();
process.exit(fails.length || !rare ? 1 : 0);
+15 -1
View File
@@ -5,6 +5,16 @@ import "../src/styles/tokens.css";
import "../src/styles/app.css";
import "../src/styles/dock.css";
import "../src/styles/studio.css";
import "../src/styles/changedpill.css";
import "../src/styles/chart.css";
import "../src/styles/community.css";
import "../src/styles/providerpresets.css";
import "../src/styles/sessionnotes.css";
import "../src/styles/sessionquick.css";
import "../src/styles/sidebardrop.css";
import "../src/styles/sponsor.css";
import "../src/styles/versions.css";
import "../src/styles/phonecomposer.css";
import "../src/styles/tips.css";
import "../src/styles/waitnote.css";
import "../src/styles/narrow.css";
@@ -34,6 +44,7 @@ let peakInFlight = 0;
// ?pack=sky opens on a pack with the live sky; ?pack=photo on one with a
// picture. Both are the states where the shell goes translucent over a backdrop.
const PACK = new URLSearchParams(location.search).get("pack");
const GLOW = new URLSearchParams(location.search).get("glow");
function benchPack(): ThemePack | null {
const tokens = {
@@ -49,7 +60,10 @@ function benchPack(): ThemePack | null {
if (PACK === "photo") {
return {
id: "bench-photo", name: "Bench photo", active: true, tokens,
background: { image: true, focusX: 0.5, focusY: 0.5, homeOpacity: 0.9, taskOpacity: 0.5, overlayStrength: 0.4 },
background: {
image: true, focusX: 0.5, focusY: 0.5, homeOpacity: 0.9, taskOpacity: 0.5, overlayStrength: 0.4,
...(GLOW === "none" || GLOW === "default" ? { textGlow: GLOW } : {}),
},
};
}
return null;
+53
View File
@@ -0,0 +1,53 @@
// 跨屏选区:把选区从靠前的一块拉到很远的一块,量画面稳住要多久、挂了多少块,
// 以及复制出来的文字是不是真文本而不是空占位。
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const TURNS = Number(process.env.TURNS ?? 300);
const browser = await chromium.launch();
const page = await (await browser.newContext({ viewport: { width: 1440, height: 900 } })).newPage();
const fails = [];
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${PAGE}?turns=${TURNS}`, { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 15000 });
await page.waitForTimeout(1500);
await page.evaluate(() => {
const flow = document.querySelector('[data-pane="flow"]');
flow.scrollTop = 0;
});
await page.waitForTimeout(500);
const run = (from, to) =>
page.evaluate(
({ from, to }) =>
new Promise((resolve) => {
const chunks = [...document.querySelectorAll(".chunk")];
const mounted = () => chunks.filter((c) => c.childElementCount > 0).length;
const before = mounted();
const first = chunks[from].querySelector("p, div") ?? chunks[from];
const range = document.createRange();
range.setStart(first, 0);
range.setEnd(chunks[to], 0);
const t0 = performance.now();
const sel = document.getSelection();
sel.removeAllRanges();
sel.addRange(range);
requestAnimationFrame(() =>
requestAnimationFrame(() =>
setTimeout(() => {
const text = sel.toString();
resolve({ ms: Math.round(performance.now() - t0), before, after: mounted(), total: chunks.length, chars: text.length, lines: text.split("\n").filter(Boolean).length });
}, 0),
),
);
}),
{ from, to },
);
const total = await page.evaluate(() => document.querySelectorAll(".chunk").length);
for (const [from, to] of [[2, Math.floor(total / 3)], [2, Math.floor(total / 2)], [2, total - 3]]) {
const r = await run(from, to);
console.log(`选区 块${from}→块${to}: ${r.ms}ms 挂载 ${r.before}→${r.after}/${r.total} 复制 ${r.chars} 字 ${r.lines} 行`);
}
await browser.close();
if (fails.length) { console.log(fails.join("\n")); process.exit(1); }
+132
View File
@@ -0,0 +1,132 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const before = process.env.GLOW_BEFORE === "1";
const shots = process.env.GLOW_SCREENSHOTS;
if (shots) await mkdir(shots, { recursive: true });
const browser = await chromium.launch();
let checked = 0;
const errors = [];
async function open(scheme, paper, width, effects, motion, glow) {
const context = await browser.newContext({ viewport: { width, height: width === 390 ? 844 : 900 }, reducedMotion: motion });
await context.addInitScript(({ scheme, effects }) => {
localStorage.setItem("rx-theme", scheme);
localStorage.setItem("rx-effects", effects);
}, { scheme, effects });
const page = await context.newPage();
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/themes/bench-photo/background", (route) => route.fulfill({
contentType: "image/svg+xml",
body: `<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000"><rect width="1600" height="1000" fill="${paper === "bright" ? "#fff9e5" : "#081329"}"/><circle cx="1250" cy="300" r="280" fill="${paper === "bright" ? "#eebc7c" : "#253d69"}"/></svg>`,
}));
await page.goto(`${BASE}?pack=photo&glow=${glow}&turns=2&ws=1&sess=2`, { waitUntil: "networkidle" });
await page.waitForSelector('.flow:not([data-empty])');
await page.evaluate(() => document.fonts.ready);
await page.waitForFunction(({ before, effects }) => {
const value = getComputedStyle(document.querySelector('.flow:not([data-empty])')).textShadow;
return before && effects === "full" ? value.includes("7px") : value === "none";
}, { before, effects });
return { context, page };
}
async function styles(page) {
return page.locator('.flow:not([data-empty])').first().evaluate((el) => {
const own = getComputedStyle(el);
const backing = getComputedStyle(el, "::before");
return {
shadow: own.textShadow,
blur: backing.backdropFilter || backing.webkitBackdropFilter,
backing: backing.backgroundColor,
content: backing.content,
frameShadow: getComputedStyle(document.querySelector(".rail")).textShadow,
};
});
}
try {
for (const scheme of ["light", "dark"]) {
for (const width of [1440, 390]) {
for (const paper of before ? [scheme === "light" ? "bright" : "dark"] : ["bright", "dark"]) {
for (const effects of before ? ["full"] : ["full", "reduced"]) {
for (const motion of before ? ["no-preference"] : ["no-preference", "reduce"]) {
const { context, page } = await open(scheme, paper, width, effects, motion, "none");
const off = await styles(page);
if (before) assert.notEqual(off.shadow, "none");
else assert.equal(off.shadow, "none");
assert.notEqual(off.content, "none");
if (effects === "full") {
assert.match(off.blur, /blur\(11px\)/);
assert.notEqual(off.frameShadow, "none");
} else {
assert.equal(off.frameShadow, "none");
}
if (shots && paper === (scheme === "light" ? "bright" : "dark") && effects === "full" && motion === "no-preference") {
await page.screenshot({ path: `${shots}/${scheme}-${width === 390 ? "phone" : "wide"}-${before ? "before" : "after"}.jpg`, type: "jpeg", quality: 88 });
}
if (!before) {
const samples = await page.locator('.flow p').evaluateAll((els) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d", { willReadFrequently: true });
return els.flatMap((el) => {
const r = el.getBoundingClientRect();
if (r.width < 10 || r.height < 10 || r.top < 0 || r.bottom > innerHeight) return [];
if (!el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return [];
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
if (!hit || !el.contains(hit)) return [];
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = getComputedStyle(el).color;
ctx.fillRect(0, 0, 1, 1);
return [{ x: Math.floor(r.left + r.width / 2), y: Math.floor(r.top + r.height / 2), text: el.textContent, fg: [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 3) }];
});
});
assert.ok(samples.length, "visible transcript prose must be measured");
const hiding = await page.addStyleTag({ content: ".flow * { color: transparent !important; text-shadow: none !important }" });
const blank = (await page.screenshot()).toString("base64");
const ratios = await page.evaluate(async ({ blank, samples }) => {
const img = new Image();
img.src = `data:image/png;base64,${blank}`;
await img.decode();
const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
ctx.canvas.width = img.width; ctx.canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const lum = (c) => c.map((v) => v / 255).map((v) => v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4).reduce((sum, v, i) => sum + v * [.2126, .7152, .0722][i], 0);
return samples.map(({ x, y, fg }) => {
const bg = [...ctx.getImageData(x, y, 1, 1).data].slice(0, 3);
const [hi, lo] = [lum(fg), lum(bg)].sort((a, b) => b - a);
return (hi + .05) / (lo + .05);
});
}, { blank, samples });
await hiding.evaluate((el) => el.remove());
assert.ok(Math.min(...ratios) >= 4.5, `prose contrast ${Math.min(...ratios).toFixed(2)}:1 ${JSON.stringify(samples)}`);
await page.evaluate(async () => {
const { apply } = await import("/src/ui/theme.ts");
const tokens = {
light: { bg: "#F2F6F8", bgSoft: "#F9FCFD", panel: "#FFFFFF", border: "#CBD9E0", fg: "#12191C", fgDim: "#4E5D65", accent: "#0E6E82" },
dark: { bg: "#080D10", bgSoft: "#0C1316", panel: "#131C21", border: "#23333A", fg: "#E4EEF2", fgDim: "#8298A2", accent: "#4FB6CE" },
};
const background = { image: true, focusX: .5, focusY: .5, homeOpacity: .9, taskOpacity: .5, overlayStrength: .4 };
apply({ id: "bench-photo", name: "Old pack", tokens, background }, document.documentElement.dataset.theme);
});
if (effects === "full") await page.waitForFunction(() => getComputedStyle(document.querySelector('.flow:not([data-empty])')).textShadow.includes("7px"));
const legacy = await styles(page);
assert.equal(legacy.backing, off.backing);
assert.equal(legacy.blur, off.blur);
if (effects === "full") assert.match(legacy.shadow, /7px/);
else assert.equal(legacy.shadow, "none");
checked += 2;
console.log(`ok ${scheme} ${width} ${paper} ${effects} ${motion}; prose min ${Math.min(...ratios).toFixed(2)}:1`);
}
await context.close();
}
}
}
}
}
assert.deepEqual(errors, []);
console.log(before ? "4 baseline views captured" : `${checked} glow/default scenarios passed; no page errors`);
} finally {
await browser.close();
}
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Cut the core face out of public/fonts/noto-studio.woff2.
The core face carries what a first screen asks for: the common simplified-Chinese
set shipped as NotoSansSC-subset.woff2, Latin, punctuation and arrows. The full
font stays as a later family in the stack, so a character the core lacks still
finds its glyph there and only then pays for the download.
Usage: slice-noto-studio.py <full.woff2> <core-out.woff2>
Needs fontTools and brotli.
"""
import sys
from fontTools import subset
from fontTools.ttLib import TTFont
COMMON = "NotoSansSC-subset.woff2"
BLOCKS = [(0x20, 0x24F), (0x2000, 0x206F), (0x2190, 0x23FF),
(0x2500, 0x257F), (0x25A0, 0x25FF), (0x2600, 0x27BF), (0x3000, 0x303F), (0xFF00, 0xFFEF)]
def main(full_path, core_path):
full = set(TTFont(full_path).getBestCmap())
common = set(TTFont(full_path.replace("noto-studio.woff2", COMMON)).getBestCmap())
core = {p for p in full if p in common or any(a <= p <= b for a, b in BLOCKS)}
opts = subset.Options()
opts.flavor = "woff2"
opts.layout_features = ["*"]
opts.notdef_outline = True
font = subset.load_font(full_path, opts)
s = subset.Subsetter(opts)
s.populate(unicodes=sorted(core))
s.subset(font)
subset.save_font(font, core_path, opts)
print(f"core: {len(core)} of {len(full)} characters")
if __name__ == "__main__":
main(*sys.argv[1:3])
+22 -2
View File
@@ -60,11 +60,16 @@ export const ACTIONS: UIAction[] = [
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.nav-rail", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
{ id: "session.more", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "session.selection", kind: "view", target: "optional", proof: "interaction" },
{ id: "session.batch-archive", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "session.batch-delete", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
{ id: "browser.open", kind: "view", target: "none", proof: "interaction" },
{ id: "browser.wide", kind: "view", target: "none", proof: "interaction" },
{ id: "browser.back", kind: "navigation", target: "none", proof: "interaction" },
{ id: "browser.reload", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "browser.address", kind: "view", target: "none", proof: "static" },
@@ -78,6 +83,7 @@ export const ACTIONS: UIAction[] = [
{ id: "chrome.receipt", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.receipt-line", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.keep-awake", kind: "view", target: "none", proof: "interaction" },
{ id: "composer.send-shortcut", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold-preset", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold-preview", kind: "view", target: "none", proof: "interaction" },
@@ -162,6 +168,7 @@ export const ACTIONS: UIAction[] = [
{ id: "skill.enabled", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "mcp.enabled", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "mcp.retry", kind: "repeatable", target: "entity", proof: "interaction" },
{ id: "mcp.trust", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "mcp.remove", kind: "destructive", target: "entity", proof: "interaction" },
{ id: "mcp.load", kind: "kernel-mutation", target: "entity", proof: "interaction" },
@@ -203,11 +210,13 @@ export const ACTIONS: UIAction[] = [
{ id: "feedback.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.another", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.thread.more", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.thread.toggle", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "feedback.reply.open", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.body", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.reply.cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.mine", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.logs", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
@@ -358,7 +367,9 @@ export const ACTIONS: UIAction[] = [
{ id: "file-revert.commit", kind: "destructive", target: "entity", proof: "authority-effect" },
{ id: "rewind.prepare", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "rewind.commit", kind: "destructive", target: "entity", proof: "authority-effect" },
{ id: "session.reload", kind: "repeatable", target: "entity", proof: "interaction" },
{ id: "rewind.undo", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "rewind.notice-dismiss", kind: "view", target: "entity", proof: "interaction" },
{ id: "extensions.submit", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "provider.probe", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "provider.draft", kind: "interaction", target: "none", proof: "interaction" },
@@ -387,6 +398,8 @@ export const ACTIONS: UIAction[] = [
{ id: "compaction.threshold", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// Which bound is in force is the setting; how much of it is on screen is not.
{ id: "compaction.advanced", kind: "view", target: "none", proof: "interaction" },
{ id: "compaction.confirm", kind: "view", target: "none", proof: "interaction" },
{ id: "compaction.now", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "panel.move", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "panel.delegate-placement", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "context.window-tokens", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -476,25 +489,32 @@ export const ACTIONS: UIAction[] = [
{ id: "settings.search", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.isolate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "reasoning.effort", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "workbench.new-browser", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.new", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.launch-files", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.launch-changes", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.launch-browser", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.tab", kind: "view", target: "entity", proof: "interaction" },
{ id: "workbench.close", kind: "view", target: "entity", proof: "interaction" },
{ id: "workbench.browser-menu", kind: "view", target: "entity", proof: "interaction" },
{ id: "workbench.browser-close", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "workbench.close", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "workbench.mode", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.save", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "workbench.search", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.clear-search", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.folder", kind: "view", target: "entity", proof: "interaction" },
{ id: "workbench.diff", kind: "view", target: "entity", proof: "interaction" },
{ id: "files.open", kind: "navigation", target: "none", proof: "interaction" },
{ id: "workbench.files", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.hidden", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.reveal", kind: "shell-native", target: "optional", proof: "interaction" },
{ id: "workbench.copy-path", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "workbench.copy-ref", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "workbench.menu", kind: "view", target: "entity", proof: "interaction" },
{ id: "palette.pick", kind: "view", target: "entity", proof: "interaction" },
{ id: "palette.search", kind: "view", target: "none", proof: "interaction" },
{ id: "reply.quote", kind: "view", target: "entity", proof: "interaction" },
{ id: "reply.branch", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "reply.selection.menu", kind: "view", target: "entity", proof: "interaction" },
{ id: "reply.retry", kind: "view", target: "entity", proof: "interaction" },
{ id: "reply.retry-now", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "reply.configure", kind: "view", target: "none", proof: "interaction" },
+38
View File
@@ -21,6 +21,8 @@ import { EN_COMMUNITY } from "./en_community";
import { EN_SPONSOR } from "./en_sponsor";
export const EN: Record<string, string> = {
"加宽浏览器": "Widen browser",
"恢复浏览器分栏": "Restore browser split",
"工作台": "Workbench",
"从右侧选择文件,或打开浏览器": "Choose a file on the right, or open a browser",
...EN_SETTINGS,
@@ -93,6 +95,9 @@ export const EN: Record<string, string> = {
"复制这段回答": "Copy this answer",
"复制这段代码": "Copy this code",
"复制路径": "Copy path",
"复制引用": "Copy reference",
"此名称不能表示为 @ 引用": "This name cannot be represented as an @ reference",
"此名称可能被识别为 MCP 资源": "This name could resolve to an MCP resource",
"思考 {chars}": "Thought · {chars}",
"耗时 {t}": "Worked for {t}",
"思考 {secs} 秒 · {chars}": "Thought for {secs}s · {chars}",
@@ -142,6 +147,10 @@ export const EN: Record<string, string> = {
"活动": "Activity",
"轨迹": "Trajectory",
"对话": "Conversation",
"展开对话": "Expand conversation",
"收起对话": "Collapse conversation",
"{n} 条回复": "{n} replies",
"{n} 条新回复": "{n} new replies",
"事件轨迹": "Event trail",
"运行图": "Run graph",
"你说过的话": "What you said",
@@ -543,6 +552,13 @@ export const EN: Record<string, string> = {
"忘记": "Forget",
"重连": "Reconnect",
"立即连接": "Connect now",
"定义有改动": "Definition changed",
"无法核对": "Cannot be checked",
"等待你批准的工具": "Tools waiting for your approval",
"这个服务有 {n} 个工具的定义与你批准过的不一致,已暂时不交给模型:": "{n} of this service's tools differ from what you approved and are withheld from the model for now:",
"还有 {n} 个没有列出,批准时会一并生效。": "{n} more are not listed; approving covers them too.",
"批准并重新连接": "Approve and reconnect",
"批准中…": "Approving…",
"记的是 {had},但它答的是 {got}。": "Recorded as {had}, but it answers as {got}.",
// 收工回执
"没有未经验证的部分": "nothing left unverified",
@@ -750,8 +766,16 @@ export const EN: Record<string, string> = {
"计划已移入右栏": "The plan is in the right rail",
"收工检查": "Wrap-up check",
"查看其后续使用的简报": "See the digest it carries forward",
"操作已完成,但会话刷新失败": "The operation completed, but the conversation could not refresh",
"重试刷新会话": "Retry session refresh",
"正在还原…": "Restoring…",
"会话已切换,请重新选择还原位置": "The conversation changed. Choose a restore point again.",
"撤销这次还原": "Undo this restore",
"回退已完成": "Rewind completed",
"关闭回退提示": "Dismiss rewind notice",
"无法保存关闭状态:{error}": "Could not save dismissal: {error}",
"撤销这次回退": "Undo this rewind",
"正在撤销…": "Undoing…",
"仍还原其余部分": "Restore the rest anyway",
"还原失败": "Could not restore",
"回退范围内未修改任何文件": "No files changed in this rewind range",
@@ -1139,7 +1163,9 @@ export const EN: Record<string, string> = {
"置顶": "Pinned",
"归档": "Archived",
"归档会话": "Archive session",
"归档会话:{title}": "Archive session: {title}",
"取消归档": "Unarchive",
"取消归档:{title}": "Unarchive: {title}",
"操作执行前由你确认": "Ask before an operation runs",
"只查看内容,不允许写入": "View content without allowing writes",
"当前内核暂不支持会话级只读模式": "The current runtime does not support session-level read-only mode yet",
@@ -1155,6 +1181,10 @@ export const EN: Record<string, string> = {
"仅显示当前模型实际支持的档位。": "Only levels actually supported by this model are shown.",
"按模型生效": "Per model",
"查看上下文与压缩": "View context and compaction",
"立即压缩": "Compact now",
"确认压缩": "Confirm compaction",
"压缩当前上下文?": "Compact the current context?",
"模型将依据摘要继续工作;完整对话记录会保留。": "The model will continue from a summary. The full conversation will be preserved.",
"查看本轮费用": "View turn cost",
"查看钱包余额": "View wallet balance",
"余额不足": "Balance is low",
@@ -1290,7 +1320,12 @@ export const EN: Record<string, string> = {
"未开始": "Not started",
"任务清单": "Task list",
"引用到输入框": "Quote into the composer",
"引用选中内容": "Quote selected text",
"复制选中内容": "Copy selected text",
"选中内容": "Selected text",
"重新生成": "Regenerate",
"分支为新会话": "Branch into a new conversation",
"分支": "branch",
"当前回复会留在运行历史里": "The current reply stays in the run history",
"按当前配置重试": "Retry as configured",
"先调整模型与强度": "Adjust the model and effort first",
@@ -1368,6 +1403,9 @@ export const EN: Record<string, string> = {
"The staged diff is long; the draft reads only its first part",
"这个工作区不是 git 仓库,无法提交":
"This workspace is not a git repository, so there is nothing to commit",
"回合运行中,等它结束后再分支": "A turn is running; branch after it ends",
"这个位置无法分支(重启后恢复的会话,或已被压缩的历史)": "This point cannot be branched (a session restored after a restart, or history that was compacted)",
"分支失败,请重试": "Branching failed; try again",
"暂存区是空的,先用 git add 暂存要提交的文件":
"Nothing is staged; stage the files to commit with git add first",
"暂存区在你确认之后又变了,请重新起草":
@@ -76,6 +76,9 @@ export const EN_FEEDBACK: Record<string, string> = {
"入口": "Surface",
"渠道": "Channel",
"只读。不包含密钥、地址或任何文件内容。": "Read-only. It contains no keys, addresses or file contents.",
"打开日志文件夹": "Open logs folder",
"维护者向你要日志时,打开这个文件夹,把最新的 shell.log 和 host.log 发给我们。发之前可以先自己看一眼。": "When a maintainer asks for logs, open this folder and send us the newest shell.log and host.log. You can read them first.",
"无法打开日志文件夹": "The logs folder could not be opened.",
"去 GitHub": "Go to GitHub",
"重试发送": "Retry sending",
"正文不能为空,请写下你遇到的情况。": "The text is empty. Please describe what happened.",
@@ -9,6 +9,7 @@ export const EN_KERNEL: Record<string, string> = {
"内核繁忙,这次操作可能没有被收到,重试前请先确认": "The kernel is busy; the action may not have been received - check before retrying",
"无法连接内核,这次操作可能没有送达,重试前请先确认": "The kernel cannot be reached; the action may not have been delivered - check before retrying",
"本界面无响应,这次操作可能没有送达内核,重试前请先确认": "This window is unresponsive; the action may not have reached the kernel - check before retrying",
"无法关闭这个网页:{error}": "Could not close this page: {error}",
"递归删除目标受保护或超出授权范围;请使用工作区或授权目录内的字面路径": "The recursive delete target is protected or outside the granted roots; use a literal path inside the workspace or a granted directory",
"无法确定递归删除范围;请使用字面命令名、路径和解释器内容": "The recursive delete extent is unknown; use a literal command name, path and interpreter payload",
"请拆分命令;递归删除前只能使用字面路径切换目录,不能依赖变量赋值": "Split the call; only literal directory changes may precede a recursive delete, without relying on variable assignments",
@@ -25,6 +26,11 @@ export const EN_KERNEL: Record<string, string> = {
"所需写入范围:{paths}": "Requested write claim: {paths}",
"参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试": "The arguments break this tool's contract, so the call did not run; complete or correct them per the tool description and retry",
"另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试": "Another session holds the requested write claim. This operation did not run; end the current turn and retry after the claim is released",
"搜索模型「{model}」的格式应为 服务/模型": "Search model “{model}” must be written as service/model",
"搜索模型「{model}」所在的连接已被移出": "Search model “{model}”'s connection has been removed",
"搜索模型「{model}」已不在配置里": "Search model “{model}” is no longer configured",
"搜索模型「{model}」的协议不支持原生搜索,或已关闭搜索": "Search model “{model}” has no native search on its protocol, or search is turned off for it",
"搜索模型「{model}」所在的连接没有凭据": "Search model “{model}”'s connection has no credentials",
"反馈通道今日已满,请明天再试": "Feedback is at capacity for today - try again tomorrow",
"这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试": "This report has reached its reply limit, or you replied too often - try again later",
"这份反馈现在不接收回复": "This report takes no reply right now",
@@ -195,6 +201,7 @@ export const EN_KERNEL: Record<string, string> = {
"默认模型「{model}」不在已选择的模型中": "The default model “{model}” is not among the ones you picked",
"显示名称最多 {max} 个字符": "A display name can be at most {max} characters",
"显示名称不能包含换行或其他控制字符": "A display name cannot contain line breaks or other control characters",
"显示别名必须使用已选择模型的完整 ID": "Display aliases must use the exact IDs of selected models",
"该协议不发送思考参数,启用后不会生效": "This protocol never sends thinking parameters — turning it on does nothing",
"该协议在轮次之间不保留状态,无需选择续接方式": "This protocol keeps no state between turns, so there is nothing to choose",
"该协议不支持由端点自行搜索": "This protocol has no wire format for a search the endpoint runs itself",
+37 -2
View File
@@ -2,6 +2,24 @@
// alone — one catalogue is still the model, and this file is read as its
// continuation: same keying by Chinese source text, same grouping by screen.
export const EN_SETTINGS: Record<string, string> = {
"{name} 的显示别名": "Display alias for {name}",
"显示别名(留空使用模型 ID)": "Display alias (empty uses model ID)",
"仅用于显示,不改变请求中的模型 ID": "Display only; the model ID sent in requests is unchanged",
"浏览器标签": "Browser tabs",
"关闭其他浏览器标签": "Close other browser tabs",
"关闭全部浏览器标签": "Close all browser tabs",
"无法关闭 {name}:{why}": "Could not close {name}: {why}",
"发送快捷键": "Send shortcut",
"用于任务输入、待发送消息编辑与历史消息重发。输入法确认候选词时不会发送。": "Applies to task input, editing queued messages, and resending past messages. Confirming an input-method candidate never sends.",
"Enter 发送": "Enter to send",
"{key} 发送": "{key} to send",
"{key} 发送 · Enter 换行": "{key} to send · Enter for a new line",
"{key} 插话 · Enter 换行": "{key} to steer · Enter for a new line",
"Shift+Enter 换行;触屏设备使用发送按钮。": "Shift+Enter for a new line; use the send button on touch devices.",
"Enter 换行,也可点击发送按钮。": "Enter for a new line, or click the send button.",
"会话标签栏": "Session tabs",
"显示会话标签栏": "Show session tabs",
"打开多个会话时,在顶部显示标签,方便切换会话。": "Show tabs at the top when multiple sessions are open, for quick switching.",
"步骤 1 · 连接服务": "Step 1 · Connect service",
"先连接服务,再选择这个来源要启用的模型。": "Connect the service, then choose which models to enable from it.",
"来源名称": "Source name",
@@ -13,6 +31,18 @@ export const EN_SETTINGS: Record<string, string> = {
"{n} 个来源": "{n} sources",
"模型服务": "Model services",
"模型偏好": "Model preferences",
"独立搜索请求使用的模型": "Model for independent search requests",
"项目配置覆盖:实际使用 {model};此处保存的是全局设置。": "Project override: {model} is used; this saves the global preference.",
"自动:使用对话模型自带的搜索({model})": "Automatic: the conversation model's built-in search ({model})",
"对话模型没有内置搜索;需要联网搜索时请选择一个搜索模型": "The conversation model has no built-in search; pick a search model to search the web",
"搜索模型的格式应为 服务/模型": "The search model must be written as service/model",
"搜索模型所在的连接已被移出": "The search model's connection has been removed",
"搜索模型已不在配置里": "The search model is no longer configured",
"搜索模型的协议不支持原生搜索,或已关闭搜索": "The search model's protocol has no native search, or search is turned off for it",
"搜索模型所在的连接没有凭据": "The search model's connection has no credentials",
"项目配置覆盖了这一项;此处保存的是全局设置。": "The project config overrides this; what you set here is saved globally.",
"尚无可用搜索模型": "No search models available",
"{model}(不可用)": "{model} (unavailable)",
"模型用途": "Model uses",
"用途": "Use",
"使用模型": "Model",
@@ -24,8 +54,8 @@ export const EN_SETTINGS: Record<string, string> = {
"来自项目配置,需在项目里修改": "Set by the project config; change it there",
"在「模型服务」添加决策来源": "Add a decision source under Model services",
"按用途选择模型": "Models by use",
"默认模型用于当前对话和大多数任务,其他用途默认跟随它;只有要为某件事换一个模型时才改。切换会保留对话并重建运行时,任务执行期间无法修改。":
"The default model runs this conversation and most tasks, and every other use follows it unless you give that job a model of its own. Switching keeps the conversation and rebuilds the runtime; it cannot be done while a task runs.",
"默认模型用于当前对话和大多数任务,其他用途默认跟随它;只有要为某件事换一个模型时才改。网页搜索默认用对话模型自带的搜索;指定独立搜索模型后,每次搜索多一次计费,且不带对话上下文。切换会保留对话并重建运行时,任务执行期间无法修改。":
"The default model runs this conversation and most tasks, and every other use follows it unless you give that job a model of its own. Web search uses the conversation model's built-in search by default; assigning a search model adds one billed request per search and sends it no conversation context. Switching keeps the conversation and rebuilds the runtime; it cannot be done while a task runs.",
"添加模型服务": "Add model service",
"搜索已添加的服务": "Search added services",
"上移 {name}(Alt+上方向键)": "Move {name} up (Alt+Up Arrow)",
@@ -583,6 +613,10 @@ export const EN_SETTINGS: Record<string, string> = {
"{err} —— 本地功能不受影响,请稍后重试。": "{err} — nothing local is affected; try again later.",
"固定版本为 {pinned},当前运行的是 {current}": "Pinned to {pinned}; what is running is {current}",
"有新版本 {v}": "{v} is out",
"当前版本 {v} 已被撤回": "{v} was withdrawn",
"撤回原因:{reason}。": "Reason: {reason}. ",
"请更新到 {v},可在下方对应行安装。": "Update to {v} from its row below.",
"请在下方选择一个版本。": "Pick a version below.",
"切换到 {v} 失败": "Switching to {v} failed",
"已固定在 {v},不再提示新版本": "Pinned to {v}; newer versions are not offered",
"回退时会固定在所选版本,免得新版本提示把你带回刚离开的版本。": "A rollback pins the version you chose, so an update prompt does not take you back to the one you left.",
@@ -996,6 +1030,7 @@ export const EN_SETTINGS: Record<string, string> = {
"天": "days",
"无法读取自动归档设置。": "Could not read the auto-archive settings.",
"已归档": "Archived",
"已置顶": "Pinned",
"已自动归档 {n} 个会话,可在“已归档”中恢复。": "{n} conversations were archived automatically. You can restore them from Archived.",
"图标栏": "Icon rail",
"会话侧栏左边的一列图标,直达会话、用量、文件、工具、扩展、记忆、远程、反馈、账号和设置。窗口窄到手机宽度时不显示。": "A column of icons left of the session sidebar that opens conversation, usage, files, tools, extensions, memory, remote, feedback, account and settings. It is not drawn when the window is as narrow as a phone.",
@@ -24,8 +24,10 @@ export const EN_TRAJECTORY: Record<string, string> = {
"系统": "System",
"信号轨": "Signal rail",
"与下方共用时间轴": "Shares the timeline below",
"没有时长、也不是工具调用的运行记录(最多前 8 条),按发生的时刻摆在时间轴上,所以位置就是时间,无法拖动;点一个看它的记录。":
"Records of the run that have no duration and are not tool calls (the first 8 at most), placed on the timeline at the moment they happened. Their position is their time, so they cannot be dragged; click one to see its record.",
"时间轴缩放": "Timeline zoom",
"重置缩放": "Reset zoom",
"没有时长、也不是工具调用的运行记录,位置就是时间,无法拖动;缩放后可横向滚动,重叠信号分行显示。点一个看它的记录。":
"Records with no duration that are not tool calls. Their position is their time, so they cannot be dragged. Zoom and scroll horizontally; overlapping signals use separate lanes. Select one to inspect its record.",
"活动回合": "Activity rounds",
"点击一行查看记录": "Select a row to inspect it",
"模型回合": "Model round",
+20 -1
View File
@@ -3,6 +3,21 @@
// en_settings and en_kernel were — one screen's worth of wording, read together.
export const EN_WINDOW: Record<string, string> = {
"选择本机会话": "Select local sessions",
"选择会话:{name}": "Select session: {name}",
"已选 {n} 个会话": "{n} sessions selected",
"完成选择": "Done selecting",
"全选当前显示的会话": "Select all displayed sessions",
"取消当前显示会话的选择": "Deselect displayed sessions",
"取消全选": "Deselect all",
"全选可见": "Select visible",
"归档所选会话": "Archive selected sessions",
"取消所选会话归档": "Unarchive selected sessions",
"删除所选会话": "Delete selected sessions",
"所选会话正在运行,停止后才能操作": "Selected sessions are running. Stop them before continuing.",
"正在处理所选会话…": "Processing selected sessions…",
"删除 {n} 个会话?": "Delete {n} sessions?",
"请先打开一个会话,再查看它的文件。": "Open a session first to browse its files.",
"运行指标": "Run metrics",
"时间线": "Timeline",
"定位": "Locate",
@@ -25,6 +40,7 @@ export const EN_WINDOW: Record<string, string> = {
"计划已生成,如何执行由你决定。": "The plan is written. What happens next is your call.",
"开始执行": "Start execution",
"修改计划": "Revise the plan",
"目标": "Goal",
"暂不执行": "Not now",
"「修改计划」将保持在计划模式中,请在下方说明需要修改之处。": "“Revise the plan” stays in plan mode — say what to change below and the planner rewrites it.",
"已开始执行。": "Executing.",
@@ -34,6 +50,9 @@ export const EN_WINDOW: Record<string, string> = {
"继续规划。": "Still planning.",
"在下方说明需要修改的内容,规划者将据此重写该计划。": "Say what to change below and the planner rewrites the plan.",
"主导航": "Main navigation",
"松开以添加项目文件夹": "Drop to add a project folder",
"请一次拖入一个文件夹": "Drop one folder at a time",
"无法取得本机路径,请使用添加文件夹": "The local path is unavailable. Use Add folder instead.",
"浏览器": "Browser",
"空白页": "Blank page",
"智能体正在查看的页面": "Page the agent is viewing",
@@ -83,7 +102,7 @@ export const EN_WINDOW: Record<string, string> = {
"失败步数": "Failed",
"待确认": "Awaiting you",
"内置 Browser": "Built-in Browser",
"新建浏览器标签": "New browser tab",
"新建标签": "New tab",
"调整浏览器宽度": "Resize the browser",
"模型正在操作这个页面": "The model is working on this page",
"网页地址": "Web address",
+11
View File
@@ -66,6 +66,7 @@ const SAID: Record<string, string> = {
"provider.default_not_selected": "默认模型「{model}」不在已选择的模型中",
"provider.display_name_too_long": "显示名称最多 {max} 个字符",
"provider.display_name_invalid": "显示名称不能包含换行或其他控制字符",
"provider.model_alias_unlisted": "显示别名必须使用已选择模型的完整 ID",
// ── 来源:这个协议做不到 ─────────────────────────────────────────
"provider.no_thinking_param": "该协议不发送思考参数,启用后不会生效",
@@ -105,6 +106,11 @@ const SAID: Record<string, string> = {
"model.decision_only": "「{model}」是决策来源,只服务 system_one 的询问,不能用作对话模型",
"model.not_decision_source": "「{model}」是对话模型,不是决策来源;决策只能选择「模型服务」里添加的决策来源",
"roles.override_not_in_user_config": "「{key}」不在用户配置里,可能已被清除,或来自项目配置",
"roles.search_model_bad_ref": "搜索模型「{model}」的格式应为 服务/模型",
"roles.search_model_not_added": "搜索模型「{model}」所在的连接已被移出",
"roles.search_model_removed": "搜索模型「{model}」已不在配置里",
"roles.search_model_unsupported": "搜索模型「{model}」的协议不支持原生搜索,或已关闭搜索",
"roles.search_model_no_credentials": "搜索模型「{model}」所在的连接没有凭据",
"shell.editing_disabled": "这台服务器未开放 shell 设置",
"account.signin_disabled": "这台服务器未开放账号登录",
"backup.signed_out": "登录账号后才能使用云备份",
@@ -160,6 +166,7 @@ const SAID: Record<string, string> = {
// ── 来源:连接与授权 ─────────────────────────────────────────────
"provider.editing_disabled": "这台服务器不允许修改模型来源",
"browser.close_failed": "无法关闭这个网页:{error}",
"browser.open_failed": "打不开这个网页:{error}",
"browser.network_path": "内置浏览器不会打开指向网络上另一台机器的路径,只按路径写法拒绝,不会去访问",
"browser.engine_missing": "没有找到可用的浏览器。请安装 Chrome、Edge 或 Chromium,或在配置里用 [browser] executable 指定路径,新会话才会读到",
@@ -244,6 +251,10 @@ const SAID: Record<string, string> = {
"prompt_refine.failed": "优化失败,请重试",
"prompt_refine.bad_request": "优化请求格式不正确",
"conversation_branch.turn_running": "回合运行中,等它结束后再分支",
"conversation_branch.unavailable": "这个位置无法分支(重启后恢复的会话,或已被压缩的历史)",
"conversation_branch.failed": "分支失败,请重试",
"commit.no_repository": "这个工作区不是 git 仓库,无法提交",
"commit.nothing_staged": "暂存区是空的,先用 git add 暂存要提交的文件",
"commit.staged_changed": "暂存区在你确认之后又变了,请重新起草",
@@ -21,6 +21,11 @@ export const NOTICE_TEXT: Record<string, string> = {
suspected_injection: "一条外部内容看起来在向智能体下指令,已提醒它只当资料看待",
await_user: "等待你的输入",
default_model_unavailable: "配置里保存的默认模型已不在已配置的供应商中,本次改用第一个可用的模型;在设置里重新选择默认模型即可替换,配置文件未被改动",
web_search_model_bad_ref: "搜索模型的格式应为 服务/模型,搜索已关闭;到模型偏好里重新选择,或恢复自动",
web_search_model_not_added: "所选搜索模型所在的连接已被移出,搜索已关闭;到模型偏好里重新选择,或恢复自动",
web_search_model_removed: "所选搜索模型已不在配置里,搜索已关闭;到模型偏好里重新选择,或恢复自动",
web_search_model_unsupported: "所选搜索模型的协议不支持原生搜索,或已关闭搜索,搜索已关闭;到模型偏好里重新选择,或恢复自动",
web_search_model_no_credentials: "所选搜索模型所在的连接没有凭据,搜索已关闭;到模型偏好里重新选择,或恢复自动",
approval_mode_unrecognized: "配置里的默认审批档位本版本不认识,已按每次询问处理;在界面里选一个档位即可替换",
memory_migration_backup: "旧版记忆文件没能先备份,所以没有迁移,保持原样;详情里有具体原因",
inbox_recovered: "已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”",
+5
View File
@@ -7,6 +7,8 @@ import "./styles/tokens.css";
import "./styles/app.css";
import "./styles/dock.css";
import "./styles/studio.css";
import "./styles/planpeek.css";
import "./styles/phonecomposer.css";
import "./styles/tips.css";
import "./styles/waitnote.css";
import "./styles/narrow.css";
@@ -21,6 +23,9 @@ import "./styles/versions.css";
import "./styles/changedpill.css";
import "./styles/sessionnotes.css";
import "./styles/providerpresets.css";
import "./styles/sidebardrop.css";
import "./styles/sessionquick.css";
import "./styles/sessionpin.css";
import { App } from "./ui/App";
import { SseHub } from "./port/hub";
import { HttpError } from "./port/http_error";
+18
View File
@@ -55,6 +55,11 @@ export interface HostPort {
/** Show a project the sidebar lists, which needs no pane. The hub answers
* where it is and refuses any folder it does not list. */
revealWorkspace(root: string): Promise<Refusal | null>;
/** Whether this shell can open the folder it writes its own logs to. */
revealsLogs(): boolean;
/** Open that folder in the platform's file manager; null once shown,
* otherwise why it was not. */
revealLogs(): Promise<Refusal | null>;
/** Whether this shell draws the agent's browser pages inside the window. */
drawsBrowserViews(): boolean;
/** Draw one of the agent's pages over rect, in on-screen coordinates, and
@@ -150,6 +155,7 @@ interface ElectronBridge {
pickFolder(startIn: string): Promise<string>;
revealPath?(base: string, path: string): Promise<Refusal | null>;
revealWorkspace?(root: string): Promise<Refusal | null>;
revealLogs?(): Promise<Refusal | null>;
showBrowserView?(target: string, rect: ViewRect): Promise<void>;
hideBrowserView?(): Promise<void>;
freezeBrowserView?(): Promise<string>;
@@ -223,6 +229,12 @@ class ElectronHost implements HostPort {
revealWorkspace(root: string) {
return this.api.revealWorkspace?.(root) ?? Promise.resolve({ error: "this shell cannot show files" });
}
revealsLogs() {
return typeof this.api.revealLogs === "function";
}
revealLogs() {
return this.api.revealLogs?.() ?? Promise.resolve({ error: "this shell cannot show files" });
}
// A shell older than the verbs has no views to draw, and says so by lacking them.
drawsBrowserViews() {
return typeof this.api.showBrowserView === "function";
@@ -298,6 +310,12 @@ class BrowserHost implements HostPort {
revealWorkspace() {
return Promise.resolve({ error: "a browser tab cannot show files" });
}
revealsLogs() {
return false;
}
revealLogs() {
return Promise.resolve({ error: "a browser tab cannot show files" });
}
drawsBrowserViews() {
return false;
}
@@ -12,3 +12,11 @@ it("offers reveal only when the shell can show both a pane's file and a project"
expect((await hostOver({ revealPath: async () => null, revealWorkspace: async () => null })).revealsFiles()).toBe(true);
expect((await hostOver({ revealPath: async () => null })).revealsFiles()).toBe(false);
});
it("offers the logs folder only when the shell can open it, and passes its refusal through", async () => {
expect((await hostOver({})).revealsLogs()).toBe(false);
const refusing = await hostOver({ revealLogs: async () => ({ error: "no such folder" }) });
expect(refusing.revealsLogs()).toBe(true);
expect(await refusing.revealLogs()).toEqual({ error: "no such folder" });
expect(await (await hostOver({ revealLogs: async () => null })).revealLogs()).toBeNull();
});
+2 -1
View File
@@ -13,6 +13,7 @@ export interface ThemeBackground {
homeOpacity: number;
taskOpacity: number;
overlayStrength: number;
textGlow?: "default" | "none";
}
// A live backdrop the window draws, rather than a picture it places. Absent on
@@ -36,7 +37,7 @@ export interface ThemePack {
sky?: ThemeSky;
hasPreview?: boolean;
removable?: boolean;
// Tokens the kernel dropped and why: an unknown name, or a value that could
// Values the kernel dropped and why: an unknown name, or a value that could
// not be let into a stylesheet. The pack still loads without them.
warnings?: string[];
}
+14
View File
@@ -28,6 +28,20 @@ export interface McpEntry {
launch?: string;
// Why a pending server waits for the user, as the kernel's code.
pendingReason?: string;
// Tools this connection withheld from the model because their definition is
// not the one the user approved. heldDigest names exactly that set, and
// approving it hands the digest back so what is approved is what was shown.
// heldMore counts withheld tools past the list's cap.
held?: McpHeld[];
heldDigest?: string;
heldMore?: number;
}
// One withheld tool and how it differs from what was approved: added, changed,
// or unverifiable when the approval record could not be read.
export interface McpHeld {
name: string;
class: string;
}
// always puts a server's tools in every request; deferred reaches them through
+29 -6
View File
@@ -1,6 +1,6 @@
import type { PlanAction } from "./session";
import { HttpError } from "./port";
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import { MockWorkspace } from "./mock_workspace";
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
@@ -17,7 +17,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
private log: WireEvent[] = [];
// What the user has sent, so checkpoints() can mirror one per turn.
private prompts: string[] = [];
private undone: string[] | null = null;
private undone: { prompts: string[]; offer: RewindUndo } | null = null;
private at = 0;
// The kernel numbers every frame a client cannot afford to miss, and a
// bootstrap cut is read against those numbers.
@@ -67,6 +67,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
// The subagent runs somewhere cheaper; everything else rides the main model.
private assigned: RoleAssignments = {
web_search: "",
planner: "",
subagent: "deepseek/deepseek-flash",
guardian: "",
@@ -335,6 +336,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
}
async newSession() {
this.clearUndo();
this.log = [];
this.at = 0;
this.state.goal = "";
@@ -404,6 +406,10 @@ export class MockPort extends MockWorkspace implements AgentPort {
return this.prompts.map((prompt, i) => ({ turn: i, prompt, files: this.prompts.length > 1 ? 3 : 0, msgIndex: mockMsgIndex(i + 1) }));
}
async branchAt(turn: number): Promise<string> {
return `/mock/branch-${turn}.jsonl`;
}
// The second prompt onwards is scripted to have run bash, so mock mode can
// show the consent stage the real kernel demands on partial coverage.
async prepareRewind(turn: number, scope: RewindScope): Promise<RewindPlan> {
@@ -425,13 +431,26 @@ export class MockPort extends MockWorkspace implements AgentPort {
async commitRewind(planId: string): Promise<RewindResult> {
const turn = Number(planId.split("-")[2] ?? 0), files = !planId.endsWith("conversation") && this.prompts.length > 1;
this.undone = this.prompts.slice();
this.prompts = this.prompts.slice(0, turn);
return { ok: true, transactionId: `mock-tx-${turn}`, undoAvailable: true, written: files ? ["main.ts", "style.css"] : [], deleted: files ? ["note.txt"] : [] };
const scope = planId.split("-")[3];
const offer = { transactionId: `mock-tx-${turn}-${crypto.randomUUID()}`, turn, files: files ? 3 : 0 };
this.undone = { prompts: this.prompts.slice(), offer };
this.emit({ kind: "rewind_changed" });
if (scope !== "code") this.prompts = this.prompts.slice(0, turn);
return { ok: true, transactionId: offer.transactionId, undoAvailable: true, conversationOk: scope !== "code",
written: files ? ["main.ts", "style.css"] : [], deleted: files ? ["note.txt"] : [] };
}
private clearUndo() {
if (!this.undone) return;
this.undone = null;
this.emit({ kind: "rewind_changed" });
}
async availableUndo(): Promise<RewindUndo | null> { return this.state.running ? null : this.undone?.offer ?? null; }
async undoRewind(_transactionId: string): Promise<void> {
if (this.undone) this.prompts = this.undone;
if (this.undone) this.prompts = this.undone.prompts;
this.clearUndo();
}
subscribe(onEvent: (ev: WireEvent) => void, _onGap?: () => void, bootstrap?: () => Promise<number>) {
@@ -585,6 +604,8 @@ export class MockPort extends MockWorkspace implements AgentPort {
return { id: "mock", target: "mock", url, title: url, active: true };
}
async browserClose(_tab: string): Promise<void> {}
async queue(): Promise<Queue> {
return {
revision: this.queueRevision,
@@ -660,6 +681,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
this.emit({ kind: "steer", text });
return;
}
this.clearUndo();
this.prompts.push(text);
// The first turn is what puts the session on disk. serve answers with a
// truncated first message straight away and swaps in the generated title
@@ -685,6 +707,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
}
async resume(_path: string) {
this.clearUndo();
this.log = [];
this.at = 0;
}
@@ -107,6 +107,16 @@ export class MockMcp extends MockProvider {
];
}
async trustMcp(name: string, digest: string) {
const s = this.servers.find((x) => x.name === name);
if (!s) return { state: "failed", error: "no configured MCP server named " + name };
if (!s.held?.length || s.heldDigest !== digest) return { state: "failed", error: "the held MCP tool definitions changed since they were shown" };
s.held = undefined;
s.heldDigest = undefined;
s.tools = s.tools + 1;
return { state: "ready", tools: s.tools };
}
async reconnectMcp(name: string) {
const s = this.servers.find((x) => x.name === name);
if (!s) return { state: "failed", error: "no configured MCP server named " + name };
@@ -8,7 +8,7 @@ export function mockModels(efforts: string[]): ModelEntry[] {
return [
{
ref: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro",
kind: "openai", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY", active: true, efforts, effort: "high",
kind: "openai", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY", active: true, efforts, effort: "high", webSearch: true,
contextWindow: 131072, price: { input: 2, output: 8, currency: "CNY" },
},
{
+6
View File
@@ -49,6 +49,7 @@ export interface ModelEntry {
// What the user renamed provider to; provider stays the identity.
displayName?: string;
model: string;
modelAlias?: string;
kind?: string;
active?: boolean;
default?: boolean;
@@ -59,6 +60,7 @@ export interface ModelEntry {
// nothing declares it — never "no". Rendering a guess here sends the user to
// a rejected request they cannot explain.
vision?: boolean;
webSearch?: boolean;
// What this model produces. Absent means chat: it is what every model was
// before decision backends, so a kernel predating the field still answers.
answers?: "chat" | "decision";
@@ -91,6 +93,10 @@ export interface ModelMode {
// kernel appear here; image routing joins once it can name its own model
// instead of borrowing whatever the subagent runs.
export interface RoleAssignments {
web_search: string;
web_search_effective?: string;
web_search_reason?: string;
web_search_source?: string;
planner: string;
subagent: string;
guardian: string;
+1
View File
@@ -108,6 +108,7 @@ export interface PluginAction {
warnings?: string[];
error?: string;
next?: string;
errorCode?: string;
// The kernel shortened a field or list of this step; what is shown is not all of it.
previewTruncated?: boolean;
}
@@ -0,0 +1,25 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { SsePort } from "./sse";
import { SseExtensions } from "./sse_ext";
import type { PluginPlan } from "./plugin";
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
it.each([
["Studio", () => new SsePort("/rt/neutral")],
["extension port", () => new SseExtensions("/rt/neutral")],
] as const)("preserves publication and content causes in %s", async (_name, make) => {
const port = make();
const plan: PluginPlan = {
ok: false, status: "failed", applied: true,
actions: [{ kind: "plugin", action: "install_plugin_package", status: "failed", riskLevel: "medium", error: "detail", errorCode: "install.publication_failed" }],
};
vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => Response.json(plan)));
expect((await port.installPlugin({ source: "/neutral", replace: true, planId: "approved" })).actions?.[0].errorCode).toBe("install.publication_failed");
plan.actions![0].errorCode = "install.digest_mismatch";
expect((await port.installPlugin({ source: "/neutral", replace: true, planId: "approved" })).actions?.[0].errorCode).toBe("install.digest_mismatch");
plan.ok = true; plan.status = "planned"; plan.applied = false;
plan.actions![0].status = "planned"; delete plan.actions![0].error; delete plan.actions![0].errorCode;
expect((await port.planPlugin({ source: "/neutral" })).status).toBe("planned");
});
+10 -2
View File
@@ -19,7 +19,7 @@ export type { MemoryEdit } from "./memory";
export type { Money, UsageDay, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage";
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments, RoleOverride } from "./model";
import type { NetworkProbe, NetworkSettings } from "./network";
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
import type { SkillCatalog, SkillEntry } from "./skill";
import type { StudioCrash, StudioLogs, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
@@ -33,7 +33,7 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval
HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource, JobEntry, McpCatalog, McpDraft,
McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, MemoryCatalog,
MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
RewindResult, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus,
RewindResult, RewindUndo, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus,
ShellOption, ShellSettings, SkillCatalog, SkillEntry, UpdateProgress, VersionEntry,
StudioCrash, StudioLogs, VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo };
@@ -300,6 +300,9 @@ export interface AgentPort {
// Retries a failed or disconnected server and answers with its new state, so
// the caller never has to race a follow-up GET against the connect.
reconnectMcp(name: string): Promise<{ state: string; tools?: number; error?: string }>;
// Approves the withheld definitions the digest names, then reconnects. The
// kernel refuses a digest that no longer matches what it withholds.
trustMcp(name: string, digest: string): Promise<{ state: string; tools?: number; error?: string }>;
setMcpEnabled(name: string, enabled: boolean, scope?: ScopeLayer, root?: string): Promise<void>;
clearMcpOverride(name: string, root?: string): Promise<void>;
setMcpLoad(name: string, load: McpLoad): Promise<void>;
@@ -456,6 +459,10 @@ export interface AgentPort {
// the user agreed to.
prepareRewind(turn: number, scope: RewindScope): Promise<RewindPlan>;
commitRewind(planId: string): Promise<RewindResult>;
// Copies the conversation up to the end of a turn into a new session and
// answers with its path; this session stays where it is.
branchAt(turn: number, name: string): Promise<string>;
availableUndo(): Promise<RewindUndo | null>;
// Reverses a committed rewind. Only reachable while the caller still holds the
// transaction id the commit returned.
undoRewind(transactionId: string): Promise<void>;
@@ -542,6 +549,7 @@ export interface AgentPort {
// the agent can read and drive; the window keeping its own would be a page
// neither could hand to the other.
browserOpen(url: string, newTab: boolean): Promise<BrowserTab>;
browserClose(tab: string): Promise<void>;
// The entry's full text. preview is cut to a manifest-sized line, so editing
// against it would silently shorten what the user actually said.
readQueued(itemId: string): Promise<string>;
@@ -9,6 +9,7 @@ export interface ProviderEntry {
kind: string;
baseUrl: string;
models: string[];
modelAliases?: Record<string, string>;
default: string;
hasKey: boolean;
keyEnv?: string;
@@ -215,6 +216,8 @@ export interface ProviderModelCheckRequest {
// Changing a source that already exists: everything else on the entry stays.
export interface ProviderEdit {
name: string;
// Omitted preserves aliases; blank or missing values reset listed models.
modelAliases?: Record<string, string>;
// Omitted leaves the wire format alone.
kind?: string;
baseUrl?: string;
@@ -102,6 +102,12 @@ export interface RewindFileStage {
compensateError?: string;
}
export interface RewindUndo {
transactionId: string;
turn: number;
files: number;
}
export interface RewindResult {
ok?: boolean;
transactionId?: string;
+20 -1
View File
@@ -1,5 +1,5 @@
import { PLAN_ACTIONS, type PlanAction } from "./session";
import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import { HttpError, type CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles } from "./port";
import { SseWorkspace } from "./sse_workspace";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
@@ -138,6 +138,17 @@ export class SsePort extends SseWorkspace implements AgentPort {
return this.post("/memory/restore", { name, revision });
}
async trustMcp(name: string, digest: string) {
const res = await fetch(this.base + "/mcp/trust", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ name, digest }),
});
const body = (await res.json().catch(() => ({}))) as { state?: string; tools?: number | unknown[]; error?: string };
if (!res.ok && !body.error) throw new Error(`/mcp/trust: ${res.status}`);
return { state: body.state ?? (res.ok ? "ready" : "failed"), tools: typeof body.tools === "number" ? body.tools : body.tools?.length, error: body.error };
}
async reconnectMcp(name: string) {
const res = await fetch(this.base + "/mcp/reconnect", {
method: "POST",
@@ -497,6 +508,11 @@ export class SsePort extends SseWorkspace implements AgentPort {
return this.post0<RewindResult>("/rewind/commit", { planId });
}
async branchAt(turn: number, name: string) {
return (await this.post0<{ path: string }>("/branch/at", { turn, name })).path;
}
availableUndo() { return this.get<RewindUndo | null>("/rewind/undo"); }
undoRewind(transactionId: string) {
return this.post("/rewind/undo", { transactionId });
}
@@ -684,6 +700,9 @@ export class SsePort extends SseWorkspace implements AgentPort {
browserOpen(url: string, newTab: boolean) {
return this.post0<BrowserTab>("/browser/open", { url, newTab });
}
browserClose(tab: string) {
return this.post0<void>("/browser/close", { tab });
}
async readQueued(itemId: string) {
const r = await this.get<{ envelope?: { displayText?: string } }>("/inbox/items/" + encodeURIComponent(itemId));
// Every stored entry has a body — the kernel refuses an empty one at
+11
View File
@@ -21,6 +21,17 @@ export class SseMcp extends SseProvider {
// whole point of the button, so it is read rather than thrown away.
// 502 carries the diagnosis in the body — the reason the retry failed is the
// whole point of the button, so it is read rather than thrown away.
async trustMcp(name: string, digest: string) {
const res = await fetch(this.base + "/mcp/trust", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ name, digest }),
});
const body = (await res.json().catch(() => ({}))) as { state?: string; tools?: number | unknown[]; error?: string };
if (!res.ok && !body.error) throw new Error(`/mcp/trust: ${res.status}`);
return { state: body.state ?? (res.ok ? "ready" : "failed"), tools: typeof body.tools === "number" ? body.tools : body.tools?.length, error: body.error };
}
async reconnectMcp(name: string) {
const res = await fetch(this.base + "/mcp/reconnect", {
method: "POST",
@@ -15,6 +15,12 @@ export interface VersionNotes {
cached: boolean;
}
// The running version was pulled after publication; reason is the publisher's.
export interface WithdrawnNote {
version: string;
reason: string;
}
// err rides alongside the data: an unreachable catalog must not hide which
// version is running.
export interface VersionHub {
@@ -24,6 +30,7 @@ export interface VersionHub {
latest: string;
newer: boolean;
versions: VersionEntry[];
withdrawn?: WithdrawnNote;
err?: string;
}
+1
View File
@@ -32,6 +32,7 @@ export type Kind =
| "workspace_changed"
| "completion_summary"
| "inbox_changed"
| "rewind_changed"
| "adjudications_changed"
| "browser_tabs_changed"
| "graph_delta"
+72 -3
View File
@@ -1,6 +1,8 @@
// Per-machine display choices. They live in localStorage rather than in the
// kernel's settings because they answer "what does this screen show me",
// which is not a fact about the session and does not travel with it.
import { useSyncExternalStore } from "react";
// Per-machine interface choices. They live in localStorage rather than in the
// kernel's settings because they govern this screen's presentation and input,
// not session facts, and do not travel with the session.
// On unless this machine turned it off. The desktop shell reads the same key
// from its saved preferences, so a flip here reaches it without a request.
@@ -310,3 +312,70 @@ export function setShowsHiddenFiles(on: boolean): void {
/* the choice holds for this window and is forgotten on the next */
}
}
// The send key is local to this machine, never read from project settings.
export type SendShortcut = "enter" | "modifier_enter";
const SEND_SHORTCUT_KEY = "rx-send-shortcut";
const sendShortcutListeners = new Set<() => void>();
let transientSendShortcut: SendShortcut = "enter";
// A refused write can still leave reads returning the old choice.
let sendShortcutStorageFailed = false;
export function sendShortcut(): SendShortcut {
if (sendShortcutStorageFailed) return transientSendShortcut;
try {
return localStorage.getItem(SEND_SHORTCUT_KEY) === "modifier_enter" ? "modifier_enter" : "enter";
} catch {
return transientSendShortcut;
}
}
export function setSendShortcut(next: SendShortcut): void {
transientSendShortcut = next;
try { localStorage.setItem(SEND_SHORTCUT_KEY, next); sendShortcutStorageFailed = false; } catch { sendShortcutStorageFailed = true; }
sendShortcutListeners.forEach((fn) => fn());
}
function subscribeSendShortcut(fn: () => void): () => void {
sendShortcutListeners.add(fn);
const storage = (event: StorageEvent) => {
if (event.key === SEND_SHORTCUT_KEY || event.key === null) fn();
};
window.addEventListener("storage", storage);
return () => { sendShortcutListeners.delete(fn); window.removeEventListener("storage", storage); };
}
export function useSendShortcut(): SendShortcut {
return useSyncExternalStore(subscribeSendShortcut, sendShortcut, sendShortcut);
}
// The sidebar remains the default session switcher. Tabs are a local display
// choice and do not change which sessions stay open in the kernel.
const SESSION_TABS_KEY = "rx-show-session-tabs";
let sessionTabsVisible = false;
const sessionTabListeners = new Set<() => void>();
export function showsSessionTabs(): boolean {
try {
return localStorage.getItem(SESSION_TABS_KEY) === "on";
} catch {
return sessionTabsVisible;
}
}
export function setShowsSessionTabs(on: boolean): void {
sessionTabsVisible = on;
try {
localStorage.setItem(SESSION_TABS_KEY, on ? "on" : "off");
} catch {
/* the choice holds for this window and is forgotten on the next */
}
sessionTabListeners.forEach((fn) => fn());
}
export function onShowsSessionTabsChange(fn: () => void): () => void {
sessionTabListeners.add(fn);
return () => {
sessionTabListeners.delete(fn);
};
}
@@ -0,0 +1,32 @@
import { useSyncExternalStore } from "react";
const PREFIX = "rx-rewind-notice-dismissed:";
const listeners = new Set<() => void>();
function dismissed(transactionId: string | undefined): boolean {
if (!transactionId) return false;
try { return localStorage.getItem(PREFIX + transactionId) === "1"; }
catch { return false; }
}
function refresh(event: StorageEvent) {
if (event.key === null || event.key.startsWith(PREFIX)) listeners.forEach((fn) => fn());
}
function subscribe(fn: () => void): () => void {
listeners.add(fn);
if (listeners.size === 1) window.addEventListener("storage", refresh);
return () => {
listeners.delete(fn);
if (listeners.size === 0) window.removeEventListener("storage", refresh);
};
}
export function dismissRewindNotice(transactionId: string): void {
localStorage.setItem(PREFIX + transactionId, "1");
listeners.forEach((fn) => fn());
}
export function useRewindNoticeDismissed(transactionId: string | undefined): boolean {
return useSyncExternalStore(subscribe, () => dismissed(transactionId), () => false);
}
@@ -55,6 +55,7 @@ export const initialState: SessionState = {
steerQueue: [], takenBack: [], subagentPhase: {},
awaitingTurnStart: [],
queueMoved: 0,
rewindMoved: 0,
browserTabsMoved: 0,
panels: [],
views: [],
@@ -221,6 +222,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
return left.length === s.entranceOwed.length ? s : { ...s, entranceOwed: left };
}
if (ev.kind === "__runtime_seen") return { ...s, runtime: s.runtime.filter((n) => n.id !== ev.id) };
if (ev.kind === "rewind_changed") return { ...s, rewindMoved: s.rewindMoved + 1 };
if (ev.kind === "inbox_changed") return { ...s, queueMoved: s.queueMoved + 1 };
if (ev.kind === "browser_tabs_changed") return { ...s, browserTabsMoved: s.browserTabsMoved + 1 };
// Both event.Message emitters carry assistant text, so nothing on the wire
@@ -243,6 +243,7 @@ export interface SessionState {
// and this is what asks it again. Counting, not the kernel's own revision:
// the point is that it changed, and a counter cannot arrive out of order.
queueMoved: number;
rewindMoved: number;
// How many times the agent's browser tabs moved; the tabs are read back.
browserTabsMoved: number;
// Standing extension surfaces, keyed by plugin and surface id. They describe
@@ -256,6 +256,19 @@ rules are written in.
the global icon navigation is not visible. It follows the window's existing
navigation visibility state, including collapsed-only mode and narrow-window
folds, so both columns never offer the same footer actions together.
- Browser wide mode changes the existing pane's grid tracks, keeping the
workbench, pages, transcript and composer in place. The conversation takes
34% of the pane up to 380px; the browser takes the remaining width.
- The saved split width is untouched, and its divider is absent while wide so dragging
cannot silently overwrite a width that is not being drawn.
- The compact composer's metrics row wraps inside the composer. Absolute
positioning below the box lets balance and task controls cross the browser seam.
- Under the side fold, wide mode stacks the browser above the conversation and
composer. Two columns at phone width cannot keep either surface usable.
- The workbench toggle and Ctrl/Cmd+Shift+B share `browser.wide`. Escape returns
to split before the window can close the browser or stop the run. Closing the
dock or leaving the conversation resets wide mode; mounting never expands it.
- Column widths change on `.app`, so the tween belongs there and `.cols` only
reads the result into a track. The tween must be cut during a drag: a .34s
tween makes the column chase the pointer.
@@ -1192,6 +1205,11 @@ what exists.
## Panes
- The sidebar is the default session switcher. Settings > Appearance offers
session tabs as an opt-in, off by default and saved on this machine. The strip
only mounts when the choice is on and more than one pane is open; changing the
choice does not close or stop any session.
- Panes are stacked, one visible at a time. Side by side they squeeze each other
and a glance cannot tell which composer belongs to which run; as tabs the
background one keeps running.
@@ -82,6 +82,9 @@ grid to break.
## Surfaces
- Studio's default light palette separates the frame (`#F0F0ED`), reading
surface (`#FAFAF8`) and composer (`--raised`). Its composer uses a short
`--compose-shadow`; installed packs retain their existing surface recipe.
- `--float` / `--float-hi` exist apart from `--raised`. In dark, `--raised` and
what it covers are nearly the same lightness, and a black shadow on a
near-black ground paints nothing, so a menu read as transparent. A floating
@@ -238,6 +241,14 @@ raised with its ratios kept.
## Weight and contrast
- Default light Studio replies use `--w-read: 400` for prose only. Headings,
emphasis and small UI text keep their existing weights. A chosen weight,
installed pack or dark scheme falls back to `--w-reg`.
- `background.textGlow: none` paints `--flow-text-shadow: none` through the
pack sheet. The backdrop transcript rule defaults to its 2px shadow and 7px
halo when this variable is absent. The choice affects text only; the frosted
backing remains. Reduced effects overrides the text shadow.
- Stroke weight is an axis beside contrast, not part of it: however bright the
colour, 400 Han at 11px is thin. Han strokes are dense and blur at a size
Latin still holds, so the Chinese interface starts at medium.
+2 -2
View File
@@ -82,8 +82,8 @@
}
:root[data-bg] .flow:not([data-empty]) {
text-shadow: 0 1px 2px color-mix(in srgb, var(--page) 90%, transparent),
0 0 7px color-mix(in srgb, var(--page) 72%, transparent);
text-shadow: var(--flow-text-shadow, 0 1px 2px color-mix(in srgb, var(--page) 90%, transparent),
0 0 7px color-mix(in srgb, var(--page) 72%, transparent));
}
:root[data-bg] .compose {
background: color-mix(in srgb, var(--raised) 80%, transparent);
@@ -0,0 +1,22 @@
.pane[data-browser-size="wide"]:has(> .pbody[data-dock]) {
grid-template-columns: minmax(0, min(34%, 380px)) minmax(0, 1fr);
}
.pane[data-browser-size="wide"] .studio-meterrail {
position: static;
flex-wrap: wrap;
margin-top: 8px;
padding-inline: 0;
}
[data-fold~="side"] .app[data-browser="on"] .pane[data-browser-size="wide"]:has(> .pbody[data-dock]) {
grid-template: "nav" auto "find" auto "side" minmax(0, 1.2fr) "body" minmax(0, 1fr) "compose" auto / minmax(0, 1fr);
}
[data-fold~="side"] .app[data-browser="on"] .pane[data-browser-size="wide"]:has(> .pbody[data-dock]) > .pbody {
display: flex;
}
[data-fold~="side"] .app[data-browser="on"] .pane[data-browser-size="wide"]:has(> .pbody[data-dock]) > .compose {
display: block;
}
@@ -0,0 +1,4 @@
.compact-now .wsconfirm { display: grid; gap: 8px; padding: 8px 0; border: 0; background: transparent; }
.compact-now .wsconfirm-t .q { overflow: visible; white-space: normal; }
.compact-now .wsconfirm-a { justify-content: flex-end; flex-wrap: wrap; }
.compact-now .wsconfirm-a button:disabled { opacity: .4; cursor: default; }
@@ -0,0 +1 @@
.comp-live { min-height: 1lh; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
@@ -107,6 +107,7 @@
.fbk-env dt { color: var(--muted); font-size: var(--fs-meta); }
.fbk-env dd { margin: 0; overflow-wrap: anywhere; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--text); }
.fbk-env .fbk-hint { margin: 0; }
.fbk-logs { display: grid; justify-items: start; gap: 6px; margin-top: 10px; }
.fbk-note {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px 10px; padding: 8px 12px; border: 1px solid var(--border);
@@ -196,6 +197,9 @@
}
.fbk-thread { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
.fbk-thread-body:not([hidden]) { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; width: 100%; }
.fbk-thread-toggle { gap: 6px; flex-wrap: wrap; }
.fbk-thread-toggle .studio-icon { width: 12px; height: 12px; }
.fbk-thread ol { display: flex; flex-direction: column; gap: 8px; width: 100%; margin: 0; padding: 0; list-style: none; }
.fbk-thread li {
display: flex; flex-direction: column; gap: 3px; max-width: min(92%, 560px); padding: 8px 11px;
@@ -208,7 +212,7 @@
.fbk-msg-hd { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: var(--fs-meta); }
.fbk-msg-hd b { color: var(--text); font-weight: var(--w-med); }
.fbk-msg-hd time { margin-left: auto; }
.fbk-new {
.fbk-new, .fbk-thread-new {
padding: 0 7px; border-radius: var(--r-pill); background: var(--accent); color: var(--accent-fg);
font: var(--w-med) var(--fs-micro)/1.6 var(--ui);
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
const SHEETS = import.meta.glob(["./studio.css", "./tokens.css"], { query: "?raw", import: "default", eager: true }) as Record<string, string>;
const SHEET = Object.values(SHEETS).join("\n");
const faces = (family: string) =>
[...SHEET.matchAll(/@font-face\s*\{([^{}]*)\}/g)].map((m) => m[1]).filter((body) => body.includes(`font-family: "${family}"`));
describe("the CJK font stack", () => {
it("serves a first screen from the core face and keeps the full font as a later family", () => {
const [core] = faces("Noto Studio");
expect(core, "a Noto Studio face").toBeTruthy();
expect(core).toContain("noto-studio-core.woff2");
expect(core).toMatch(/unicode-range\s*:/);
const [full] = faces("Noto Studio Full");
expect(full, "a Noto Studio Full face").toBeTruthy();
expect(full).toContain("noto-studio.woff2");
});
it("names the full font after the core one in every stack that uses either", () => {
const stacks = [...SHEET.matchAll(/--(?:ui|mono)\s*:\s*([^;]*);/g)].map((m) => m[1]).filter((s) => s.includes("Noto Studio"));
expect(stacks.length).toBeGreaterThan(0);
for (const stack of stacks) {
const core = stack.indexOf('"Noto Studio"');
const full = stack.indexOf('"Noto Studio Full"');
expect(core, stack).toBeGreaterThan(-1);
expect(full, stack).toBeGreaterThan(core);
}
});
});
@@ -0,0 +1,6 @@
.srv-held { margin: 6px 0 0 15px; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--overlay); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); color: var(--text); display: grid; gap: 6px; justify-items: start }
.srv-held ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 2px; max-width: 100% }
.srv-held li { display: flex; gap: 8px; align-items: baseline; min-width: 0 }
.srv-held li b { font-weight: var(--w-med); overflow-wrap: anywhere }
.srv-held li i { font-style: normal; color: var(--muted); white-space: nowrap }
.srv-held .hn { color: var(--muted) }
@@ -0,0 +1,6 @@
[data-fold~="scene"] .compose:not(:focus-within):not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) textarea { min-height: 1lh; }
[data-fold~="scene"] .compose:not(:focus-within):not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) .studio-refine { display: none; }
[data-fold~="scene"] .compose:not(:focus-within):not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) .studio-mode-picker { width: 30px; justify-content: center; padding-inline: 0; }
[data-fold~="scene"] .compose:not(:focus-within):not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) :is(.studio-mode-picker > span:not(.studio-sr-label), .studio-mode-picker > .studio-icon:last-child) { display: none; }
[data-fold~="scene"] .compose:not(:focus-within):not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) .turntools { flex-wrap: nowrap; }
[data-fold~="scene"] .compose:not(:focus-within):not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) .studio-model-group { flex-basis: 0; flex-wrap: nowrap; }
@@ -0,0 +1,19 @@
.tabs:has(.plan-peek-pop) { z-index: 25; }
.plan-peek { margin-inline-start: auto; flex: none; }
.plan-peek-trigger { display: flex; align-items: center; gap: 6px; }
.plan-peek-trigger .studio-icon { width: 13px; height: 13px; }
.plan-peek-trigger small { font: 500 10px/1 var(--mono); font-variant-numeric: tabular-nums; }
.plan-peek-trigger[aria-expanded="true"] { background: var(--overlay); color: var(--text); }
.plan-peek-pop {
position: absolute; inset-inline-end: var(--pane-gutter); top: calc(100% + 6px);
width: min(360px, calc(100% - 2 * var(--pane-gutter))); max-height: min(420px, calc(55vh / var(--zoom, 1)));
overflow-y: auto; overscroll-behavior: contain; padding: 14px; border-radius: var(--r-lg);
background: var(--surface); color: var(--text); box-shadow: var(--shadow-float);
}
.plan-peek-pop:focus-visible { outline: 2px solid var(--focus); }
.plan-peek-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.plan-peek-head b { font: 600 var(--fs-ui)/1.4 var(--ui); }
.plan-peek-goal { margin: 12px 0; color: var(--muted); font: 450 var(--fs-ui)/1.6 var(--ui); overflow-wrap: anywhere; white-space: pre-wrap; }
.plan-peek-pop .block[data-b="plan"] { margin: 12px 0 0; padding: 0; border: 0; background: none; }
.plan-peek-pop .block[data-b="plan"] > .lbl { display: none; }
.plan-peek-pop .s { animation: none; }
@@ -0,0 +1,50 @@
.pbody.rewind-notice-host { position: relative; }
.rewind-notice {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
gap: 9px;
flex: none;
align-self: center;
width: fit-content;
max-width: min(420px, calc(100% - 24px));
margin: 0;
padding: 7px 36px 7px 10px;
border: 1px solid var(--border);
border-radius: var(--r-lg);
background: var(--float);
color: var(--text);
box-shadow: var(--shadow-float);
font: var(--w-med) var(--fs-ui)/1.45 var(--ui);
}
.rewind-notice > .studio-icon { flex: none; color: var(--muted); }
.rewind-notice-copy { display: flex; flex: 0 1 auto; flex-direction: column; gap: 4px; min-width: 0; }
.rewind-notice-error { color: var(--err-ink); overflow-wrap: anywhere; }
.rewind-notice > .btn { flex: none; padding: 5px 9px; white-space: nowrap; }
@container conversation (max-width: 460px) {
.rewind-notice { max-width: calc(100% - 20px); flex-wrap: wrap; }
}
.rewind-notice-close {
position: absolute;
top: 4px;
right: 4px;
display: grid;
place-items: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: var(--r-sm);
background: transparent;
color: var(--muted);
cursor: pointer;
}
.rewind-notice-close:hover:not(:disabled) { color: var(--text); }
.rewind-notice-close:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.rewind-notice-close:disabled { opacity: .45; cursor: default; }
.rewind-notice-count { color: var(--muted); }
@@ -0,0 +1,9 @@
import { expect, it } from "vitest";
const CSS = import.meta.glob("./studio.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
it("keeps the activity visibility floor in fixed pixels rather than timeline percentages", () => {
const rule = Object.values(CSS)[0].match(/\.run-round-track\s*>\s*i\s*\{([^}]+)\}/)?.[1];
expect(rule).toBeDefined();
expect(rule).toMatch(/min-width:\s*3px\s*;/);
});
@@ -0,0 +1,18 @@
.session-batch-entry { display: flex; justify-content: flex-end; padding: 3px 18px; }
.session-batch-entry button, .session-batch button { font: var(--w-med) var(--fs-micro) var(--ui); }
.session-batch-entry button { padding: 4px 6px; border: 0; border-radius: 4px; color: var(--muted); background: transparent; cursor: pointer; }
.session-batch-entry button:hover:not(:disabled) { color: var(--text); background: var(--overlay); }
.session-batch { margin: 4px 12px 8px; padding: 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--page); }
.session-batch-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; font-size: var(--fs-micro); }
.session-batch-head button { flex: none; color: var(--muted); background: transparent; padding: 4px; border: 0; border-radius: 4px; cursor: pointer; }
.session-batch-actions { display: flex; gap: 4px; flex-wrap: wrap; }
.session-batch-actions button { padding: 5px 7px; background: var(--raised); color: var(--text); border: 1px solid var(--border); border-radius: 4px; }
.session-batch-actions button:hover:not(:disabled) { background: var(--overlay); }
.session-batch [data-action="session.batch-delete"] { color: var(--err-ink); }
.session-batch p { margin: 6px 0 0; color: var(--muted); font-size: var(--fs-micro); }
.session-batch button:disabled, .session-batch-entry button:disabled { opacity: .45; cursor: default; }
.session-batch button:focus-visible, .session-batch-entry button:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.session-batch .wsconfirm { margin: 8px 0 0; flex-wrap: wrap; gap: 8px; }
.session-batch .wsconfirm-t { flex-basis: 100%; }
.session-batch-names { font-size: var(--fs-micro); color: var(--text); overflow-wrap: anywhere; max-height: 100px; overflow-y: auto; }
.rail .session-select { flex: none; width: 14px; height: 14px; margin: 0 6px 0 0; accent-color: var(--accent); }
@@ -0,0 +1 @@
.sesspin { width: 11px; height: 11px; flex: none; margin-inline-end: 4px; color: var(--muted); vertical-align: -1px }
@@ -0,0 +1,27 @@
.rail .sessquick {
flex: none; display: flex; align-items: center; gap: 2px; width: 0; overflow: hidden;
opacity: 0; pointer-events: none; transition: opacity 100ms ease;
}
.rail .sessrow:hover > .sessquick,
.rail .sessrow:focus-within > .sessquick { width: 50px; margin-inline-end: 6px; opacity: 1; pointer-events: auto; }
.rail .sessquick > button {
flex: none; width: 24px; height: 28px; padding: 0; display: grid; place-items: center;
border: 0; border-radius: 5px; background: transparent; color: var(--faint); cursor: pointer;
}
.rail .sessquick .studio-icon { width: 14px; height: 14px; }
.rail .sessquick > button:hover { background: var(--raised); color: var(--text); }
.rail .sessquick > .danger:hover { color: var(--err-ink); }
.rail .sessquick > button:disabled { opacity: .35; cursor: default; background: transparent; color: var(--faint); }
.rail .sessquick > button:focus-visible { outline: 1px solid var(--focus); outline-offset: -1px; }
.rail .sessrow-context[data-unread]:hover > .sessquick,
.rail .sessrow-context[data-unread]:focus-within > .sessquick { margin-inline-end: 26px; }
.rail .sessrow-context[data-unread]:hover > .sesscopies,
.rail .sessrow-context[data-unread]:focus-within > .sesscopies { margin-inline-end: 8px; }
.rail .sessrow-context[data-unread]:hover > .sesstitle,
.rail .sessrow-context[data-unread]:focus-within > .sesstitle { padding-inline-end: 9px; }
@media (hover: none) {
.rail .sessquick { opacity: 1; width: 50px; margin-inline-end: 6px; pointer-events: auto; }
.rail .sessrow-context[data-unread] > .sessquick { margin-inline-end: 26px; }
.rail .sessrow-context[data-unread] > .sesscopies { margin-inline-end: 8px; }
.rail .sessrow-context[data-unread] > .sesstitle { padding-inline-end: 9px; }
}
@@ -0,0 +1,18 @@
.rail > .rail-drop {
position: absolute;
z-index: 1;
inset: 8px;
width: auto;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px;
border: 1px dashed var(--accent);
border-radius: 8px;
background: var(--surface);
color: var(--text);
font: 500 12px/1.5 var(--ui);
pointer-events: none;
}
.rail-drop svg { flex: none; width: 18px; height: 18px; }
+44 -51
View File
@@ -21,13 +21,6 @@
font-weight: 600;
font-display: swap;
}
@font-face {
font-family: "Noto Studio";
src: url("/fonts/noto-studio.woff2") format("woff2");
font-style: normal;
font-weight: 100 900;
font-display: swap;
}
@font-face {
font-family: "Studio Mono";
src: url("/fonts/studio-mono-400.ttf") format("truetype");
@@ -122,28 +115,6 @@
--shadow-float: 0 0 0 1px rgb(0 0 0 / .42), 0 20px 56px -18px rgb(0 0 0 / .75);
}
:root[data-theme="light"] {
--page: #f6f6f4;
--surface: #ffffff;
--raised: #ffffff;
--overlay: #efefec;
--border: #deded9;
--hair: #e9e9e5;
--float: #ffffff;
--float-hi: #f4f4f1;
--accent: #282a28;
--accent-fg: #ffffff;
--accent-ink: #303330;
--accent-wash: #e9eae6;
--net: #566aa9;
--net-wash: #eef1fa;
--focus: #7388c8;
--text: #292b29;
--muted: #5f625d;
--faint: #666a63;
--ghost: #93968f;
}
body { background: var(--page); }
.app { --rail-open: 238px; background: var(--page); }
.app[data-nav="on"] { --nav-w: var(--nav-open); }
@@ -433,8 +404,8 @@ body { background: var(--page); }
}
:root[data-theme="light"] {
--page: #efefec;
--surface: #f8f8f5;
--page: #f0f0ed;
--surface: #fafaf8;
--raised: #ffffff;
--overlay: #e9e9e5;
--border: #d6d7d1;
@@ -445,6 +416,8 @@ body { background: var(--page); }
--accent-fg: #ffffff;
--accent-ink: #252725;
--accent-wash: #e4e5e0;
--net: #566aa9;
--net-wash: #eef1fa;
--focus: #68736d;
--text: #292b29;
--muted: #5e625c;
@@ -452,6 +425,16 @@ body { background: var(--page); }
--ghost: #666a63;
}
:root[data-theme="light"]:not([data-pack]):not([data-bg]) {
--reading-surface: var(--surface);
--compose-surface: var(--raised);
--compose-shadow: 0 3px 12px rgb(25 30 25 / .07);
}
:root[data-theme="light"]:not([data-pack]):not([data-weight]) {
--w-read: 400;
}
body { background: var(--page) !important; }
:root[data-bg] .app,
:root[data-sky] .app { background: transparent !important; }
@@ -492,11 +475,7 @@ body { background: var(--page) !important; }
}
.main { background: var(--surface) !important; }
.panes { background: var(--surface) !important; }
/* The prototype hid the pane navigation entirely, which also made trajectory,
task progress and the run graph unreachable. Keep the legacy pane strip
hidden, but expose the current purpose-based navigation. */
.tabs { display: flex !important; }
.panetabs { display: none !important; }
.railhost { display: block; }
.app { --srail-w: 36px; }
@@ -807,7 +786,7 @@ body { background: var(--page) !important; }
}
.flow .call[data-k="say"] > .c > .out { margin-top: 0; }
.flow .call[data-k="say"] > .c > .out > .txt {
max-width: none; color: var(--text); font-family: var(--ui); font-size: var(--read); font-weight: var(--w-reg); line-height: 1.72;
max-width: none; color: var(--text); font-family: var(--ui); font-size: var(--read); font-weight: var(--w-read, var(--w-reg)); line-height: 1.72;
letter-spacing: 0; -webkit-font-smoothing: antialiased;
}
.flow .call[data-k="say"] .acts {
@@ -1393,7 +1372,7 @@ body { background: var(--page) !important; }
.workbench-tabs svg { width: 13px; height: 13px; flex: none; }
.workbench-body { flex: 1; min-height: 0; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) clamp(190px, 25%, 300px); grid-template-rows: minmax(0, 1fr); }
.workbench-canvas { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.workbench-canvas > .studio-browser, .workbench-canvas > .bpanel { width: 100%; height: 100%; min-width: 0; }
.workbench-content > .studio-browser, .workbench-content > .bpanel { width: 100%; height: 100%; min-width: 0; }
.workbench-canvas .bbar > .vtabs { display: none; }
.workbench-explorer { min-width: 0; display: flex; flex-direction: column; border-left: 1px solid var(--hair); background: var(--page); }
.workbench-explorer-head { height: 36px; padding: 0 12px; flex: none; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--hair); color: var(--muted); font: 620 10px/1 var(--ui); text-transform: uppercase; }
@@ -1461,6 +1440,7 @@ body { background: var(--page) !important; }
.workbench-read > .md { max-width: 820px; margin: 0 auto; font-size: var(--read, 14px); line-height: 1.75; }
.workbench-empty { flex: 1; display: flex; place-content: center; align-items: center; gap: 8px; color: var(--faint); font: 500 11px/1 var(--ui); }
.workbench-empty svg { width: 18px; height: 18px; }
.workbench-error.workbench-close-error { flex: none; font-size: 12px; white-space: pre-line; overflow-wrap: anywhere; }
.workbench-error { padding: 8px 12px; border-bottom: 1px solid var(--err); color: var(--err-ink); font: 500 10px/1.4 var(--ui); }
@media (max-width: 760px) {
.workbench-body { grid-template-columns: minmax(0, 1fr); }
@@ -1521,7 +1501,7 @@ body { background: var(--page) !important; }
:root[data-theme="light"] .main,
:root[data-theme="light"] .panes,
:root[data-theme="light"] .pane,
:root[data-theme="light"] .pane > .scroll { background: var(--page) !important; }
:root[data-theme="light"] .pane > .scroll { background: var(--reading-surface, var(--page)) !important; }
:root[data-theme="light"] .flow .call[data-k="say"] > .g .sym { background: var(--accent); }
:root[data-theme="light"] .flow .call[data-k="say"] > .g .sym::after { color: var(--accent-fg); }
:root[data-theme="light"] .flow .call[data-k="say"] > .c > .hl .nm { color: var(--muted); }
@@ -1587,7 +1567,10 @@ body { background: var(--page) !important; }
:root[data-theme="light"] .flow .call[data-k="me"]:hover {
background: transparent; border-color: transparent; box-shadow: none;
}
:root[data-theme="light"] .compose { background: var(--surface) !important; box-shadow: 0 12px 40px -30px #555a53; }
:root[data-theme="light"] .compose {
background: var(--compose-surface, var(--surface)) !important;
box-shadow: var(--compose-shadow, 0 12px 40px -30px #555a53);
}
:root[data-theme="light"] .compose::after { border-color: var(--border); }
:root[data-theme="light"] .compose:hover::after,
:root[data-theme="light"] .compose:focus-within::after { border-color: #9da198; }
@@ -1870,8 +1853,8 @@ body { background: var(--page) !important; }
No !important: this sheet already loads after the tokens it is replacing, and
marking one would outrank the reader's own font and size as well. */
:root {
--ui: "Inter Studio", "Noto Studio", "PingFang SC", "Microsoft YaHei UI", sans-serif;
--mono: "Studio Mono", "Noto Studio", Consolas, monospace;
--ui: "Inter Studio", "Noto Studio", "Noto Studio Full", "PingFang SC", "Microsoft YaHei UI", sans-serif;
--mono: "Studio Mono", "Noto Studio", "Noto Studio Full", Consolas, monospace;
--fs-lead: 14px;
}
body,
@@ -2335,7 +2318,7 @@ body { font-size: 14px; line-height: 1.75; }
min-width: 29px !important;
height: 29px !important;
padding: 0 !important;
display: grid !important;
display: grid;
place-items: center;
border: 1px solid transparent !important;
border-radius: 999px !important;
@@ -3294,11 +3277,9 @@ body { font-size: 14px; line-height: 1.75; }
color: var(--text);
}
/* View switching belongs to app chrome. Keeping this row on a stable surface
prevents wallpapers from changing the apparent contrast of inactive tabs. */
.pane > .tabs {
background: color-mix(in srgb, var(--surface) 94%, transparent);
border-bottom-color: var(--border);
background: transparent;
border-bottom-color: transparent;
}
.pane > .tabs .tab { color: var(--muted); }
.pane > .tabs .tab:hover { color: var(--text); background: var(--overlay); }
@@ -3469,6 +3450,7 @@ body { font-size: 14px; line-height: 1.75; }
events and the execution graph stay in Runtime details; this surface answers
the common question first: where did the turn spend its time? */
.run-analysis {
--ra-columns: 34px minmax(180px, 1fr) 180px 116px;
--ra-model: color-mix(in srgb, var(--accent) 82%, var(--text));
--ra-tool: color-mix(in srgb, var(--net) 78%, var(--text));
--ra-recovery: color-mix(in srgb, var(--err) 76%, var(--text));
@@ -3551,7 +3533,14 @@ body { font-size: 14px; line-height: 1.75; }
.run-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 7px; }
.run-legend span { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font: var(--w-reg) 10.5px var(--ui); }
.run-legend i { width: 8px; height: 8px; border-radius: 2px; background: var(--ra-color); }
.run-signal-line { position: relative; height: 34px; border-block: 1px dashed var(--border); background: color-mix(in srgb, var(--surface) 56%, transparent); }
.run-timeline-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-top: 14px; font: var(--w-reg) 11px var(--ui); }
.run-timeline-controls label { display: flex; align-items: center; gap: 10px; }
.run-timeline-controls input { width: 130px; accent-color: var(--accent); }
.run-timeline-scroll { overflow-x: auto; container-type: inline-size; }
.run-timeline-scroll:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.run-timeline-canvas .run-inspect { position: sticky; left: 16px; max-width: calc(100cqw - 76px); }
.run-signal-track { display: grid; grid-template-columns: var(--ra-columns); gap: 12px; padding: 5px 10px; }
.run-signal-line { grid-column: 2; position: relative; height: 34px; border-block: 1px dashed var(--border); background: color-mix(in srgb, var(--surface) 56%, transparent); }
.run-signal-line button { position: absolute; top: 50%; width: 20px; height: 30px; translate: -50% -50%; border: 0; background: transparent; cursor: pointer; }
.run-signal-line button::after { content: ""; position: absolute; left: 50%; top: 3px; bottom: 3px; width: 1px; background: var(--border); }
.run-signal-line button i { position: absolute; z-index: 1; left: 50%; top: 50%; width: 9px; height: 9px; translate: -50% -50%; rotate: 45deg; border: 2px solid var(--raised); background: var(--ra-color); box-shadow: 0 0 0 1px color-mix(in srgb, var(--ra-color) 62%, var(--border)); }
@@ -3568,11 +3557,11 @@ body { font-size: 14px; line-height: 1.75; }
.run-rounds li[data-on] .run-round-id,
.run-rounds li[data-on] .run-round-copy b { color: var(--text); }
.run-rounds button {
width: 100%; min-height: 38px; display: grid; grid-template-columns: 34px minmax(180px, 1fr) minmax(150px, .72fr) 116px;
width: 100%; min-height: 38px; display: grid; grid-template-columns: var(--ra-columns);
align-items: center; gap: 12px; padding: 5px 10px; border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer;
}
.run-round-id { color: var(--muted); font: var(--w-reg) 10.5px var(--mono); }
.run-round-track { position: relative; height: 14px; overflow: hidden; border-radius: 3px; border-left: 1px solid var(--border); background-color: var(--surface); background-image: linear-gradient(90deg, var(--hair) 1px, transparent 1px); background-size: 25% 100%; }
.run-round-track { position: relative; height: 14px; overflow: hidden; border-radius: 3px; background-color: var(--surface); background-image: linear-gradient(90deg, var(--hair) 1px, transparent 1px); background-size: 25% 100%; }
.run-round-track > i { position: absolute; top: 3px; height: 8px; min-width: 3px; border-radius: 2px; background: var(--ra-color, var(--ra-tool)); }
.run-round-track > i[data-c="round"] { --ra-color: var(--ra-model); }
.run-round-track > i[data-c="read"] { --ra-color: var(--ghost); }
@@ -3597,7 +3586,7 @@ body { font-size: 14px; line-height: 1.75; }
.run-kpis { grid-template-columns: repeat(3, 1fr); }
.run-kpis > div:nth-child(3) { border-right: 0; }
.run-kpis > div:nth-child(-n+3) { border-bottom: 1px solid var(--hair); }
.run-rounds button { grid-template-columns: 30px minmax(120px, 1fr) minmax(120px, .8fr); }
.run-analysis { --ra-columns: 30px minmax(120px, 1fr) 140px; }
.run-round-kind { display: none; }
}
@media (max-width: 580px) {
@@ -3606,7 +3595,7 @@ body { font-size: 14px; line-height: 1.75; }
.run-kpis > div:nth-child(2n) { border-right: 0; }
.run-kpis > div:nth-child(3) { border-right: 1px solid var(--hair); }
.run-kpis > div:nth-child(-n+4) { border-bottom: 1px solid var(--hair); }
.run-rounds button { grid-template-columns: 26px minmax(100px, 1fr); }
.run-analysis { --ra-columns: 26px minmax(100px, 1fr); }
.run-round-copy { display: none; }
}
:root[data-theme="light"] .grp,
@@ -3689,6 +3678,7 @@ body { font-size: 14px; line-height: 1.75; }
.addp .fields select,
.addp .fields textarea,
.mline .nm { font-size: var(--fs-ui); }
.mline .malias { min-width: 0; width: 100%; box-sizing: border-box; font-size: var(--fs-ui); }
.prefs-main { padding-top: 28px; }
.prefs-col { max-width: 820px; }
.grp { padding: 20px 22px 18px; }
@@ -4234,3 +4224,6 @@ body { font-size: 14px; line-height: 1.75; }
.nest-bd :is(.term, .term-l) { white-space: pre-wrap; overflow-wrap: anywhere; }
.agent-tx .call { content-visibility: auto; contain-intrinsic-size: auto 80px; }
.agent-tx:focus { outline: none; }
.workbench-content { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.workbench-content[hidden] { display: none; }
@@ -15,6 +15,22 @@
unicode-range: U+2E80-9FFF, U+3000-303F, U+FF00-FFEF, U+2010-2027, U+00B7;
}
@font-face {
font-family: "Noto Studio";
src: url("/fonts/noto-studio-core.woff2") format("woff2");
font-style: normal;
font-weight: 100 900;
font-display: swap;
unicode-range: U+20-24F, U+2000-206F, U+2190-23FF, U+2500-257F, U+25A0-25FF, U+2600-27BF, U+2E80-9FFF, U+3000-303F, U+FF00-FFEF;
}
@font-face {
font-family: "Noto Studio Full";
src: url("/fonts/noto-studio.woff2") format("woff2");
font-style: normal;
font-weight: 100 900;
font-display: swap;
unicode-range: U+2E80-9FFF, U+F900-FAFF, U+FE30-FE4F, U+3000-303F, U+FF00-FFEF, U+20000-2FFFF;
}
@font-face {
font-family: "IBM Plex Mono var";
src: url("/fonts/IBMPlexMonoVariable.woff2") format("woff2");
@@ -0,0 +1,220 @@
// @vitest-environment jsdom
import { StrictMode } from "react";
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { SsePort } from "../port/sse";
import type { ProviderModelCheck, ProviderModelCheckRequest } from "../port/port";
import { AddProvider } from "./AddProvider";
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
const names = ["m1", "m2", "m3", "m4", "m5"];
const all = () => screen.getByRole("button", { name: /^测试已启用模型/ }) as HTMLButtonElement;
const summary = () => document.querySelector(".msummary")?.textContent;
const row = (model: string) => screen.getByText(model).closest(".mline") as HTMLElement;
async function draft({ strict = false, noProxy = false, probe = true } = {}) {
const pending: Array<{ model: string; settle: (result: ProviderModelCheck | Error) => void }> = [];
const calls: ProviderModelCheckRequest[] = [];
let peak = 0;
let live = 0;
const fetchMock = vi.fn(async (url: string, init?: RequestInit): Promise<Response> => {
if (url.endsWith("/providers/protocols")) return Response.json([
{ kind: "openai", discovery: "openai", reasoningParams: true },
{ kind: "anthropic", discovery: "anthropic", reasoningParams: true },
]);
if (url.endsWith("/providers/probe")) return Response.json({
kind: "openai", models: names, default: names[0], efforts: [], effort: "",
vision: ["m1"], authHeader: true, noProxy,
});
if (url.endsWith("/providers/check/model")) {
const req = JSON.parse(init!.body as string) as ProviderModelCheckRequest;
calls.push(req);
peak = Math.max(peak, ++live);
return new Promise((resolve, reject) => pending.push({ model: req.model, settle: (result) => {
live--;
if (result instanceof Error) reject(result); else resolve(Response.json(result));
} }));
}
if (url.endsWith("/providers") && init?.method === "POST") return new Response(null, { status: 204 });
throw new Error(`Unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const onDone = vi.fn();
const onCancel = vi.fn();
const port = new SsePort("/batch-kernel");
const form = (p = port) => <AddProvider port={p} taken={[]} known={[]} onDone={onDone} onCancel={onCancel} />;
const view = render(strict ? <StrictMode>{form()}</StrictMode> : form());
const user = userEvent.setup();
await screen.findByRole("option", { name: "Anthropic 兼容" });
await user.type(screen.getByLabelText("来源名称"), "relay-draft");
await user.type(screen.getByLabelText("接口地址"), " https://relay.example/v1 ");
await user.type(screen.getByLabelText("API Key"), " draft-key ");
if (probe) {
await user.click(screen.getByRole("button", { name: "验证连接并读取" }));
await screen.findByText("连接可用 · 找到 5 个模型");
}
const finish = async (model: string, result: ProviderModelCheck | Error = { model, status: "available" }) => {
const index = pending.findIndex((p) => p.model === model);
expect(index).toBeGreaterThanOrEqual(0);
const [request] = pending.splice(index, 1);
await act(async () => request.settle(result));
};
const add = async (model: string) => {
const search = screen.getByRole("searchbox", { name: "搜索或添加模型" });
await user.clear(search);
await user.type(search, `${model}{enter}`);
await user.clear(search);
};
const saveCalls = () => fetchMock.mock.calls.filter(([url, init]) => url.endsWith("/providers") && init?.method === "POST");
return { user, calls, pending, finish, add, peak: () => peak, saveCalls, onDone, onCancel, view, form };
}
for (const strict of [false, true]) it(`sends at most two requests for enabled discovered and manual models (StrictMode=${strict})`, async () => {
const g = await draft({ strict });
await g.user.click(screen.getByRole("checkbox", { name: "选用 m5" }));
await g.add("manual/Exact-ID");
await g.user.selectOptions(screen.getByLabelText("接口协议"), "anthropic");
const liveRegion = document.querySelector(".msummary[role=status]");
await g.user.click(all());
expect(all().getAttribute("aria-busy")).toBe("true");
expect(summary()).toBe("2 个验证中 · 3 个未验证");
expect(g.calls.map((r) => r.model)).toEqual(["m1", "m2"]);
await g.user.click(all());
expect(g.calls).toHaveLength(2);
for (const model of ["m1", "m2", "m3", "m4", "manual/Exact-ID"]) await g.finish(model);
await waitFor(() => expect(all().disabled).toBe(false));
expect(g.calls.map((r) => r.model)).toEqual(["m1", "m2", "m3", "m4", "manual/Exact-ID"]);
expect(g.peak()).toBe(2);
for (const req of g.calls) expect(req).toMatchObject({ baseUrl: "https://relay.example/v1", apiKey: "draft-key", kind: "anthropic", authHeader: true, noProxy: false });
expect(summary()).toBe("5 个可用");
expect(document.querySelector(".msummary[role=status]")).toBe(liveRegion);
expect(within(row("manual/Exact-ID")).getByText("用户添加")).toBeTruthy();
expect(g.saveCalls()).toHaveLength(0);
await g.user.click(screen.getByRole("button", { name: "添加来源" }));
await waitFor(() => expect(g.onDone).toHaveBeenCalledOnce());
expect(JSON.parse(g.saveCalls()[0][1]!.body as string)).toMatchObject({
name: "relay-draft", kind: "anthropic", models: ["m1", "m2", "m3", "m4", "manual/Exact-ID"], default: "m1", vision: ["m1"],
});
});
it("keeps per-model HTTP evidence and continues after partial failures without changing selection", async () => {
const g = await draft();
await g.user.click(all());
await g.finish("m1");
await g.finish("m2", { model: "m2", status: "unavailable", reason: "not_found", httpStatus: 404, detail: "no such model" });
await g.finish("m3", new Error("connection closed"));
await g.finish("m4", { model: "m4", status: "unknown", reason: "rejected", httpStatus: 400, detail: "tools unsupported" });
await g.finish("m5");
expect(summary()).toBe("2 个可用 · 1 个不可用 · 2 个无法确定");
expect(row("m2").textContent).toContain("HTTP 404");
expect(row("m2").textContent).toContain("no such model");
expect(row("m4").textContent).toContain("HTTP 400");
expect(row("m4").textContent).toContain("tools unsupported");
for (const model of names) expect(within(row(model)).getByRole("checkbox").getAttribute("aria-checked")).toBe("true");
expect(document.querySelectorAll(".mevidence[aria-live]")).toHaveLength(0);
expect(document.querySelectorAll(".msummary[role=status]")).toHaveLength(1);
await g.user.click(all());
await g.finish("m2", new Error("connection closed"));
expect(row("m2").querySelector(".mhttp")).toBeNull();
expect(row("m2").querySelector(".mdetail")).toBeNull();
expect(row("m2").querySelector(".mevidence i")?.getAttribute("data-state")).toBe("unknown");
});
it.each([false, true])("uses the displayed proxy route for the whole batch (probe bypass=%s)", async (noProxy) => {
const g = await draft({ noProxy });
await g.user.click(screen.getByText("高级连接选项"));
if (!noProxy) await g.user.click(screen.getByRole("switch", { name: "绕过系统代理" }));
await g.user.click(all());
for (const model of names) await g.finish(model);
expect(g.calls.every((r) => r.noProxy && r.authHeader)).toBe(true);
});
it("holds conflicting actions during a batch and restores them when it settles", async () => {
const g = await draft();
await g.user.click(all());
const controls = [all(), screen.getByRole("button", { name: "添加来源" }), screen.getByRole("button", { name: "验证连接并读取" }), screen.getByRole("button", { name: "验证模型 m3" }), ...screen.getAllByRole("button", { name: "取消" })];
for (const control of controls) {
expect((control as HTMLButtonElement).disabled).toBe(true);
await g.user.click(control);
}
for (const label of ["来源名称", "接口地址", "API Key", "接口协议"]) expect((screen.getByLabelText(label) as HTMLInputElement).disabled).toBe(true);
expect(g.calls).toHaveLength(2);
expect(g.saveCalls()).toHaveLength(0);
expect(g.onCancel).not.toHaveBeenCalled();
for (const model of names) await g.finish(model);
for (const control of controls) expect((control as HTMLButtonElement).disabled).toBe(false);
});
it.each(["limit", "selection", "manual model", "extra body"])("stops queued checks and discards old answers after changing %s", async (change) => {
const g = await draft();
await g.user.click(all());
if (change === "limit") await g.user.type(screen.getByLabelText("上下文窗口"), "1");
if (change === "selection") await g.user.click(screen.getByRole("checkbox", { name: "选用 m5" }));
if (change === "manual model") await g.add("new/model");
if (change === "extra body") {
await g.user.click(screen.getByText("高级连接选项"));
await g.user.type(screen.getByRole("textbox", { name: /^额外请求体/ }), " ");
}
expect(screen.getByText("已停止启动新的验证:草稿已改动")).toBeTruthy();
expect(document.querySelectorAll('.mevidence i[data-state="checking"]')).toHaveLength(0);
await g.finish("m1");
await g.finish("m2");
expect(g.calls).toHaveLength(2);
expect(document.querySelectorAll('.mevidence i[data-state="available"]')).toHaveLength(0);
expect(all().disabled).toBe(false);
await g.user.click(all());
expect(screen.queryByText("已停止启动新的验证:草稿已改动")).toBeNull();
expect(g.calls).toHaveLength(4);
});
it("a stopped batch cannot overwrite a newer run, and a completed receipt survives cancellation", async () => {
const g = await draft();
await g.user.click(all());
await g.finish("m1");
await g.user.type(screen.getByLabelText("上下文窗口"), "1");
expect(summary()).toBe("1 个可用 · 4 个未验证");
await g.user.click(all());
await g.finish("m2", { model: "m2", status: "unavailable", reason: "not_found" });
expect(all().getAttribute("aria-busy")).toBe("true");
expect(row("m2").querySelector(".mevidence i")?.getAttribute("data-state")).toBe("checking");
await g.finish("m3");
expect(g.calls.map((r) => r.model)).toEqual(["m1", "m2", "m3", "m1", "m2"]);
await g.finish("m1");
await g.finish("m2");
for (const model of ["m3", "m4", "m5"]) await g.finish(model);
expect(summary()).toBe("5 个可用");
});
it.each(["unmount", "port change"])("stops queued checks on %s", async (change) => {
const g = await draft();
await g.user.click(all());
if (change === "unmount") g.view.unmount(); else g.view.rerender(g.form(new SsePort("/other-kernel")));
await g.finish("m1");
await g.finish("m2");
expect(g.calls).toHaveLength(2);
if (change === "port change") {
expect(all().disabled).toBe(false);
expect(document.querySelectorAll('.mevidence i[data-state="checking"]')).toHaveLength(0);
}
});
it("requires an address and an enabled model, and excludes a concurrent single check", async () => {
const g = await draft({ probe: false });
expect(all().disabled).toBe(true);
expect(screen.getByText(/每个已勾选的模型各发送一次小请求/)).toBeTruthy();
await g.add("manual/model");
await g.user.clear(screen.getByLabelText("接口地址"));
expect(all().disabled).toBe(true);
await g.user.type(screen.getByLabelText("接口地址"), "https://relay.example/v1");
await g.user.click(screen.getByRole("button", { name: "验证模型 manual/model" }));
expect(all().disabled).toBe(true);
await g.user.click(all());
expect(g.calls).toHaveLength(1);
await g.finish("manual/model");
expect(all().disabled).toBe(false);
await g.user.click(screen.getByRole("checkbox", { name: "选用 manual/model" }));
expect(all().disabled).toBe(true);
});
+29 -13
View File
@@ -10,6 +10,7 @@ import { reason } from "../i18n/kernel";
import { HttpError } from "../port/http_error";
import { EffortShape } from "./EffortShape";
import { THINKING, parseExtraBody, parseHeaders } from "./provider_compat";
import { useModelBatch } from "./useModelBatch";
// Detection is assistance, not authority. Compatible relays often expose one
// model-list shape while expecting another request protocol, so every durable
@@ -146,7 +147,7 @@ export function AddProvider({
};
const checkModel = async (model: string) => {
if (checkingModel || busy) return;
if (testing || busy) return;
setCheckingModel(model);
setFacts((current) => ({
...current,
@@ -179,6 +180,16 @@ export function AddProvider({
}
};
const draftKey = JSON.stringify([name, baseUrl, apiKey, kind, picked, win, maxOut, thinkingOn, thinkingProtocol, heads, extra, noProxy, probe?.authHeader, probe?.noProxy]);
const { batch, testAll } = useModelBatch({
port, picked, draftKey, setFacts, origin: "manual",
blocked: checkingModel !== "" || busy || baseUrl.trim() === "",
request: {
baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), kind,
authHeader: probe?.authHeader ?? false, noProxy: noProxy || (probe?.noProxy ?? false),
},
});
const testing = checkingModel !== "" || batch === "running";
if (preset) return <PresetInstall port={port} preset={preset} onDone={onDone} onCancel={onCancel} onBack={() => setPreset(null)} />;
return (
@@ -189,7 +200,7 @@ export function AddProvider({
<strong>{t("添加模型来源")}</strong>
<p>{t("按服务文档填写。连接检测只帮助读取模型,不会替你决定协议。")}</p>
</div>
<button className="addp-close" onClick={onCancel} disabled={busy || checkingModel !== ""} aria-label={t("取消")}>×</button>
<button className="addp-close" onClick={onCancel} disabled={busy || testing} aria-label={t("取消")}>×</button>
</div>
{baseUrl.trim() === "" && <PresetGallery load={loadPresets} onPick={setPreset} disabled={busy || checkingModel !== ""} />}
<div className="fields">
@@ -202,7 +213,7 @@ export function AddProvider({
value={name}
placeholder={t("例如:公司中转站")}
onChange={(e) => { setName(e.target.value); setNameErr(""); }}
disabled={busy || checkingModel !== ""}
disabled={busy || testing}
spellCheck={false}
aria-invalid={nameBad || nameErr !== "" || undefined}
aria-describedby="addp-name-rule"
@@ -219,7 +230,7 @@ export function AddProvider({
<select aria-label={t("接口协议")} data-action="provider.draft" data-value="protocol" value={kind} onChange={(e) => {
setKind(e.target.value);
setFacts(clearModelCheckFacts);
}} disabled={busy || checkingModel !== ""}>
}} disabled={busy || testing}>
{choices.map((k) => <option key={k} value={k}>{t(KIND_LABEL[k] ?? k)}</option>)}
</select>
<i className="tip">{t("以服务商文档为准;模型列表无法可靠判断聊天协议。")}</i>
@@ -239,7 +250,7 @@ export function AddProvider({
setBaseUrl(e.target.value);
setFacts(clearModelCheckFacts);
}}
disabled={busy || checkingModel !== ""}
disabled={busy || testing}
spellCheck={false}
/>
{wire?.answers === "decision" && <i className="tip">{t("填写服务的基地址,不要带 /v1/systemone;Reasonix 会自己在后面追加。本地的 Ollama 之类地址不适用这个协议。")}</i>}
@@ -250,7 +261,7 @@ export function AddProvider({
onChange={(e) => {
setApiKey(e.target.value);
setFacts(clearModelCheckFacts);
}} disabled={busy || checkingModel !== ""} spellCheck={false} />
}} disabled={busy || testing} spellCheck={false} />
</label>
<p className="addp-privacy">{t("API Key 仅保存在运行内核的这台机器上。")}</p>
{sibling && (
@@ -300,7 +311,7 @@ export function AddProvider({
role="switch"
aria-label={t("发送思考控制")}
aria-checked={protocolCanThink && thinkingOn}
disabled={!protocolCanThink || busy || checkingModel !== ""}
disabled={!protocolCanThink || busy || testing}
onClick={() => setThinkingOn((v) => !v)}
><span /></button>
</div>
@@ -322,7 +333,7 @@ export function AddProvider({
<small>{t("仅当该地址通过系统代理无法连接、直连可用时开启。")}</small>
</span>
<button type="button" className="switch-control" data-action="provider.draft" data-value="no-proxy" role="switch" aria-label={t("绕过系统代理")} aria-checked={noProxy || (probe?.noProxy ?? false)}
disabled={busy || checkingModel !== "" || probe?.noProxy === true} onClick={() => {
disabled={busy || testing || probe?.noProxy === true} onClick={() => {
setNoProxy((v) => !v);
setFacts(clearModelCheckFacts);
}}><span /></button>
@@ -351,7 +362,7 @@ export function AddProvider({
<span>{t(protocolCanProbe ? "至少手动添加一个模型 ID,也可尝试从接口读取" : "这个协议没有模型列表接口,直接填写服务商给出的模型 ID")}</span>
</div>
{protocolCanProbe && (
<button className="act quiet" data-action="provider.probe" onClick={connect} disabled={busy || checkingModel !== "" || baseUrl.trim() === ""}>
<button className="act quiet" data-action="provider.probe" onClick={connect} disabled={busy || testing || baseUrl.trim() === ""}>
{t(busy ? "检测中…" : "验证连接并读取")}
</button>
)}
@@ -368,15 +379,20 @@ export function AddProvider({
<div className="mlhead">
<span className="ttl">{t("启用的模型")}</span>
<span className="count">{t("已启用 {on}/{all}", { on: picked.length, all: models.length })}</span>
<button className="mrefresh" data-action="provider.model-check-all" onClick={testAll}
disabled={busy || testing || picked.length === 0 || baseUrl.trim() === ""} aria-busy={batch === "running" || undefined}>
{t("测试已启用模型({n})", { n: picked.length })}
</button>
</div>
<p className="mguide">{t("目录只用于发现,不是白名单。直接输入服务商给出的原始模型 ID 即可。")}</p>
<p className="mguide">{t("目录只用于发现,不是白名单。未列出的模型会按原始 ID 保存;「测试已启用模型」会给每个已勾选的模型各发送一次小请求,可能产生少量 Token 费用。")}</p>
{batch === "stopped" && <p className="mdiff" role="status">{t("已停止启动新的验证:草稿已改动")}</p>}
<ModelChoice
models={models}
picked={picked}
vision={probe?.vision ?? []}
facts={facts}
onCheck={checkModel}
checkDisabled={busy || checkingModel !== "" || baseUrl.trim() === ""}
checkDisabled={busy || testing || baseUrl.trim() === ""}
onToggle={toggle}
onAdd={addModel}
/>
@@ -411,10 +427,10 @@ export function AddProvider({
</div>
)}
<div className="acts addp-footer">
<button className="act" data-action="provider.add" data-primary onClick={save} aria-describedby={nameBad ? "addp-name-rule" : undefined} disabled={busy || composing || checkingModel !== "" || picked.length === 0 || name.trim() === "" || nameBad || kind === "" || baseUrl.trim() === "" || extraBad}>
<button className="act" data-action="provider.add" data-primary onClick={save} aria-describedby={nameBad ? "addp-name-rule" : undefined} disabled={busy || composing || testing || picked.length === 0 || name.trim() === "" || nameBad || kind === "" || baseUrl.trim() === "" || extraBad}>
{t(busy ? "保存中…" : "添加来源")}
</button>
<button className="act" onClick={onCancel} disabled={busy || checkingModel !== ""}>{t("取消")}</button>
<button className="act" onClick={onCancel} disabled={busy || testing}>{t("取消")}</button>
</div>
</div>
</fieldset>
+19 -3
View File
@@ -1,7 +1,7 @@
import { watchTreeWake } from "./treewake";
import { usePinnedSessions } from "./usePinnedSessions";
import { AutoArchiveNotice } from "./AutoArchiveNotice";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
import { reason } from "../i18n/kernel";
import { t } from "../i18n";
import type { AccountState, AgentPort, ProviderSetup } from "../port/port";
@@ -36,6 +36,7 @@ import { Sidebar } from "./Sidebar";
import { Sky } from "./Sky";import { useAddWorkspace } from "./addws";
import { AddWorkspacePrompt } from "./AddWorkspacePrompt";
import { PaneTabs } from "./PaneTabs";
import { onShowsSessionTabsChange, showsSessionTabs } from "../state/prefs";
import { isUnread, useViewed } from "./unread";
import { Onboarding } from "./Onboarding";
import { Welcome } from "./Welcome";
@@ -75,6 +76,7 @@ const NO_REPORT: PaneReport = {
// conversation — its transcript, metrics and event stream — lives in the Pane
// that owns it. That split is what lets two sessions run side by side.
export function App({ hub }: { hub: HubPort }) {
const sessionTabs = useSyncExternalStore(onShowsSessionTabsChange, showsSessionTabs, showsSessionTabs);
const [runtimes, setRuntimes] = useState<RuntimeView[]>([]);
const [panesRead, setPanesRead] = useState(false);
const [active, setActive] = useState(savedActivePane);
@@ -102,6 +104,7 @@ export function App({ hub }: { hub: HubPort }) {
const [dockLimit, setDockLimit] = useState(() => dockMax(document.body.clientWidth));
const [report, setReport] = useState<PaneReport>(NO_REPORT);
const [findPulse, setFindPulse] = useState(0);
const [filesRequests, setFilesRequests] = useState<Record<string, number>>({});
const [error, setError] = useState("");
// false = closed, true = open at its last section, a string = open there.
const [settings, setSettings] = useState<string | boolean>(false);
@@ -342,6 +345,16 @@ export function App({ hub }: { hub: HubPort }) {
void reloadRemotes();
}, [reloadRemotes]);
const openFiles = useCallback(() => {
if (!panePorts.has(active)) {
setError(t("请先打开一个会话,再查看它的文件。"));
return;
}
hidePrefs();
if (roomGaveUp("scene")) setRail(false);
setFilesRequests((requests) => ({ ...requests, [active]: (requests[active] ?? 0) + 1 }));
}, [active, panePorts, hidePrefs]);
const openPane = useOpenPane({ hub, runtimes, panePorts, active, reportsRef, runsRef, setRuntimes, setActive, setTakeover, reloadPanes: refreshPanes, sw });
// Awaitable because deleting a conversation has to close its pane first and
@@ -565,7 +578,6 @@ export function App({ hub }: { hub: HubPort }) {
rail={rail}
theme={scheme}
onRail={() => setRail((v) => !v)}
railLocked={!!settings}
onTheme={() => setTheme(scheme === "dark" ? "light" : "dark")}
onFind={openFind}
hub={hub} onError={fail}
@@ -578,6 +590,7 @@ export function App({ hub }: { hub: HubPort }) {
<Nav
at={settings === false ? null : settings === true ? "" : settings}
onGo={showPrefs}
onFiles={openFiles}
onHome={hidePrefs}
onRail={() => setRail((v) => !v)}
onFeedback={() => setFeedback(feedbackEntryTab(feedbackUnread))}
@@ -619,6 +632,7 @@ export function App({ hub }: { hub: HubPort }) {
accountUnread={accountUnread}
wallet={report.wallet}
onSettings={showPrefs}
onFiles={openFiles}
onFeedback={(tab) => setFeedback(tab ?? "send")}
feedbackUnread={feedbackUnread}
onFind={openFind}
@@ -638,7 +652,7 @@ export function App({ hub }: { hub: HubPort }) {
{/* One conversation on screen at a time. Side by side, two panes
squeezed each other and a glance could not tell which composer
belonged to which run; the ones behind keep streaming either way. */}
{tabs.length > 1 && (
{sessionTabs && tabs.length > 1 && (
<PaneTabs
tabs={tabs}
active={active}
@@ -671,6 +685,7 @@ export function App({ hub }: { hub: HubPort }) {
onSessionChanged={refreshPanes}
pulse={settingsPulse}
findPulse={findPulse}
filesPulse={filesRequests[rt.id] ?? 0}
alert={rt.id === active ? bar : undefined}
needsProject={needsProject}
onOpenProject={() => {
@@ -682,6 +697,7 @@ export function App({ hub }: { hub: HubPort }) {
setClaimed(true);
}}
onSettings={(section) => section ? showPrefs(section) : showPrefs()}
onOpenSession={(path) => void openPane({ root: rt.root, sessionPath: path }).catch(fail)}
theme={scheme}
dockW={shownDockW}
dockMax={dockLimit}
@@ -14,6 +14,8 @@ import { Folding } from "./Folding";
import { WindowSection } from "./WindowSection";
import { NavRailSection } from "./NavRailSection";
import { GraphicsSection } from "./GraphicsSection";
import { SendShortcut } from "./SendShortcut";
import { SessionTabsSetting } from "./SessionTabsSetting";
import { ThemeImport } from "./ThemeImport";
import { ThemeRemove } from "./ThemeRemove";
import { useThemeInventory } from "./useThemeInventory";
@@ -545,6 +547,8 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
<WindowSection port={port} />
<NavRailSection />
<GraphicsSection />
<SendShortcut />
<SessionTabsSetting />
<Folding />
<Notifications port={port} />
@@ -6,6 +6,7 @@ import { SWAP_MARK } from "./swap";
import { occluded, visibleBox } from "./occlusion";
import { BrowserFailure } from "./BrowserFailure";
import { AgentPageNote } from "./AgentPageNote";
import { readPageTabs } from "./browser_open";
const START = "reasonix://start";
@@ -162,7 +163,7 @@ export function useBrowserTabs(port: AgentPort, moved: number): BrowserTab[] {
const [tabs, setTabs] = useState<BrowserTab[]>(UNREAD_TABS);
useEffect(() => {
let live = true;
port.browserTabs().then((next) => live && setTabs(next), () => {});
readPageTabs(port).then((next) => live && setTabs(next), () => {});
return () => { live = false; };
}, [port, moved]);
return tabs;
@@ -0,0 +1,42 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { t } from "../i18n";
import { keyOf, type Surface } from "./workbench_tabs";
import { listenAction } from "./listen";
import { pinToViewport } from "./place";
interface Props {
menu: { surface: Surface; x: number; y: number; anchor: HTMLElement };
others: boolean; onDismiss: () => void; onClose: (mode: "one" | "others" | "all") => void;
}
export function BrowserTabMenu({ menu, others, onDismiss, onClose }: Props) {
const box = useRef<HTMLDivElement>(null);
useEffect(() => {
const dismiss = (event: Event) => { if (!box.current?.contains(event.target as Node)) onDismiss(); };
const blur = () => onDismiss();
const off = listenAction(document, "pointerdown", { action: "layer.dismiss", listener: dismiss });
window.addEventListener("blur", blur);
box.current?.querySelector<HTMLButtonElement>("button")?.focus();
return () => {
off();
window.removeEventListener("blur", blur);
if (menu.anchor.isConnected) menu.anchor.focus();
};
}, [menu, onDismiss]);
return createPortal(
<div className="tabmenu" data-action-keydown="layer.dismiss" role="menu" aria-label={t("浏览器标签")} ref={(el) => { box.current = el; if (el) pinToViewport(el, menu.x, menu.y); }} onKeyDown={(e) => {
if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); e.stopPropagation(); onDismiss(); }
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return;
e.preventDefault();
const items = [...e.currentTarget.querySelectorAll<HTMLButtonElement>("button:not(:disabled)")];
const at = items.indexOf(document.activeElement as HTMLButtonElement);
const next = e.key === "Home" ? 0 : e.key === "End" ? items.length - 1 : (at + (e.key === "ArrowUp" ? -1 : 1) + items.length) % items.length;
items[next]?.focus();
}}>
<button role="menuitem" data-action="workbench.close" data-target={keyOf(menu.surface)} onClick={() => onClose("one")}>{t("关闭")}</button>
<button role="menuitem" data-action="workbench.browser-close" data-target={keyOf(menu.surface)} data-value="others" disabled={!others} onClick={() => onClose("others")}>{t("关闭其他浏览器标签")}</button>
<button role="menuitem" data-action="workbench.browser-close" data-target={keyOf(menu.surface)} data-value="all" onClick={() => onClose("all")}>{t("关闭全部浏览器标签")}</button>
</div>, document.body,
);
}
@@ -0,0 +1,14 @@
import { t } from "../i18n";
import { StudioIcon } from "./StudioIcon";
import { ariaChord, chord } from "./keys";
export function BrowserWideToggle({ wide, onToggle }: { wide: boolean; onToggle: () => void }) {
const label = wide ? t("恢复浏览器分栏") : t("加宽浏览器");
return (
<button className="workbench-files" data-action="browser.wide" aria-pressed={wide}
aria-label={label} aria-keyshortcuts={`${ariaChord("B")}+Shift`}
title={`${label} (${chord("Shift+B")})`} onClick={onToggle}>
<StudioIcon name="panel" />
</button>
);
}
+2 -4
View File
@@ -32,8 +32,6 @@ interface Props {
rail: boolean;
theme: string;
onRail: () => void;
/** The settings sheet is a modal over the window: what is behind it is not for pressing. */
railLocked?: boolean;
onTheme: () => void;
onFind: () => void;
// The window's port, for what belongs to the window rather than a pane.
@@ -41,7 +39,7 @@ interface Props {
onError?: (e: unknown) => void;
}
export function Chrome({ port, status, title, steer, onSettings, onBrowser, browser, account, host, rail, theme, onRail, railLocked = false, onTheme, onFind, hub, onError }: Props) {
export function Chrome({ port, status, title, steer, onSettings, onBrowser, browser, account, host, rail, theme, onRail, onTheme, onFind, hub, onError }: Props) {
const root = status?.workspaceRoot || status?.cwd || "";
const project = root ? base(root) : "—";
// Only for the "隔离" tag: the folder list and the switch itself moved to the
@@ -73,7 +71,7 @@ export function Chrome({ port, status, title, steer, onSettings, onBrowser, brow
return (
<>
{hub && <DeviceBar hub={hub} />}
<button className="studio-menu" data-action="chrome.rail" onClick={onRail} disabled={railLocked} aria-pressed={rail} aria-label={rail ? t("收起工作区栏") : t("展开工作区栏")}>
<button className="studio-menu" data-action="chrome.rail" onClick={onRail} aria-pressed={rail} aria-label={rail ? t("收起工作区栏") : t("展开工作区栏")}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 4h10M3 8h10M3 12h10" /></svg>
</button>
<div className="chrome" onDoubleClick={zoomOnTitleBar}>
@@ -0,0 +1,54 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { t } from "../i18n";
import type { AgentPort } from "../port/port";
import type { WireEvent } from "../port/wire";
import { useDismiss } from "./dismiss";
import "../styles/compact-now.css";
export interface CompactAction {
busy: boolean;
pending: boolean;
run: () => void;
}
export function useCompactNow(port: AgentPort, blocked: boolean, onError: (error: unknown) => void) {
const [pending, setPending] = useState(false);
const held = useRef(false);
const release = useCallback(() => { held.current = false; setPending(false); }, []);
const onEvent = useCallback((event: WireEvent) => {
if (event.kind === "notice" && (event.code === "compacted" || event.code === "compact_declined" || event.code === "compact_failed")) release();
}, [release]);
const run = useCallback(() => {
if (blocked || held.current) return;
held.current = true;
setPending(true);
// /submit acknowledges admission; the typed notice settles the operation.
void port.submit("/compact").catch((error) => { release(); onError(error); });
}, [port, blocked, onError, release]);
return useMemo(() => ({ busy: blocked || pending, pending, run, onEvent, release }), [blocked, pending, run, onEvent, release]);
}
export function CompactNow({ action }: { action: CompactAction }) {
const [asking, setAsking] = useState(false);
const box = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const close = useCallback(() => setAsking(false), []);
useDismiss(asking, box, close);
const cancel = () => { close(); trigger.current?.focus(); };
return (
<div className="compact-now" ref={box}>
<button ref={trigger} className="btn sm" data-action="compaction.confirm" disabled={action.busy} aria-expanded={asking} onClick={() => setAsking(true)}>
{action.pending ? t("正在压缩…") : t("立即压缩")}
</button>
{asking && (
<div className="wsconfirm" role="alertdialog" aria-label={t("压缩当前上下文?")}>
<div className="wsconfirm-t"><span className="q">{t("压缩当前上下文?")}</span><span className="h">{t("模型将依据摘要继续工作;完整对话记录会保留。")}</span></div>
<div className="wsconfirm-a">
<button autoFocus data-action="layer.dismiss" onClick={cancel}>{t("取消")}</button>
<button data-action="compaction.now" disabled={action.busy} onClick={() => { close(); action.run(); trigger.current?.focus(); }}>{t("确认压缩")}</button>
</div>
</div>
)}
</div>
);
}
@@ -10,6 +10,16 @@ import { draftKey } from "./drafts";
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
it("labels the active model with its alias while keeping the raw reference in details", async () => {
const port = new MockPort();
vi.spyOn(port, "models").mockResolvedValue([
{ ref: "relay/vendor/long-v1", provider: "relay", model: "vendor/long-v1", modelAlias: "Short" },
]);
draw({ port, st: status({ modelRef: "relay/vendor/long-v1" }) });
const trigger = await screen.findByRole("button", { name: /Short/ });
expect(trigger.title).toBe("relay/vendor/long-v1");
});
// The workspace's git answer, the way /workspace/git words it. Tests that name
// an override get it; the rest get a plain repository on main.
const git = (over: Partial<WorkspaceGit> = {}): WorkspaceGit =>
@@ -344,6 +354,33 @@ describe("composer stop under a starved window", () => {
});
describe("composer menus", () => {
it("shows the session's current model first and refreshes that position with canonical status", async () => {
const port = new MockPort();
const models: ModelEntry[] = [
{ ref: "alpha/first", provider: "alpha", model: "first", vendor: "alpha.example" },
{ ref: "alpha/second", provider: "alpha", model: "second", vendor: "alpha.example" },
{ ref: "beta/third", provider: "beta", model: "third", vendor: "beta.example" },
];
vi.spyOn(port, "models").mockResolvedValue(models);
const setModel = vi.spyOn(port, "setModel").mockResolvedValue(undefined);
const onChanged = vi.fn();
const props = { port, status: status({ modelRef: "beta/third" }), running: false,
onSubmit: async () => true, onChanged, onError: vi.fn() };
const view = render(<Composer {...props} />);
fireEvent.click(view.container.querySelector('button.model-picker')!);
await waitFor(() => expect(screen.getAllByRole("menuitem")).toHaveLength(4));
expect(screen.getAllByRole("menuitem")[0].getAttribute("data-value")).toBe("beta/third");
expect(screen.getAllByRole("menuitem")[0].hasAttribute("data-on")).toBe(true);
fireEvent.click(screen.getAllByRole("menuitem").find((el) => el.getAttribute("data-value") === "alpha/second")!);
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
expect(setModel).toHaveBeenCalledExactlyOnceWith("alpha/second");
view.rerender(<Composer {...props} status={status({ modelRef: "alpha/second" })} />);
fireEvent.click(view.container.querySelector('button.model-picker')!);
expect(screen.getAllByRole("menuitem")[0].getAttribute("data-value")).toBe("alpha/second");
expect(screen.getAllByRole("menuitem")[0].hasAttribute("data-on")).toBe(true);
expect(screen.getAllByRole("menuitem").filter((el) => el.getAttribute("data-value") === "alpha/second")).toHaveLength(1);
});
it("shows that slash skills are loading until the catalog answers", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
@@ -691,3 +728,69 @@ describe("a line taken back from the queue", () => {
expect(box.value).toBe("甲");
});
});
describe("send shortcut preference", () => {
it("keeps Enter for multiline drafting and sends with Control+Enter", async () => {
localStorage.setItem("rx-send-shortcut", "modifier_enter");
const { box, onSubmit } = draw();
fireEvent.change(box, { target: { value: "第一行\n第二行", selectionStart: 7 } });
expect(fireEvent.keyDown(box, { key: "Enter" })).toBe(true);
expect(onSubmit).not.toHaveBeenCalled();
expect(box.getAttribute("aria-keyshortcuts")).toBe("Control+Enter");
expect(screen.getByText("Ctrl Enter 发送 · Enter 换行")).toBeTruthy();
fireEvent.keyDown(box, { key: "Enter", ctrlKey: true });
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("第一行\n第二行"));
});
it("never sends an IME confirmation in modifier mode", async () => {
localStorage.setItem("rx-send-shortcut", "modifier_enter");
const { box, onSubmit } = draw();
fireEvent.change(box, { target: { value: "中文候选", selectionStart: 4 } });
fireEvent.compositionStart(box);
fireEvent.keyDown(box, { key: "Enter", ctrlKey: true, isComposing: true });
fireEvent.compositionEnd(box);
fireEvent.keyDown(box, { key: "Enter", ctrlKey: true });
expect(onSubmit).not.toHaveBeenCalled();
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 120)); });
fireEvent.keyDown(box, { key: "Enter" });
expect(onSubmit).not.toHaveBeenCalled();
fireEvent.keyDown(box, { key: "Enter", ctrlKey: true });
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("中文候选"));
});
it("preserves Alt+Enter submission in Enter mode", async () => {
const { box, onSubmit } = draw();
fireEvent.change(box, { target: { value: "兼容已有快捷键", selectionStart: 7 } });
fireEvent.keyDown(box, { key: "Enter", altKey: true });
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("兼容已有快捷键"));
});
it.each([false, true])("uses tap hints on touch screens in modifier mode (running=%s)", (running) => {
localStorage.setItem("rx-send-shortcut", "modifier_enter");
touchPointer();
draw({ running });
expect(screen.getByText(running ? "点按插话 · 回车换行" : "点按发送 · 回车换行")).toBeTruthy();
});
it.each(["Enter", "Tab", "Control+Enter"])("routes %s correctly while a slash completion owns Enter", async (key) => {
localStorage.setItem("rx-send-shortcut", "modifier_enter");
const port = new MockPort();
vi.spyOn(port, "complete").mockResolvedValue({ kind: "slash", from: 0, to: 4, query: "/rev", items: [{ label: "/review", insert: "/review ", kind: "skill" }] });
const { box, onSubmit } = draw({ port });
fireEvent.change(box, { target: { value: "/rev", selectionStart: 4 } });
await screen.findByRole("option", { name: /review/ });
fireEvent.keyDown(box, { key: key === "Tab" ? "Tab" : "Enter", ctrlKey: key === "Control+Enter" });
if (key === "Control+Enter") await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("/rev"));
else { expect(box.value).toBe("/review "); expect(onSubmit).not.toHaveBeenCalled(); }
});
it("keeps the modifier-to-steer hint while a turn runs", () => {
localStorage.setItem("rx-send-shortcut", "modifier_enter");
const { box, onSubmit } = draw({ running: true });
fireEvent.change(box, { target: { value: "补充一句", selectionStart: 4 } });
fireEvent.keyDown(box, { key: "Enter" });
expect(onSubmit).not.toHaveBeenCalled();
expect(screen.getByText("Ctrl Enter 插话 · Enter 换行")).toBeTruthy();
});
});
+13 -11
View File
@@ -12,6 +12,8 @@ import { ChipMirror, useSkillChips } from "./ChipMirror";
import { useIme } from "./ime";
import { usePlaceholderFit } from "./placeholderfit";
import { useFitHeight } from "./fitHeight";
import { useSendShortcut } from "../state/prefs";
import { sendAria, sendHint, sendsOnKey } from "./sendkey";
import { countLines, pasteIsLong, planTone, planVerb } from "./intake";
import { kindOf, nameOf, previewURL } from "./chipfile";
import { useIntake } from "./useIntake";
@@ -179,6 +181,7 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
box.current?.focus();
}, chips);
const ime = useIme();
const sendShortcut = useSendShortcut();
// A counter, not a boolean: asking twice in a row has to move the cursor twice.
useEffect(() => {
@@ -355,7 +358,8 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
// composer answering for an endpoint that never spoke.
const declared = efforts.length > 0;
const forcedThinking = forcesThinkingFor(models, status?.modelRef);
const modelLb = status?.modelRef?.replace(/^[^/]+\//, "") ?? status?.label ?? "—";
const modelLb = models.find((m) => m.ref === status?.modelRef)?.modelAlias?.trim()
|| (status?.modelRef?.replace(/^[^/]+\//, "") ?? status?.label ?? "—");
// A model switch rebuilds the runtime kernel-side; other controls here may
// land at once. Each click needs its own pending state: greying the whole
// shelf says nothing can change while only one control waits on the kernel.
@@ -500,7 +504,7 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
role="combobox"
aria-label={t("任务输入")}
aria-describedby={guide}
aria-keyshortcuts={touch ? undefined : "Enter Shift+Enter"}
aria-keyshortcuts={sendAria(sendShortcut, touch)}
aria-busy={submitting}
readOnly={submitting}
aria-expanded={menu.open}
@@ -552,15 +556,15 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
if (e.key === "Enter") e.preventDefault();
return;
}
const sendKey = sendsOnKey(e, sendShortcut, touch);
if (menu.open && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
e.preventDefault();
menu.move(e.key === "ArrowDown" ? 1 : -1);
return;
}
// Tab completes, always. Enter belongs to the menu only where the
// line is not yet a message — a half-typed command — or where the
// user went looking through the list themselves.
if (menu.open && (e.key === "Tab" || (e.key === "Enter" && menu.ownsEnter)) && !e.shiftKey) {
// Tab completes; Enter completes a partial command or an explicitly selected item.
// The configured modifier chord sends the draft even with a menu open.
if (menu.open && (e.key === "Tab" || (e.key === "Enter" && menu.ownsEnter && !(sendShortcut === "modifier_enter" && sendKey))) && !e.shiftKey) {
e.preventDefault();
menu.accept();
return;
@@ -573,7 +577,7 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
menu.dismiss();
return;
}
if (e.key === "Enter" && !e.shiftKey && !touch) {
if (sendKey) {
e.preventDefault();
send();
}
@@ -595,9 +599,7 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
? t("正在添加附件…")
: failed
? t("有附件添加失败,请重试或移除")
: t(touch
? running ? "点按插话 · 回车换行" : "点按发送 · 回车换行"
: running ? "Enter 插话 · Shift+Enter 换行" : "Enter 发送 · Shift+Enter 换行")}
: sendHint(sendShortcut, running, touch)}
</span>
{showCount && <span className="fcount">{t("{n} 字 · {lines} 行", { n: text.length, lines })}</span>}
</div>
@@ -668,7 +670,7 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
place="bottom"
title={status?.modelRef ?? modelLb}
current={status?.modelRef}
items={modelMenu(models, providerOrder)}
items={modelMenu(models, providerOrder, status?.modelRef)}
menuClassName="studio-model-menu"
searchAlways
searchPlaceholder={t("搜索模型或服务商…")}
@@ -1,13 +1,15 @@
import { t } from "../i18n";
import type { ContextBreakdown, McpEntry } from "../port/port";
import { CompactNow, type CompactAction } from "./CompactNow";
const compact = (n: number): string => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1_000 ? `${Math.round(n / 100) / 10}k` : String(n);
export function ContextSummaryCard({ context, mcp, percent, onManage, className = "" }: {
export function ContextSummaryCard({ context, mcp, percent, onManage, compact: action, className = "" }: {
context: ContextBreakdown | null;
mcp: McpEntry[];
percent: number | null;
onManage: () => void;
compact?: CompactAction;
className?: string;
}) {
const active = mcp.filter((entry) => entry.enabled);
@@ -31,6 +33,7 @@ export function ContextSummaryCard({ context, mcp, percent, onManage, className
<div className="chrome-context-parts">{parts.map(([label, value, key]) => <div key={key}><i data-part={key} /><span>{label}</span><b>{compact(value)}</b></div>)}</div>
</>
) : <p>{t("暂时没有上下文数据")}</p>}
{action && <CompactNow action={action} />}
<section>
<div><b>MCP</b><small>{ready.length} / {active.length} {t("个已连接")} · {tools} {t("个工具")}</small></div>
{active.slice(0, 4).map((server) => <div className="chrome-mcp-row" key={server.name}><i data-ready={server.state === "ready" ? "" : undefined} /><span>{server.name}</span><small>{server.state === "ready" ? `${server.tools} ${t("个工具")}` : t("等待连接")}</small></div>)}
+2 -1
View File
@@ -22,7 +22,7 @@ export async function copyText(text: string) {
}
}
export function CopyButton({ text, iconOnly = false, showFeedback = false, className, label: what, ariaLabel }: { text: string; iconOnly?: boolean; showFeedback?: boolean; className?: string; label?: string; ariaLabel?: string }) {
export function CopyButton({ text, iconOnly = false, showFeedback = false, className, label: what, ariaLabel, role }: { text: string; iconOnly?: boolean; showFeedback?: boolean; className?: string; label?: string; ariaLabel?: string; role?: "menuitem" }) {
const [state, setState] = useState<"idle" | "done" | "failed">("idle");
const timer = useRef<number | null>(null);
@@ -44,6 +44,7 @@ export function CopyButton({ text, iconOnly = false, showFeedback = false, class
<button
className={className ? `copy ${className}` : "copy"}
type="button"
role={role}
data-icon={iconOnly ? "" : undefined}
data-state={state}
data-feedback={showFeedback && state !== "idle" ? "" : undefined}
+12 -49
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import type { Protocol, ProviderCheck, ProviderEntry } from "../port/port";
import { checkedFact, clearModelCheckFacts, ModelChoice, type ModelFact } from "./ModelChoice";
@@ -12,6 +12,7 @@ import { EffortShape } from "./EffortShape";
import type { ModelEffort, ModelLimit, ProviderEdit } from "../port/port";
import { KIND_LABEL } from "./vendors";
import { ModelLimits, limitTextOf, limitsToSend, type LimitText } from "./ModelLimits";
import { useModelBatch } from "./useModelBatch";
// Only what this form owns is sent: the entry keeps its prices, effort
// vocabularies and everything else the panel cannot show.
@@ -41,6 +42,7 @@ export function EditConn({
const [apiKey, setApiKey] = useState("");
const [models, setModels] = useState<string[]>(seededModels);
const [picked, setPicked] = useState<string[]>(entry.models);
const [aliases, setAliases] = useState<Record<string, string>>(entry.modelAliases ?? {});
const [vision, setVision] = useState<string[]>(seededVision);
const [visionSettable, setVisionSettable] = useState<string[] | undefined>(
entry.visionSettable ? [...new Set([...entry.visionSettable, ...(initialCheck?.vision ?? [])])] : undefined,
@@ -48,9 +50,6 @@ export function EditConn({
const [facts, setFacts] = useState<Record<string, ModelFact>>(() => modelFacts(entry.models, initialCheck));
const [diff, setDiff] = useState(() => initialCheck?.ok ? catalogDiff(entry.models, initialCheck.models ?? [], false) : null);
const [checkingModel, setCheckingModel] = useState("");
const [batch, setBatch] = useState<"idle" | "running" | "stopped">("idle");
const run = useRef<AbortController | null>(null);
const testing = checkingModel !== "" || batch === "running";
const [def, setDef] = useState(entry.default || entry.models[0] || "");
const [err, setErr] = useState<{ text: string; kind: "save" | "unapplied" } | null>(null);
const [refreshFail, setRefreshFail] = useState("");
@@ -181,43 +180,6 @@ export function EditConn({
}
};
const stopBatch = useCallback(() => {
if (!run.current) return false;
run.current.abort();
run.current = null;
setFacts(stopChecking);
setBatch("stopped");
return true;
}, []);
const testAll = async () => {
if (run.current || picked.length === 0) return;
const ctl = new AbortController();
run.current = ctl;
setBatch("running");
const queue = [...picked];
const lane = async () => {
for (let model = queue.shift(); model !== undefined && !ctl.signal.aborted; model = queue.shift()) {
const m = model;
setFacts((cur) => ({ ...cur, [m]: { ...(cur[m] ?? { origin: "configured" }), checking: true } }));
let next: (fact: ModelFact | undefined) => ModelFact;
try {
const got = await port.checkProviderModel({ name: entry.name, model: m, baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), kind });
next = (fact) => ({ ...checkedFact(fact, "configured", got), checking: false });
} catch {
next = (fact) => ({ ...(fact ?? { origin: "configured" }), status: "unknown", reason: "network", checking: false });
}
if (ctl.signal.aborted) return;
setFacts((cur) => ({ ...cur, [m]: next(cur[m]) }));
}
};
await Promise.all([lane(), lane()]);
if (run.current === ctl) {
run.current = null;
setBatch("idle");
}
};
// A connection list typed here is what every inheriting model gets; with
// none typed, the kernel's answer holds only while the form still matches
// what was saved.
@@ -251,6 +213,7 @@ export function EditConn({
baseUrl: baseUrl.trim(),
apiKey: apiKey.trim(),
models: picked,
modelAliases: Object.fromEntries(Object.entries(aliases).filter(([m, value]) => picked.includes(m) && value.trim()).map(([m, value]) => [m, value.trim()])),
default: picked.includes(def) ? def : picked[0] ?? "",
vision: vision.filter((m) => picked.includes(m)),
contextWindow: Number(win.replace(/\D/g, "")) || 0,
@@ -278,10 +241,12 @@ export function EditConn({
const dirty = draftKey !== stored;
useEffect(() => onDirty?.(dirty), [dirty, onDirty]);
useEffect(() => () => onDirty?.(false), [onDirty]);
useEffect(() => {
if (!stopBatch()) setBatch((b) => (b === "stopped" ? "idle" : b));
}, [draftKey, stopBatch]);
useEffect(() => () => run.current?.abort(), []);
const { batch, testAll } = useModelBatch({
port, picked, draftKey, setFacts, origin: "configured",
blocked: checkingModel !== "" || busy !== "",
request: { name: entry.name, baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), kind },
});
const testing = checkingModel !== "" || batch === "running";
const [edited, setEdited] = useState(false);
if (dirty && !edited) setEdited(true);
@@ -413,6 +378,8 @@ export function EditConn({
facts={facts}
visionLocked={visionLocked}
onCheck={checkModel}
aliases={aliases}
onAlias={(m, value) => setAliases((current) => ({ ...current, [m]: value }))}
checkDisabled={busy !== "" || testing}
onToggle={(m) => toggle(picked, setPicked, m)}
onVision={(m) => toggle(vision, setVision, m)}
@@ -614,7 +581,3 @@ const PHASE_TEXT: Record<Phase, string> = {
clean: "没有更改", dirty: "有未保存的更改", failed: "有未保存的更改", saving: "正在保存…",
saved: "已保存", unapplied: "已保存,尚未生效",
};
function stopChecking(facts: Record<string, ModelFact>): Record<string, ModelFact> {
return Object.fromEntries(Object.entries(facts).map(([model, fact]) => [model, fact.checking ? { ...fact, checking: false } : fact]));
}
@@ -3,6 +3,7 @@ import { t } from "../i18n";
import { systemLabel } from "./osname";
import { tx } from "../i18n/rich";
import { FEEDBACK_CATEGORIES, FEEDBACK_CODE, FEEDBACK_REPO_ISSUES, type FeedbackCategory, type FeedbackEnv, type FeedbackReceipt } from "../port/feedback";
import { host } from "../port/host";
import type { AgentPort } from "../port/port";
import { CopyButton } from "./CopyButton";
import { StudioIcon } from "./StudioIcon";
@@ -52,6 +53,7 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
const [over, setOver] = useState(false);
const [phase, setPhase] = useState<"editing" | "sending" | "sent">("editing");
const [failure, setFailure] = useState<FeedbackFailure | null>(null);
const [logsFailed, setLogsFailed] = useState(false);
const [sent, setSent] = useState<FeedbackReceipt | null>(null);
const key = useRef(uuid());
const attempt = useRef("");
@@ -383,6 +385,23 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
<dd>{env.env.providerKind}</dd>
</dl>
<p className="fbk-hint">{t("只读。不包含密钥、地址或任何文件内容。")}</p>
{host().revealsLogs() && (
<div className="fbk-logs">
<button
type="button"
className="btn sm"
data-action="feedback.logs"
onClick={() => {
setLogsFailed(false);
void host().revealLogs().then((why) => setLogsFailed(why !== null));
}}
>
{t("打开日志文件夹")}
</button>
<p className="fbk-hint">{t("维护者向你要日志时,打开这个文件夹,把最新的 shell.log 和 host.log 发给我们。发之前可以先自己看一眼。")}</p>
{logsFailed && <p className="fbk-hint" role="alert" data-tone="error">{t("无法打开日志文件夹")}</p>}
</div>
)}
</section>
)}
+24 -14
View File
@@ -108,8 +108,7 @@ interface Props {
onUnread?: (n: number) => void;
}
// Opening the list is what reads the replies: each report with new ones is
// marked seen, and its "new" marks stay until the page is closed.
// A reply is read only in an expanded thread; its "new" mark stays for this visit.
export function FeedbackMine({ port, onFile, onUnread }: Props) {
const [mine, setMine] = useState<Mine | null>(null);
const [failure, setFailure] = useState<FeedbackFailure | null>(null);
@@ -122,12 +121,24 @@ export function FeedbackMine({ port, onFile, onUnread }: Props) {
report.current = onUnread;
const itemsRef = useRef<Mine["items"]>([]);
const pending = useRef(new Set<string>());
const tell = () => report.current?.(itemsRef.current.filter((i) => i.needsInput || pending.current.has(i.receipt)).length);
const unfolded = (receipt: string) =>
port.feedbackSeen(receipt, newest(itemsRef.current.find((i) => i.receipt === receipt)!)).then(() => {
pending.current.delete(receipt);
tell();
}).catch(() => {});
const views = useRef(new Map<string, boolean>());
const tell = useCallback(() => report.current?.(itemsRef.current.filter((i) => i.needsInput || pending.current.has(i.receipt)).length), []);
const markSeen = useCallback(async (item: FeedbackItem) => {
const upTo = newest(item);
await port.feedbackSeen(item.receipt, upTo);
const current = itemsRef.current.find((i) => i.receipt === item.receipt);
if (current && newest(current) <= upTo) pending.current.delete(item.receipt);
tell();
}, [port, tell]);
const view = (receipt: string, all: boolean | null) => {
if (all === null) {
views.current.delete(receipt);
return;
}
views.current.set(receipt, all);
const item = itemsRef.current.find((i) => i.receipt === receipt)!;
if (pending.current.has(receipt) && (all || !foldedUnread(item))) void markSeen(item).catch(() => {});
};
const load = useCallback(() => {
setLoading(true);
@@ -142,17 +153,16 @@ export function FeedbackMine({ port, onFile, onUnread }: Props) {
return next;
});
itemsRef.current = m.items;
pending.current = new Set(m.items.filter((i) => i.unreadReplies > 0 && foldedUnread(i)).map((i) => i.receipt));
const shown = m.items.filter((i) => i.unreadReplies > 0 && !pending.current.has(i.receipt));
const read = await Promise.allSettled(shown.map((i) => port.feedbackSeen(i.receipt, newest(i))));
shown.forEach((i, k) => read[k]!.status === "rejected" && pending.current.add(i.receipt));
pending.current = new Set(m.items.filter((i) => i.unreadReplies > 0).map((i) => i.receipt));
const shown = m.items.filter((i) => i.unreadReplies > 0 && views.current.has(i.receipt) && (views.current.get(i.receipt) || !foldedUnread(i)));
await Promise.allSettled(shown.map(markSeen));
tell();
const fresher = m.items.filter((i) => i.unreadReplies > 0).length;
if (fresher > 0) setSaid(t("有 {n} 份反馈收到了新回复。", { n: fresher }));
})
.catch((e) => setFailure(feedbackFailure(e)))
.finally(() => setLoading(false));
}, [port]);
}, [port, markSeen, tell]);
useEffect(load, [load]);
@@ -263,7 +273,7 @@ export function FeedbackMine({ port, onFile, onUnread }: Props) {
))}
</ol>}
<FeedbackReviewNote item={item} />
<FeedbackThread item={item} fresh={fresh[item.receipt] ?? 0} onShowAll={unfolded} />
<FeedbackThread item={item} fresh={fresh[item.receipt] ?? 0} onView={view} />
<FeedbackReplyBox port={port} item={item} limit={replyBytes} offline={mine.offline} onSent={sent} onFile={onFile} onStale={load} />
</li>
))}
@@ -38,6 +38,7 @@ describe("the thread under a report", () => {
const port = portWith(() => mine([item({ status: "answered", replies: [reply(1, "maintainer", hostile), reply(2, "user", "thanks\nsecond line")] })]));
render(<FeedbackMine port={port} onFile={() => {}} />);
await screen.findByText("FB-AAAA-0001");
await userEvent.click(screen.getByRole("button", { name: /展开对话/ }));
const msgs = [...row("FB-AAAA-0001").querySelectorAll(".fbk-thread li")];
expect(msgs.map((li) => li.getAttribute("data-author"))).toEqual(["maintainer", "user"]);
expect(msgs[0]!.querySelector(".fbk-msg")!.textContent).toBe(hostile);
@@ -55,6 +56,7 @@ describe("the thread under a report", () => {
await screen.findByText("FB-AAAA-0001");
expect(screen.queryByText("message 3")).toBeNull();
expect(screen.getByText("message 4")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: /展开对话/ }));
await userEvent.click(screen.getByRole("button", { name: "显示更早的 3 条" }));
expect(screen.getByText("message 1")).toBeTruthy();
expect(screen.queryByRole("button", { name: /显示更早/ })).toBeNull();
@@ -239,7 +241,7 @@ describe("who may reply", () => {
});
describe("reading the replies", () => {
it("marks each report with new replies seen, keeps its New mark for this visit, and reports what still waits", async () => {
it("marks an opened report seen, keeps its New mark for this visit, and reports what still waits", async () => {
const items = [
item({ receipt: "FB-AAAA-0001", status: "answered", unreadReplies: 1, replies: [reply(1, "maintainer", "old"), reply(2, "user", "ok"), reply(3, "maintainer", "fresh")] }),
item({ receipt: "FB-AAAA-0002", status: "needs_info", needsInput: true, replies: [reply(4, "maintainer", "q")] }),
@@ -249,6 +251,8 @@ describe("reading the replies", () => {
const onUnread = vi.fn();
render(<FeedbackMine port={port} onFile={() => {}} onUnread={onUnread} />);
await screen.findByText("FB-AAAA-0001");
expect(port.feedbackSeen).not.toHaveBeenCalled();
await userEvent.click(within(row("FB-AAAA-0001")).getByRole("button", { name: /展开对话/ }));
await waitFor(() => expect(onUnread).toHaveBeenCalledWith(1));
expect(port.feedbackSeen).toHaveBeenCalledTimes(1);
expect(port.feedbackSeen).toHaveBeenCalledWith("FB-AAAA-0001", 3);
@@ -265,6 +269,8 @@ describe("reading the replies", () => {
});
const onUnread = vi.fn();
render(<FeedbackMine port={port} onFile={() => {}} onUnread={onUnread} />);
await userEvent.click(await screen.findByRole("button", { name: /展开对话/ }));
await waitFor(() => expect(port.feedbackSeen).toHaveBeenCalledWith("FB-AAAA-0001", 1));
await waitFor(() => expect(onUnread).toHaveBeenCalledWith(1));
});
@@ -301,6 +307,7 @@ describe("review fixes", () => {
port.feedbackSeen = vi.fn(async () => { throw new Error("disk"); });
const onUnread = vi.fn();
render(<FeedbackMine port={port} onFile={() => {}} onUnread={onUnread} />);
await waitFor(() => expect(port.feedbackSeen).toHaveBeenCalledWith("FB-AAAA-0001", 1));
await waitFor(() => expect(onUnread).toHaveBeenCalledWith(1));
expect(onUnread).not.toHaveBeenCalledWith(2);
});
@@ -339,6 +346,8 @@ describe("review fixes", () => {
await screen.findByText("FB-AAAA-0001");
await waitFor(() => expect(onUnread).toHaveBeenCalledWith(1));
expect(port.feedbackSeen).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: /展开对话/ }));
expect(port.feedbackSeen).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: /显示更早/ }));
await waitFor(() => expect(port.feedbackSeen).toHaveBeenCalledWith("FB-AAAA-0001", 6));
await waitFor(() => expect(onUnread).toHaveBeenLastCalledWith(0));
+35 -20
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { current, t } from "../i18n";
import { FEEDBACK_CODE, FEEDBACK_REPO_ISSUES, FEEDBACK_REPLYABLE, isUnderReview, type FeedbackItem, type FeedbackReply } from "../port/feedback";
import type { AgentPort } from "../port/port";
@@ -27,13 +27,16 @@ function when(iso: string): string {
interface ThreadProps {
item: FeedbackItem;
fresh: number;
onShowAll?: (receipt: string) => void;
onView?: (receipt: string, all: boolean | null) => void;
}
// Replies are plain text by contract: they render as text nodes, never linked,
// formatted or interpreted, whoever wrote them.
export function FeedbackThread({ item, fresh, onShowAll }: ThreadProps) {
export function FeedbackThread({ item, fresh, onView }: ThreadProps) {
const [all, setAll] = useState(false);
const [expanded, setExpanded] = useState(item.needsInput);
const id = useId();
useEffect(() => { if (item.needsInput) onView?.(item.receipt, false); }, []); // eslint-disable-line react-hooks/exhaustive-deps
const replies = item.replies;
const hidden = all ? 0 : Math.max(0, replies.length - SHOWN);
const shown = replies.slice(hidden);
@@ -42,23 +45,35 @@ export function FeedbackThread({ item, fresh, onShowAll }: ThreadProps) {
if (replies.length === 0) return null;
return (
<section className="fbk-thread" aria-label={t("对话")}>
{hidden > 0 && (
<button type="button" className="btn sm" data-action="feedback.thread.more" onClick={() => { setAll(true); onShowAll?.(item.receipt); }}>
{t("显示更早的 {n} 条", { n: hidden })}
</button>
)}
<ol>
{shown.map((r) => (
<li key={r.id} data-author={r.author} data-new={isNew(r) ? "" : undefined}>
<div className="fbk-msg-hd">
<b>{t(AUTHOR_LABEL[r.author])}</b>
{isNew(r) && <span className="fbk-new">{t("新")}</span>}
<time dateTime={r.createdAt}>{when(r.createdAt)}</time>
</div>
<p className="fbk-msg" dir="auto">{r.body}</p>
</li>
))}
</ol>
<button type="button" className="btn sm fbk-thread-toggle" data-action="feedback.thread.toggle" data-target={item.receipt} aria-expanded={expanded} aria-controls={id} onClick={() => {
const next = !expanded;
setExpanded(next);
onView?.(item.receipt, next ? all : null);
}}>
<StudioIcon name={expanded ? "down" : "chevron"} />
<span>{t(expanded ? "收起对话" : "展开对话")}</span>
<span className="fbk-meta">{t("{n} 条回复", { n: replies.length })}</span>
{fresh > 0 && <span className="fbk-thread-new">{t("{n} 条新回复", { n: fresh })}</span>}
</button>
<div id={id} className="fbk-thread-body" hidden={!expanded}>
{hidden > 0 && (
<button type="button" className="btn sm" data-action="feedback.thread.more" onClick={() => { setAll(true); onView?.(item.receipt, true); }}>
{t("显示更早的 {n} 条", { n: hidden })}
</button>
)}
<ol>
{shown.map((r) => (
<li key={r.id} data-author={r.author} data-new={isNew(r) ? "" : undefined}>
<div className="fbk-msg-hd">
<b>{t(AUTHOR_LABEL[r.author])}</b>
{isNew(r) && <span className="fbk-new">{t("新")}</span>}
<time dateTime={r.createdAt}>{when(r.createdAt)}</time>
</div>
<p className="fbk-msg" dir="auto">{r.body}</p>
</li>
))}
</ol>
</div>
</section>
);
}
Loaded 100 of 398 files, more files were not shown because too many files have changed in this diff. Show more