mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
668cdee703 | ||
|
|
2daa151e1c | ||
|
|
ca11dd05a0 | ||
|
|
ee94ecb67e | ||
|
|
b792fa4af5 | ||
|
|
5ec563372c | ||
|
|
877e1efc0f | ||
|
|
4c90c3aac0 | ||
|
|
c2ad5d4fc1 | ||
|
|
f51ed04b28 | ||
|
|
dbac04872f | ||
|
|
dcf2581882 | ||
|
|
78c1bf2b62 | ||
|
|
b414e352ab | ||
|
|
f1b613874e | ||
|
|
1cf750d67c | ||
|
|
341221c82e | ||
|
|
4b22777353 | ||
|
|
57c1ae8924 | ||
|
|
07373b0b77 | ||
|
|
3ec2f0161d | ||
|
|
4e87c51498 | ||
|
|
4a86a8923e | ||
|
|
347013f009 | ||
|
|
44c275fc6d | ||
|
|
e26f5599e7 | ||
|
|
c83f19950c | ||
|
|
f663bc2afb | ||
|
|
314e899f76 | ||
|
|
77447b565d | ||
|
|
71cf079f65 | ||
|
|
47443edf38 | ||
|
|
b9897edc59 | ||
|
|
c9fd02da4c | ||
|
|
c76d201cd0 | ||
|
|
7c4f5f2f61 | ||
|
|
536c17f986 | ||
|
|
a7855653d7 | ||
|
|
ba3546a6bf | ||
|
|
43a4347fec | ||
|
|
85aa462601 | ||
|
|
164ab70cbe | ||
|
|
e80c610e43 | ||
|
|
43009dcded | ||
|
|
671b6b740c | ||
|
|
2cde3b8d4e | ||
|
|
fb55ccd8b1 | ||
|
|
107bbbf0ac | ||
|
|
9bccbd1a41 | ||
|
|
58d7bf264d | ||
|
|
aee627a2d5 | ||
|
|
bebd89b800 | ||
|
|
540b4d5ad5 | ||
|
|
1b9bcf6df3 | ||
|
|
a8c41e2394 | ||
|
|
c19905f581 | ||
|
|
02708f7d85 | ||
|
|
154445dd8e | ||
|
|
4d44c734b9 | ||
|
|
ec4b3f61e8 | ||
|
|
1b498e9ef0 | ||
|
|
1d28cab879 | ||
|
|
413dbe9452 | ||
|
|
cd93913cbe | ||
|
|
6b95ef2978 | ||
|
|
bf5b53b5b2 | ||
|
|
546f4ee2cf | ||
|
|
e90b63bbd2 | ||
|
|
85cdf99128 | ||
|
|
d619d3fc75 | ||
|
|
deca64bcb1 | ||
|
|
b7c0508dc0 | ||
|
|
942ce85f02 | ||
|
|
0c78c31c83 | ||
|
|
cf01a93409 | ||
|
|
eeba3fe83e | ||
|
|
9c94db3e67 | ||
|
|
3bc93c07a3 | ||
|
|
9b90e4bdaa | ||
|
|
5b259ec487 | ||
|
|
323a25a5b0 | ||
|
|
9dc1d95b26 | ||
|
|
6aecbe4b89 | ||
|
|
0a2840b292 | ||
|
|
970647c5d4 | ||
|
|
d17d7aca23 | ||
|
|
537f2ed6f1 | ||
|
|
c7b4267529 | ||
|
|
876c080f7a | ||
|
|
1355d81122 | ||
|
|
0ed88af232 | ||
|
|
b5e008fddd | ||
|
|
80334d5838 | ||
|
|
e75eab9317 | ||
|
|
a986e31378 | ||
|
|
7de4a9c575 | ||
|
|
fba72a4169 | ||
|
|
3e428233e0 | ||
|
|
09c1e1f4c3 | ||
|
|
6640e96a8a | ||
|
|
a9cfb6bbe5 | ||
|
|
dbe2a3b241 | ||
|
|
06924f84b7 | ||
|
|
d9cb23997f | ||
|
|
41f67cec07 | ||
|
|
c6fe0f2771 | ||
|
|
a14dd3bf1c | ||
|
|
e67af2fa94 | ||
|
|
58e9c1cc57 | ||
|
|
9a748d67c9 | ||
|
|
af28696235 | ||
|
|
f392748041 | ||
|
|
fb8a28032b | ||
|
|
287c958d4a | ||
|
|
ffb441676a | ||
|
|
663961546f | ||
|
|
829252c184 | ||
|
|
6bbf077316 | ||
|
|
b7f36a2b53 | ||
|
|
d81eaffbd7 | ||
|
|
a741bf0a57 | ||
|
|
1c3daf3940 | ||
|
|
a4a21fcd72 | ||
|
|
4553294004 | ||
|
|
37e364152c | ||
|
|
9badd6969a | ||
|
|
0f6e675d86 | ||
|
|
aa3f0002d8 | ||
|
|
d633652aa0 | ||
|
|
b31ce49535 | ||
|
|
88218968a3 | ||
|
|
0d5100ff3a | ||
|
|
7a499d3c04 | ||
|
|
e966ce72ed | ||
|
|
771cbfe7cc | ||
|
|
7484917322 | ||
|
|
4b34d507fc | ||
|
|
b9ef378def | ||
|
|
0cdbbbb0ec | ||
|
|
1242bd5268 | ||
|
|
1559419186 | ||
|
|
17a2db6264 | ||
|
|
c94e51138d | ||
|
|
84fd077bfd | ||
|
|
ea5e3ffc63 | ||
|
|
3e4a8e279c | ||
|
|
7d4126f20a | ||
|
|
51e88c2bdc | ||
|
|
0d80a69dc4 | ||
|
|
a84641421c | ||
|
|
3d8f6fb150 | ||
|
|
6d13ae9408 | ||
|
|
56f72ef802 | ||
|
|
0d45cbf9f8 | ||
|
|
89c983a9bc | ||
|
|
ba15a60e38 | ||
|
|
68d695917a | ||
|
|
f47beddae4 | ||
|
|
6d1f42a55b | ||
|
|
8ca26f1050 | ||
|
|
983d785e1b | ||
|
|
cc99f059c6 | ||
|
|
8d42eb9ec2 | ||
|
|
3819aaa7b8 | ||
|
|
d4a3cdc584 | ||
|
|
d964364804 | ||
|
|
56eba1327a | ||
|
|
d1d0d08495 | ||
|
|
9ef2e019ed | ||
|
|
4fb5d19169 | ||
|
|
56da9cf573 | ||
|
|
772e065869 | ||
|
|
69e6ec3654 | ||
|
|
31293d1f97 | ||
|
|
3514beec85 | ||
|
|
3917c8817c | ||
|
|
bfa6abd159 | ||
|
|
9858d733ae | ||
|
|
5c952f3d33 | ||
|
|
514e39f2e5 | ||
|
|
34b7a49dd9 | ||
|
|
e1490d6f88 | ||
|
|
63b52be9ea | ||
|
|
10a46ff37c | ||
|
|
9bd3518ca9 | ||
|
|
49acdab882 | ||
|
|
961c78f473 | ||
|
|
7f2e20353c | ||
|
|
c7ebc746d9 | ||
|
|
3f70dca6ee | ||
|
|
36b53d2638 | ||
|
|
aa7ad6153c | ||
|
|
f222cb584a | ||
|
|
93442715d7 | ||
|
|
f0c301b7d3 | ||
|
|
c1c32cfac4 | ||
|
|
47482db32b | ||
|
|
9d00b31ab0 | ||
|
|
6b6a3f0f4b | ||
|
|
2fc5edf33d | ||
|
|
b1c821eead | ||
|
|
20eb9caadf | ||
|
|
cbefa8e52d | ||
|
|
0b8a4e56d3 | ||
|
|
5386a938bd | ||
|
|
d9e40b3619 | ||
|
|
d6ee5cd12b | ||
|
|
c6e26f43f1 | ||
|
|
99a4e1b7ac | ||
|
|
7c2cc5f2f0 | ||
|
|
af5cb03087 | ||
|
|
8526803d9b | ||
|
|
1fca13f3cb | ||
|
|
6f15585a72 | ||
|
|
bf4b6d8a43 | ||
|
|
e7905f8eef | ||
|
|
f6a92cd294 | ||
|
|
c63259709d | ||
|
|
a78c06df45 | ||
|
|
725c1b0581 | ||
|
|
78f97e8c62 | ||
|
|
1e3ba38550 | ||
|
|
bbb55c5318 | ||
|
|
825caa2a95 | ||
|
|
c96a2ecc3f | ||
|
|
7e16d2781b | ||
|
|
28266bce75 | ||
|
|
9904064811 | ||
|
|
d1d277d350 | ||
|
|
47266ee2e3 | ||
|
|
26026f6d30 | ||
|
|
c9b0cc6159 | ||
|
|
639c3be8dc | ||
|
|
0b3e00c538 | ||
|
|
a1a0684f94 |
No files matched your search
@@ -7,7 +7,7 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing a bug. Pick which version line you're on below — it gets
|
||||
labeled (`v1`/`v2`) automatically, no maintainer action needed.
|
||||
labeled (`v2`/`v3`) automatically, no maintainer action needed.
|
||||
- type: dropdown
|
||||
id: version-line
|
||||
attributes:
|
||||
@@ -15,7 +15,7 @@ body:
|
||||
description: Which line are you running?
|
||||
options:
|
||||
- "v2 — Go rewrite (1.x), main-v2 (active development)"
|
||||
- "v1 — Legacy TypeScript (0.x), maintenance only"
|
||||
- "v3 — Studio (2.x), studio"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
||||
@@ -10,7 +10,7 @@ body:
|
||||
description: Which line is this for?
|
||||
options:
|
||||
- "v2 — Go rewrite (1.x), main-v2 (active development)"
|
||||
- "v1 — Legacy TypeScript (0.x), maintenance only"
|
||||
- "v3 — Studio (2.x), studio"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
@@ -389,8 +389,10 @@ jobs:
|
||||
pnpm --dir frontend test:terminal
|
||||
pnpm --dir frontend test:task-monitor
|
||||
pnpm --dir frontend test:workspace
|
||||
pnpm --dir frontend test:stream
|
||||
pnpm --dir frontend test:motion
|
||||
pnpm --dir frontend test:motion-browser
|
||||
pnpm --dir frontend test:composer
|
||||
pnpm --dir frontend test:transcript
|
||||
pnpm --dir frontend test:transcript-browser
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Label issue version
|
||||
|
||||
# Issue forms can't let a reporter set a label directly (that needs triage rights),
|
||||
# so the bug/feature forms carry a "Version line" dropdown and this workflow reads
|
||||
# the submitted value and applies the matching v1/v2 label on their behalf.
|
||||
# the submitted value and applies the matching v2/v3 label on their behalf.
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
@@ -25,7 +25,8 @@ jobs:
|
||||
const sectionOf = (name) =>
|
||||
body.split(/^###\s+/m).find((s) => new RegExp(`^${name}`, 'i').test(s)) || '';
|
||||
|
||||
const m = sectionOf('Version line').match(/\bv([12])\b/i);
|
||||
const lines = ['v1', 'v2', 'v3'];
|
||||
const m = sectionOf('Version line').match(/\bv([23])\b/i);
|
||||
if (!m) {
|
||||
core.info('No version line found; nothing to label.');
|
||||
return;
|
||||
@@ -33,31 +34,35 @@ jobs:
|
||||
let choice = 'v' + m[1];
|
||||
|
||||
// The dropdown is a choice, and v2 sits first, so it gets picked by
|
||||
// reporters who are actually on 0.x. `Exact version` is pasted from
|
||||
// `reasonix --version`, so when it parses it is the better evidence:
|
||||
// 0.x is the TypeScript line, 1.x the Go rewrite. Anything that does
|
||||
// not parse (a commit sha, "main-v2") leaves the dropdown to decide.
|
||||
// reporters who are actually on the other line. `Exact version` is
|
||||
// pasted from `reasonix --version`, so when it parses it is the
|
||||
// better evidence: 1.x Go rewrite (v2), 2.x Studio (v3, studio
|
||||
// branch). 0.x is no longer a filing line. Anything that does not
|
||||
// parse (a commit sha, "main-v2", "studio") leaves the dropdown
|
||||
// to decide.
|
||||
const exact = sectionOf('Exact version').match(/\b(\d+)\.(\d+)\.(\d+)/);
|
||||
if (exact) {
|
||||
const fromVersion = Number(exact[1]) === 0 ? 'v1' : 'v2';
|
||||
if (fromVersion !== choice) {
|
||||
const major = Number(exact[1]);
|
||||
const fromVersion = major === 1 ? 'v2' : major === 2 ? 'v3' : null;
|
||||
if (fromVersion && fromVersion !== choice) {
|
||||
core.info(`Version line says ${choice} but ${exact[0]} says ${fromVersion}; trusting the version.`);
|
||||
choice = fromVersion;
|
||||
}
|
||||
}
|
||||
const other = choice === 'v2' ? 'v1' : 'v2';
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: [choice],
|
||||
});
|
||||
// Keep v1/v2 mutually exclusive in case an edit flipped the choice.
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: other,
|
||||
});
|
||||
} catch (e) {
|
||||
core.info(`No ${other} label to remove.`);
|
||||
// Keep version-line labels mutually exclusive if an edit flipped the choice.
|
||||
for (const other of lines.filter((l) => l !== choice)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: other,
|
||||
});
|
||||
} catch (e) {
|
||||
core.info(`No ${other} label to remove.`);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ name: Label PR version
|
||||
|
||||
# Contributors (especially from forks) can't set labels themselves — that needs
|
||||
# triage rights. A PR's version line is fully determined by its base branch, so
|
||||
# this reads base.ref and applies v1/v2 on their behalf. pull_request_target runs
|
||||
# this reads base.ref and applies v1/v2/v3 on their behalf. pull_request_target runs
|
||||
# in the base repo's context so it has write access even for fork PRs; it only
|
||||
# reads trusted metadata (the base ref) and never checks out or runs PR code.
|
||||
on:
|
||||
@@ -23,27 +23,28 @@ jobs:
|
||||
- uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const lineByBase = { v1: 'v1', 'main-v2': 'v2', studio: 'v3' };
|
||||
const lines = Object.values(lineByBase);
|
||||
const base = context.payload.pull_request.base.ref;
|
||||
const label = base === 'v1' ? 'v1'
|
||||
: base === 'main-v2' ? 'v2'
|
||||
: null;
|
||||
const label = lineByBase[base] || null;
|
||||
if (!label) {
|
||||
core.info(`Base branch '${base}' isn't a release line; skipping.`);
|
||||
return;
|
||||
}
|
||||
const other = label === 'v2' ? 'v1' : 'v2';
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: [label],
|
||||
});
|
||||
// Drop the other line's label if the base was changed after opening.
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
...context.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
name: other,
|
||||
});
|
||||
} catch (e) {
|
||||
core.info(`No ${other} label to remove.`);
|
||||
// Drop other line labels if the base was changed after opening.
|
||||
for (const other of lines.filter((l) => l !== label)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
...context.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
name: other,
|
||||
});
|
||||
} catch (e) {
|
||||
core.info(`No ${other} label to remove.`);
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -6,8 +6,13 @@ The transcript (`frontend/src/components/Transcript.tsx`, react-virtuoso) has
|
||||
one structural rule set, earned across #8657/#8688 and the follow-up refactors.
|
||||
Keep to it when touching anything that can move the transcript viewport.
|
||||
|
||||
- **Single writer**: only `frontend/src/lib/useTranscriptScrollArbiter.ts` may
|
||||
call `virtuosoRef.current.scrollTo/scrollBy/scrollToIndex`. Everything else
|
||||
- **Single writer**: only the scroll arbiter — `frontend/src/lib/useTranscriptScrollArbiter.ts`
|
||||
and its extracted controllers (`transcriptTailSettle.ts`,
|
||||
`transcriptAnchorCompensation.ts`) — may call
|
||||
`virtuosoRef.current.scrollTo/scrollBy/scrollToIndex`, and raw
|
||||
`scroller.scrollTop` assignments on transcript surfaces are equally
|
||||
off-limits (route through the arbiter's `SCROLL_TO_OFFSET` channel, e.g.
|
||||
owner `"anchor-compensation"` / `"block-window-prepend"`). Everything else
|
||||
(jumps, tail-follow, selection edge scrolls, layout recovery) submits
|
||||
requests to the arbiter. `frontend/scripts/check-single-scroll-writer.mjs`
|
||||
enforces it statically; `lib/transcriptScrollProbe.ts` observes it at
|
||||
|
||||
+19
-2
@@ -2982,6 +2982,8 @@ func channelDisplayName(provider, domain string) string {
|
||||
return "WeChat"
|
||||
case "qq":
|
||||
return "QQ"
|
||||
case "dingtalk":
|
||||
return "DingTalk"
|
||||
default:
|
||||
return provider
|
||||
}
|
||||
@@ -3640,12 +3642,27 @@ func (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error)
|
||||
return a.HistoryForTab(tab.ID), nil
|
||||
}
|
||||
|
||||
// validateChannelSessionPath 校验 bot/channel 会话路径:channel 会话可能位于
|
||||
// 当前 controller 的 session dir(project scope)或全局 session dir
|
||||
// (global scope),单 tab 无法同时覆盖两者,因此都放行。
|
||||
func validateChannelSessionPath(ctrlDir, path string) (string, string, error) {
|
||||
if p, b, err := validateSessionPath(ctrlDir, path); err == nil {
|
||||
return p, b, nil
|
||||
}
|
||||
if globalDir := config.SessionDir(); globalDir != "" && globalDir != ctrlDir {
|
||||
if p, b, err := validateSessionPath(globalDir, path); err == nil {
|
||||
return p, b, nil
|
||||
}
|
||||
}
|
||||
return validateSessionPath(ctrlDir, path)
|
||||
}
|
||||
|
||||
func (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {
|
||||
tab, ctrl := a.tabAndCtrlByID(tabID)
|
||||
if tab == nil || ctrl == nil {
|
||||
return []HistoryMessage{}, fmt.Errorf("tab is not ready")
|
||||
}
|
||||
sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
|
||||
sessionPath, _, err := validateChannelSessionPath(controllerSessionDir(ctrl), path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -3667,7 +3684,7 @@ func (a *App) OpenChannelSessionPageForTab(tabID, path string, limit int) (Histo
|
||||
if tab == nil || ctrl == nil {
|
||||
return HistoryPage{}, fmt.Errorf("tab is not ready")
|
||||
}
|
||||
sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
|
||||
sessionPath, _, err := validateChannelSessionPath(controllerSessionDir(ctrl), path)
|
||||
if err != nil {
|
||||
return HistoryPage{}, err
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ type WorkspaceConflictView struct {
|
||||
State string `json:"state"`
|
||||
OwnerTabID string `json:"ownerTabId,omitempty"`
|
||||
OwnerTitle string `json:"ownerTitle,omitempty"`
|
||||
OwnerScope string `json:"ownerScope,omitempty"`
|
||||
OwnerLabel string `json:"ownerLabel,omitempty"`
|
||||
OwnerWork ActiveWorkView `json:"ownerWork"`
|
||||
CanReveal bool `json:"canReveal"`
|
||||
CanCreateWorktree bool `json:"canCreateWorktree"`
|
||||
@@ -201,6 +203,7 @@ func (a *App) RevealBackgroundRuntime(tabID string) (TabMeta, error) {
|
||||
|
||||
type workspaceLeaseReporter interface {
|
||||
WorkspaceLeaseState() workspacelease.State
|
||||
WorkspaceLeaseHeldKeys() []string
|
||||
}
|
||||
|
||||
func controllerWorkspaceLeaseState(ctrl control.SessionAPI) workspacelease.State {
|
||||
@@ -210,6 +213,10 @@ func controllerWorkspaceLeaseState(ctrl control.SessionAPI) workspacelease.State
|
||||
return workspacelease.State{}
|
||||
}
|
||||
|
||||
func leaseDomainsOverlap(waitingRoot string, waiting workspacelease.State, holderRoot string, holder workspacelease.State) bool {
|
||||
return workspacelease.LeaseStatesOverlap(waitingRoot, waiting, holderRoot, holder)
|
||||
}
|
||||
|
||||
// WorkspaceConflictForTab classifies the owner that a Delivery writer is
|
||||
// currently waiting for. An acquired process-local owner is actionable; when no
|
||||
// local owner matches, the OS lock is treated as external.
|
||||
@@ -233,7 +240,7 @@ func (a *App) WorkspaceConflictForTab(tabID string) WorkspaceConflictView {
|
||||
return empty
|
||||
}
|
||||
targetState := controllerWorkspaceLeaseState(targetCtrl)
|
||||
if !targetState.Waiting || targetState.Acquired {
|
||||
if !targetState.Waiting {
|
||||
return empty
|
||||
}
|
||||
targetRoot, err := workspacelease.CanonicalWorkspace(targetWorkspaceRoot)
|
||||
@@ -266,11 +273,20 @@ func (a *App) WorkspaceConflictForTab(tabID string) WorkspaceConflictView {
|
||||
|
||||
for _, candidate := range candidates {
|
||||
root, err := workspacelease.CanonicalWorkspace(candidate.root)
|
||||
if err != nil || root != targetRoot || !controllerWorkspaceLeaseState(candidate.ctrl).Acquired {
|
||||
ownerState := controllerWorkspaceLeaseState(candidate.ctrl)
|
||||
if err != nil || !ownerState.Acquired {
|
||||
continue
|
||||
}
|
||||
if !leaseDomainsOverlap(targetRoot, targetState, root, ownerState) {
|
||||
continue
|
||||
}
|
||||
ownerScope, ownerLabel := ownerState.HeldScope, ownerState.HeldLabel
|
||||
if ownerScope == "" {
|
||||
ownerScope, ownerLabel = ownerState.Scope, ownerState.Label
|
||||
}
|
||||
return WorkspaceConflictView{
|
||||
State: "local", OwnerTabID: candidate.id, OwnerTitle: candidate.title,
|
||||
OwnerScope: ownerScope, OwnerLabel: ownerLabel,
|
||||
OwnerWork: activeWorkForController(candidate.ctrl), CanReveal: true,
|
||||
CanCreateWorktree: availability.Available,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/control"
|
||||
@@ -13,6 +15,7 @@ type backgroundRuntimeController struct {
|
||||
status control.RuntimeStatus
|
||||
jobs []jobs.View
|
||||
lease workspacelease.State
|
||||
leaseKeys []string
|
||||
cancelled []string
|
||||
turnCancel int
|
||||
}
|
||||
@@ -32,7 +35,12 @@ func (c *backgroundRuntimeController) CancelJob(id string) bool {
|
||||
}
|
||||
func (c *backgroundRuntimeController) Cancel() { c.turnCancel++ }
|
||||
func (c *backgroundRuntimeController) WorkspaceLeaseState() workspacelease.State {
|
||||
return c.lease
|
||||
state := c.lease
|
||||
state.HeldKeys = append([]string(nil), c.leaseKeys...)
|
||||
return state
|
||||
}
|
||||
func (c *backgroundRuntimeController) WorkspaceLeaseHeldKeys() []string {
|
||||
return append([]string(nil), c.leaseKeys...)
|
||||
}
|
||||
|
||||
func TestBackgroundRuntimeAPIsKeepDetachedJobsActionable(t *testing.T) {
|
||||
@@ -135,3 +143,82 @@ func TestWorkspaceConflictIdentifiesExactLocalWriterWithoutIdentity(t *testing.T
|
||||
t.Fatalf("owner work = %+v, want sanitized active work", conflict.OwnerWork)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceConflictMatchesTheWaitingFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
aPath := filepath.Join(root, "a.go")
|
||||
bPath := filepath.Join(root, "b.go")
|
||||
waiterCtrl := &backgroundRuntimeController{lease: workspacelease.State{
|
||||
Waiting: true, Scope: "file", Label: "a.go", WaitingKeys: []string{aPath},
|
||||
}}
|
||||
wrongCtrl := &backgroundRuntimeController{
|
||||
lease: workspacelease.State{Acquired: true, Scope: "file", Label: "b.go"}, leaseKeys: []string{bPath},
|
||||
}
|
||||
rightCtrl := &backgroundRuntimeController{
|
||||
lease: workspacelease.State{Acquired: true, Scope: "file", Label: "a.go"}, leaseKeys: []string{aPath},
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"waiter": {ID: "waiter", WorkspaceRoot: root, Ctrl: waiterCtrl},
|
||||
"wrong": {ID: "wrong", WorkspaceRoot: root, Ctrl: wrongCtrl},
|
||||
"right": {ID: "right", WorkspaceRoot: root, Ctrl: rightCtrl},
|
||||
},
|
||||
tabOrder: []string{"waiter", "wrong", "right"}, activeTabID: "waiter",
|
||||
}
|
||||
|
||||
conflict := app.WorkspaceConflictForTab("waiter")
|
||||
if conflict.State != "local" || conflict.OwnerTabID != "right" {
|
||||
t.Fatalf("WorkspaceConflictForTab = %+v, want right file owner", conflict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceConflictMatchesWaitingFileAcrossNestedWorkspaceRoots(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
nested := filepath.Join(parent, "nested")
|
||||
if err := os.MkdirAll(filepath.Join(nested, ".git"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(nested, "shared.go")
|
||||
waiterCtrl := &backgroundRuntimeController{lease: workspacelease.State{
|
||||
Waiting: true, Scope: "file", Label: "shared.go", WaitingKeys: []string{path},
|
||||
}}
|
||||
ownerCtrl := &backgroundRuntimeController{
|
||||
lease: workspacelease.State{Acquired: true, Scope: "file", Label: "shared.go"},
|
||||
leaseKeys: []string{path},
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"waiter": {ID: "waiter", WorkspaceRoot: parent, Ctrl: waiterCtrl},
|
||||
"owner": {ID: "owner", WorkspaceRoot: nested, Ctrl: ownerCtrl},
|
||||
},
|
||||
tabOrder: []string{"waiter", "owner"}, activeTabID: "waiter",
|
||||
}
|
||||
|
||||
conflict := app.WorkspaceConflictForTab("waiter")
|
||||
if conflict.State != "local" || conflict.OwnerTabID != "owner" {
|
||||
t.Fatalf("WorkspaceConflictForTab = %+v, want nested local owner", conflict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceConflictAllowsAcquiredOwnerToWaitForAnotherFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
aPath := filepath.Join(root, "a.go")
|
||||
waiterCtrl := &backgroundRuntimeController{lease: workspacelease.State{
|
||||
Acquired: true, Waiting: true, Scope: "file", Label: "a.go", WaitingKeys: []string{aPath},
|
||||
}}
|
||||
ownerCtrl := &backgroundRuntimeController{
|
||||
lease: workspacelease.State{Acquired: true, Scope: "file", Label: "a.go"}, leaseKeys: []string{aPath},
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"waiter": {ID: "waiter", WorkspaceRoot: root, Ctrl: waiterCtrl},
|
||||
"owner": {ID: "owner", WorkspaceRoot: root, Ctrl: ownerCtrl},
|
||||
},
|
||||
tabOrder: []string{"waiter", "owner"}, activeTabID: "waiter",
|
||||
}
|
||||
|
||||
conflict := app.WorkspaceConflictForTab("waiter")
|
||||
if conflict.State != "local" || conflict.OwnerTabID != "owner" {
|
||||
t.Fatalf("WorkspaceConflictForTab = %+v, want local owner", conflict)
|
||||
}
|
||||
}
|
||||
@@ -294,6 +294,63 @@ func (a *App) TestBotConnection(id, target string) (BotConnectionDiagnostic, err
|
||||
return diag, nil
|
||||
}
|
||||
|
||||
// TestDingtalkBot 向最近交互过的钉钉会话发送测试消息,验证凭据与发送链路。
|
||||
func (a *App) TestDingtalkBot() (BotConnectionDiagnostic, error) {
|
||||
cfg, err := a.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
return botConnectionDiagnostic(nil, "dingtalk", "error", "config", "config_load_failed", err.Error(), true), nil
|
||||
}
|
||||
conn, connID, connOK := dingtalkRuntimeConnection(cfg.Bot.Connections)
|
||||
clientID := strings.TrimSpace(cfg.Bot.Dingtalk.ClientID)
|
||||
if clientID == "" {
|
||||
clientID = os.Getenv(strings.TrimSpace(cfg.Bot.Dingtalk.ClientIDEnv))
|
||||
}
|
||||
secret := strings.TrimSpace(cfg.Bot.Dingtalk.ClientSecret)
|
||||
if secret == "" {
|
||||
secret = os.Getenv(strings.TrimSpace(cfg.Bot.Dingtalk.SecretEnv))
|
||||
}
|
||||
if connOK {
|
||||
if v := strings.TrimSpace(conn.Credential.AppID); v != "" {
|
||||
clientID = v
|
||||
}
|
||||
if v := strings.TrimSpace(conn.Credential.AppSecretEnv); v != "" {
|
||||
secret = os.Getenv(v)
|
||||
}
|
||||
}
|
||||
if clientID == "" || secret == "" {
|
||||
return botConnectionDiagnostic(nil, "dingtalk", "warning", "credential", "dingtalk_secret_missing", "钉钉凭据未配置完整(AppKey / AppSecret)。", false), nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
// domain 传空:匹配任意 domain 的钉钉 adapter(空 domain 视为匹配任意)。
|
||||
result, err := a.botRuntime.TestSendToAdapter(ctx, connID, "", "Reasonix bot 测试消息:钉钉配置与发送链路可用。")
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "requires a known chat") {
|
||||
return botConnectionDiagnostic(nil, "dingtalk", "warning", "send", "dingtalk_test_send_no_chat", "还没有可发送的钉钉会话:请先在钉钉中给机器人发一条消息,之后即可发送测试消息。", false), nil
|
||||
}
|
||||
return botConnectionDiagnostic(nil, "dingtalk", "error", "send", "dingtalk_test_send_failed", err.Error(), true), nil
|
||||
}
|
||||
diag := botConnectionDiagnostic(nil, "dingtalk", "ok", "send", "dingtalk_test_send_ok", "测试消息已发送,请检查钉钉会话。", false)
|
||||
diag.MessageID = result.MessageID
|
||||
return diag, nil
|
||||
}
|
||||
|
||||
// dingtalkRuntimeConnection 返回第一个启用的钉钉 connection 与运行时 id。
|
||||
func dingtalkRuntimeConnection(connections []config.BotConnectionConfig) (config.BotConnectionConfig, string, bool) {
|
||||
for _, conn := range connections {
|
||||
if !conn.Enabled || strings.TrimSpace(conn.Provider) != string(bot.PlatformDingtalk) {
|
||||
continue
|
||||
}
|
||||
return conn, botruntime.ConnectionRuntimeID(conn), true
|
||||
}
|
||||
return config.BotConnectionConfig{}, string(bot.PlatformDingtalk), false
|
||||
}
|
||||
|
||||
func dingtalkRuntimeConnectionID(connections []config.BotConnectionConfig) string {
|
||||
_, id, _ := dingtalkRuntimeConnection(connections)
|
||||
return id
|
||||
}
|
||||
|
||||
func botConnectionDiagnostic(conn *config.BotConnectionConfig, id, status, phase, code, message string, reportable bool) BotConnectionDiagnostic {
|
||||
id = strings.TrimSpace(id)
|
||||
label := ""
|
||||
|
||||
+85
-21
@@ -15,11 +15,12 @@ import (
|
||||
)
|
||||
|
||||
type BotRuntimeStatusView struct {
|
||||
Running bool `json:"running"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Connections int `json:"connections"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
Running bool `json:"running"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Connections int `json:"connections"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
Platforms map[string]string `json:"platforms,omitempty"`
|
||||
}
|
||||
|
||||
type desktopBotRuntime struct {
|
||||
@@ -62,6 +63,34 @@ func desktopBotChannelsWithLegacyQQ(qq config.QQBotConfig, channels map[bot.Plat
|
||||
return channels, connectionChannels
|
||||
}
|
||||
|
||||
// desktopBotChannelsWithLegacyDingtalk 把 legacy [bot.dingtalk] 的模型/权限/
|
||||
// 工作目录合成进 Channels 与 ConnectionChannels,使直配(无 connection)的
|
||||
// 钉钉 bot 也能从设置面板配置这些运行选项(与 legacy QQ 同路径)。
|
||||
func desktopBotChannelsWithLegacyDingtalk(dt config.DingtalkBotConfig, channels map[bot.Platform]bot.ChannelConfig, connectionChannels map[string]bot.ChannelConfig) (map[bot.Platform]bot.ChannelConfig, map[string]bot.ChannelConfig) {
|
||||
channel := bot.ChannelConfig{
|
||||
Model: strings.TrimSpace(dt.Model),
|
||||
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(dt.ToolApprovalMode),
|
||||
WorkspaceRoot: strings.TrimSpace(dt.WorkspaceRoot),
|
||||
SessionMappings: botruntime.SessionMappings(dt.SessionMappings),
|
||||
}
|
||||
if channel.Model == "" && channel.ToolApprovalMode == "" && channel.WorkspaceRoot == "" && len(channel.SessionMappings) == 0 {
|
||||
return channels, connectionChannels
|
||||
}
|
||||
if channels == nil {
|
||||
channels = make(map[bot.Platform]bot.ChannelConfig)
|
||||
}
|
||||
if _, ok := channels[bot.PlatformDingtalk]; !ok {
|
||||
channels[bot.PlatformDingtalk] = channel
|
||||
}
|
||||
if connectionChannels == nil {
|
||||
connectionChannels = make(map[string]bot.ChannelConfig)
|
||||
}
|
||||
if _, ok := connectionChannels[string(bot.PlatformDingtalk)]; !ok {
|
||||
connectionChannels[string(bot.PlatformDingtalk)] = channel
|
||||
}
|
||||
return channels, connectionChannels
|
||||
}
|
||||
|
||||
func (a *App) refreshBotRuntimeAsync() {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
@@ -144,6 +173,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
channels := botruntime.ChannelConfigs(cfg.Bot.Connections, true, true)
|
||||
connectionChannels := botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true)
|
||||
channels, connectionChannels = desktopBotChannelsWithLegacyQQ(cfg.Bot.QQ, channels, connectionChannels)
|
||||
channels, connectionChannels = desktopBotChannelsWithLegacyDingtalk(cfg.Bot.Dingtalk, channels, connectionChannels)
|
||||
gwCfg := bot.GatewayConfig{
|
||||
Model: modelName,
|
||||
ToolApprovalMode: cfg.Bot.ToolApprovalMode,
|
||||
@@ -156,9 +186,10 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
PairingMaxPending: cfg.Bot.Pairing.MaxPendingPerPlatform,
|
||||
IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages,
|
||||
SelfUserIDs: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ,
|
||||
bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
|
||||
bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
|
||||
bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ,
|
||||
bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
|
||||
bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
|
||||
bot.PlatformDingtalk: cfg.Bot.SelfUserIDs.Dingtalk,
|
||||
},
|
||||
ControlEnabled: cfg.Bot.Control.Enabled,
|
||||
ControlAddr: cfg.Bot.Control.Addr,
|
||||
@@ -173,27 +204,32 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
Enabled: cfg.Bot.Allowlist.Enabled,
|
||||
AllowAll: cfg.Bot.Allowlist.AllowAll,
|
||||
Users: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQUsers,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQUsers,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
|
||||
bot.PlatformDingtalk: cfg.Bot.Allowlist.DingtalkUsers,
|
||||
},
|
||||
Approvers: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQApprovers,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQApprovers,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
|
||||
bot.PlatformDingtalk: cfg.Bot.Allowlist.DingtalkApprovers,
|
||||
},
|
||||
Admins: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQAdmins,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQAdmins,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
|
||||
bot.PlatformDingtalk: cfg.Bot.Allowlist.DingtalkAdmins,
|
||||
},
|
||||
Groups: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQGroups,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinGroups,
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQGroups,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinGroups,
|
||||
bot.PlatformDingtalk: cfg.Bot.Allowlist.DingtalkGroups,
|
||||
},
|
||||
},
|
||||
Debounce: time.Duration(cfg.Bot.DebounceMs) * time.Millisecond,
|
||||
ModelResolver: botruntime.ModelResolver(cfg),
|
||||
OnInbound: botruntime.NewRemoteRememberer(logger),
|
||||
OnSessionReady: botruntime.NewSessionRemembererWithWorkspace(logger, workspaceRoot),
|
||||
OnToolApprovalModeChange: onToolApprovalModeChange,
|
||||
@@ -335,7 +371,23 @@ func (r *desktopBotRuntime) setStatus(status BotRuntimeStatusView) {
|
||||
func (r *desktopBotRuntime) snapshot() BotRuntimeStatusView {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.status
|
||||
s := r.status
|
||||
if r.gw != nil {
|
||||
s.Platforms = botAdapterPlatformStatuses(r.gw.AdapterHealth())
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// botAdapterPlatformStatuses 把 gateway 的适配器健康快照收敛为
|
||||
// platform → status 映射(如 dingtalk → running),供设置面板显示在线状态。
|
||||
func botAdapterPlatformStatuses(health []bot.AdapterHealthSnapshot) map[string]string {
|
||||
out := make(map[string]string, len(health))
|
||||
for _, h := range health {
|
||||
if strings.TrimSpace(string(h.Platform)) != "" {
|
||||
out[string(h.Platform)] = h.Status
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// updateConnectionToolApprovalMode updates a connection's tool approval mode
|
||||
@@ -367,6 +419,18 @@ func (r *desktopBotRuntime) SendToAdapter(ctx context.Context, connID, domain st
|
||||
return gw.SendToAdapter(ctx, connID, domain, msg)
|
||||
}
|
||||
|
||||
// TestSendToAdapter sends a test message through the running gateway's adapter
|
||||
// identified by connID. The adapter must implement bot.TestSender (dingtalk).
|
||||
func (r *desktopBotRuntime) TestSendToAdapter(ctx context.Context, connID, domain, text string) (bot.SendResult, error) {
|
||||
r.mu.Lock()
|
||||
gw := r.gw
|
||||
r.mu.Unlock()
|
||||
if gw == nil {
|
||||
return bot.SendResult{}, fmt.Errorf("bot runtime is not running")
|
||||
}
|
||||
return gw.TestSendToAdapter(ctx, connID, domain, text)
|
||||
}
|
||||
|
||||
// Running returns true if the bot gateway is currently active.
|
||||
func (r *desktopBotRuntime) Running() bool {
|
||||
r.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
// TestOpenChannelSessionGlobalBotAcrossDirs 回归:bot/channel 会话可能位于
|
||||
// 全局 session dir(global scope),而 tab 的 controller 位于项目 dir。
|
||||
// OpenChannelSession* 必须放行跨目录的 channel 会话,否则前端报
|
||||
// 「加载会话历史失败」(session path outside session dir)。
|
||||
func TestOpenChannelSessionGlobalBotAcrossDirs(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
globalDir := config.SessionDir()
|
||||
if err := os.MkdirAll(globalDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir global: %v", err)
|
||||
}
|
||||
botPath := filepath.Join(globalDir, "bot-abc123.jsonl")
|
||||
if err := os.WriteFile(botPath, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write bot session: %v", err)
|
||||
}
|
||||
|
||||
// tab 的 controller 位于"项目"session dir,与全局 bot 会话不同目录。
|
||||
projDir := filepath.Join(globalDir, "..", "project-sessions")
|
||||
if err := os.MkdirAll(projDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir proj: %v", err)
|
||||
}
|
||||
app := NewApp()
|
||||
ctrl := control.New(control.Options{SessionDir: projDir, SessionPath: filepath.Join(projDir, "active.jsonl"), Label: "test"})
|
||||
app.setTestCtrl(ctrl, "")
|
||||
defer app.activeCtrl().Close()
|
||||
|
||||
if _, err := app.OpenChannelSessionForTab("test", botPath); err != nil {
|
||||
t.Fatalf("open global bot session from project tab: %v", err)
|
||||
}
|
||||
if _, err := app.OpenChannelSessionPageForTab("test", botPath, 60); err != nil {
|
||||
t.Fatalf("open paged global bot session: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
// TestSetBotSettingsDingtalkRoundTrip 验证钉钉配置经 SetBotSettings 后能正确落盘并被读回。
|
||||
// 这是排查「钉钉配置界面几个字段写进去无法保存」的回归测试。
|
||||
func TestSetBotSettingsDingtalkRoundTrip(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Dingtalk = config.DingtalkBotConfig{
|
||||
Enabled: true,
|
||||
ClientID: "appkey-initial",
|
||||
SecretEnv: "DINGTALK_CLIENT_SECRET",
|
||||
BotName: "InitialBot",
|
||||
RequireMention: true,
|
||||
SessionMappings: []config.BotConnectionSessionMapping{{
|
||||
RemoteID: "cid-preserved",
|
||||
Scope: "global",
|
||||
SessionID: "path:/sessions/preserved.jsonl",
|
||||
SessionSource: "auto",
|
||||
}},
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save initial: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
view := botSettingsView(cfg.Bot)
|
||||
view.Dingtalk.ClientID = "appkey-edited"
|
||||
view.Dingtalk.BotName = "EditedBot"
|
||||
view.Dingtalk.RequireMention = false
|
||||
view.Dingtalk.ClientSecretEnv = "DINGTALK_CLIENT_SECRET"
|
||||
if err := app.SetBotSettings(view); err != nil {
|
||||
t.Fatalf("SetBotSettings: %v", err)
|
||||
}
|
||||
|
||||
got := config.LoadForEditWithoutCredentials(config.UserConfigPath())
|
||||
if got.Bot.Dingtalk.ClientID != "appkey-edited" {
|
||||
t.Fatalf("clientId = %q, want appkey-edited", got.Bot.Dingtalk.ClientID)
|
||||
}
|
||||
if got.Bot.Dingtalk.BotName != "EditedBot" {
|
||||
t.Fatalf("botName = %q, want EditedBot", got.Bot.Dingtalk.BotName)
|
||||
}
|
||||
if got.Bot.Dingtalk.RequireMention {
|
||||
t.Fatalf("requireMention = true, want false")
|
||||
}
|
||||
if got.Bot.Dingtalk.SecretEnv != "DINGTALK_CLIENT_SECRET" {
|
||||
t.Fatalf("secretEnv = %q, want DINGTALK_CLIENT_SECRET", got.Bot.Dingtalk.SecretEnv)
|
||||
}
|
||||
if mappings := got.Bot.Dingtalk.SessionMappings; len(mappings) != 1 || mappings[0].RemoteID != "cid-preserved" || mappings[0].SessionID != "path:/sessions/preserved.jsonl" {
|
||||
t.Fatalf("session mappings = %+v, want hidden legacy mapping preserved", mappings)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDesktopBotConfigConfiguredDetectsDingtalk 防止回归:用户只配置钉钉(其他平台
|
||||
// 均未配置)时,desktopBotConfigConfigured 必须返回 true,否则 legacy 合并路径会
|
||||
// 用 legacy config 整体覆盖 userCfg.Bot,导致钉钉 ClientID 等配置被丢弃
|
||||
// (表现为「输入 ClientID 后消失」)。
|
||||
func TestDesktopBotConfigConfiguredDetectsDingtalk(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Dingtalk.ClientID = "dingappkey123"
|
||||
if !desktopBotConfigConfigured(cfg.Bot) {
|
||||
t.Fatalf("dingtalk-only bot config must count as configured")
|
||||
}
|
||||
// 仅 SecretEnv 配了也应算已配置(环境变量形式的密钥)。
|
||||
cfg = config.Default()
|
||||
cfg.Bot.Dingtalk.SecretEnv = "DINGTALK_CLIENT_SECRET"
|
||||
if !desktopBotConfigConfigured(cfg.Bot) {
|
||||
t.Fatalf("dingtalk secret-env-only config must count as configured")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDingtalkRuntimeConnectionID 验证测试发送时解析到真实运行的 connection id
|
||||
// (自定义 id 如 dingtalk-test 必须命中),而不是硬编码退回 "dingtalk"。
|
||||
func TestDingtalkRuntimeConnectionID(t *testing.T) {
|
||||
// 自定义 id:优先命中启用的钉钉连接。
|
||||
got := dingtalkRuntimeConnectionID([]config.BotConnectionConfig{
|
||||
{ID: "feishu-feishu", Provider: "feishu", Enabled: true},
|
||||
{ID: "dingtalk-test", Provider: "dingtalk", Enabled: true},
|
||||
})
|
||||
if got != "dingtalk-test" {
|
||||
t.Fatalf("runtime connection id = %q, want dingtalk-test", got)
|
||||
}
|
||||
// 未启用:跳过。
|
||||
got = dingtalkRuntimeConnectionID([]config.BotConnectionConfig{
|
||||
{ID: "dingtalk-disabled", Provider: "dingtalk", Enabled: false},
|
||||
})
|
||||
if got != "dingtalk" {
|
||||
t.Fatalf("disabled connection must be skipped, got %q", got)
|
||||
}
|
||||
// 无 id 的钉钉连接:退回 provider 名。
|
||||
got = dingtalkRuntimeConnectionID([]config.BotConnectionConfig{
|
||||
{Provider: "dingtalk", Enabled: true},
|
||||
})
|
||||
if got != "dingtalk" {
|
||||
t.Fatalf("id-less connection should fall back to %q, got %q", "dingtalk", got)
|
||||
}
|
||||
// 无任何钉钉连接:退回 "dingtalk"(legacy [bot.dingtalk] 路径)。
|
||||
got = dingtalkRuntimeConnectionID(nil)
|
||||
if got != "dingtalk" {
|
||||
t.Fatalf("no connection should fall back to %q, got %q", "dingtalk", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDingtalkRuntimeConnectionResolvesCredentials 验证测试发送凭据从所选
|
||||
// connection 解析(而不是 legacy [bot.dingtalk] 块)——否则 connection 驱动的
|
||||
// 运行时在 legacy 块为空时误报 dingtalk_secret_missing。
|
||||
func TestDingtalkRuntimeConnectionResolvesCredentials(t *testing.T) {
|
||||
conns := []config.BotConnectionConfig{
|
||||
{ID: "feishu-feishu", Provider: "feishu", Enabled: true},
|
||||
{ID: "dingtalk-main", Provider: "dingtalk", Enabled: true,
|
||||
Credential: config.BotConnectionCredential{AppID: "app-from-conn", AppSecretEnv: "DINGTALK_CONN_SECRET"}},
|
||||
}
|
||||
conn, id, ok := dingtalkRuntimeConnection(conns)
|
||||
if !ok {
|
||||
t.Fatal("enabled dingtalk connection not found")
|
||||
}
|
||||
if id != "dingtalk-main" {
|
||||
t.Fatalf("runtime id = %q, want dingtalk-main", id)
|
||||
}
|
||||
if conn.Credential.AppID != "app-from-conn" || conn.Credential.AppSecretEnv != "DINGTALK_CONN_SECRET" {
|
||||
t.Fatalf("resolved connection credentials = %q/%q, want app-from-conn/DINGTALK_CONN_SECRET",
|
||||
conn.Credential.AppID, conn.Credential.AppSecretEnv)
|
||||
}
|
||||
// 未启用的钉钉连接不应命中。
|
||||
if _, _, ok := dingtalkRuntimeConnection([]config.BotConnectionConfig{
|
||||
{ID: "dingtalk-off", Provider: "dingtalk", Enabled: false},
|
||||
}); ok {
|
||||
t.Fatal("disabled dingtalk connection must not be selected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetBotSettingsDingtalkRuntimeOptionsRoundTrip 验证 legacy [bot.dingtalk]
|
||||
// 的模型/工具审批模式/工作目录经 SetBotSettings 落盘并被读回。这是排查
|
||||
// 「钉钉面板模型、权限切换无法保存」的回归测试:config 结构体与 settings
|
||||
// view 都支持这些字段,但 render.go 漏写导致 SaveTo 丢弃(已被修复)。
|
||||
func TestSetBotSettingsDingtalkRuntimeOptionsRoundTrip(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Dingtalk = config.DingtalkBotConfig{
|
||||
Enabled: true,
|
||||
ClientID: "ding-appkey",
|
||||
ClientSecret: "secret",
|
||||
SecretEnv: "DINGTALK_CLIENT_SECRET",
|
||||
BotName: "安博特",
|
||||
RequireMention: true,
|
||||
Model: "deepseek-flash/deepseek-v4-flash",
|
||||
ToolApprovalMode: "yolo",
|
||||
WorkspaceRoot: "/tmp/probe-ws",
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save initial: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
view := botSettingsView(cfg.Bot)
|
||||
if view.Dingtalk.Model != "deepseek-flash/deepseek-v4-flash" {
|
||||
t.Fatalf("view model = %q, want deepseek-flash", view.Dingtalk.Model)
|
||||
}
|
||||
if view.Dingtalk.ToolApprovalMode != "yolo" {
|
||||
t.Fatalf("view approval = %q, want yolo", view.Dingtalk.ToolApprovalMode)
|
||||
}
|
||||
view.Dingtalk.Model = "deepseek-flash/deepseek-v4-flash"
|
||||
view.Dingtalk.ToolApprovalMode = "yolo"
|
||||
view.Dingtalk.WorkspaceRoot = "/tmp/probe-ws"
|
||||
if err := app.SetBotSettings(view); err != nil {
|
||||
t.Fatalf("SetBotSettings: %v", err)
|
||||
}
|
||||
|
||||
got := config.LoadForEditWithoutCredentials(config.UserConfigPath())
|
||||
if got.Bot.Dingtalk.Model != "deepseek-flash/deepseek-v4-flash" {
|
||||
t.Fatalf("model = %q, want deepseek-flash (render must persist)", got.Bot.Dingtalk.Model)
|
||||
}
|
||||
if got.Bot.Dingtalk.ToolApprovalMode != "yolo" {
|
||||
t.Fatalf("toolApprovalMode = %q, want yolo (render must persist)", got.Bot.Dingtalk.ToolApprovalMode)
|
||||
}
|
||||
if got.Bot.Dingtalk.WorkspaceRoot != "/tmp/probe-ws" {
|
||||
t.Fatalf("workspaceRoot = %q, want /tmp/probe-ws (render must persist)", got.Bot.Dingtalk.WorkspaceRoot)
|
||||
}
|
||||
// 原有字段不能丢。
|
||||
if got.Bot.Dingtalk.ClientSecret != "secret" {
|
||||
t.Fatalf("clientSecret lost: %q", got.Bot.Dingtalk.ClientSecret)
|
||||
}
|
||||
if got.Bot.Dingtalk.BotName != "安博特" {
|
||||
t.Fatalf("botName lost: %q", got.Bot.Dingtalk.BotName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
func dingtalkConfigFromView(view DingtalkBotView, current config.DingtalkBotConfig) config.DingtalkBotConfig {
|
||||
return config.DingtalkBotConfig{
|
||||
Enabled: view.Enabled,
|
||||
ClientID: strings.TrimSpace(view.ClientID),
|
||||
ClientSecret: current.ClientSecret,
|
||||
ClientIDEnv: current.ClientIDEnv,
|
||||
SecretEnv: strings.TrimSpace(view.ClientSecretEnv),
|
||||
BotName: strings.TrimSpace(view.BotName),
|
||||
RequireMention: view.RequireMention,
|
||||
Model: strings.TrimSpace(view.Model),
|
||||
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode),
|
||||
WorkspaceRoot: strings.TrimSpace(view.WorkspaceRoot),
|
||||
Access: botAccessConfigFromView(view.Access),
|
||||
SessionMappings: append([]config.BotConnectionSessionMapping(nil), current.SessionMappings...),
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ func historyLocalOnlyRows(m provider.Message) ([]HistoryMessage, bool) {
|
||||
if recovery := m.FinalReadinessRecovery; recovery != nil && recovery.Pending {
|
||||
return []HistoryMessage{{
|
||||
Role: "notice", Code: event.NoticeCodeFinalReadiness, Level: "info", Pending: true,
|
||||
Content: "Task status needs one more check; continue the remaining work.",
|
||||
Content: "Task is not complete; continue the remaining work or checks.",
|
||||
Readiness: &event.FinalReadiness{
|
||||
Attempts: 1,
|
||||
Missing: append([]string(nil), recovery.Missing...),
|
||||
|
||||
@@ -208,8 +208,14 @@ try {
|
||||
const settle = () => frames-- <= 0 ? resolve() : requestAnimationFrame(settle);
|
||||
requestAnimationFrame(settle);
|
||||
}));
|
||||
await page.mouse.move(hydrationBox.x + hydrationBox.width / 2, hydrationBox.y + hydrationBox.height / 2);
|
||||
await page.mouse.wheel(0, 100_000);
|
||||
await moveToOuterReaderGutter(page, hydrationTranscript);
|
||||
for (let attempt = 0; attempt < 32; attempt += 1) {
|
||||
await page.mouse.wheel(0, 10_000);
|
||||
const atBottom = await hydrationTranscript.evaluate((element) =>
|
||||
element.scrollHeight - element.scrollTop - element.clientHeight <= 1);
|
||||
if (atBottom) break;
|
||||
await page.waitForTimeout(16);
|
||||
}
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector(".transcript");
|
||||
return element instanceof HTMLElement
|
||||
@@ -488,16 +494,13 @@ try {
|
||||
assert(beforeGrowth.anchorKey && beforeGrowth.grownKey, "bench exposes a visible anchor and mounted dynamic row above it");
|
||||
|
||||
const gestureStart = beforeGrowth.top;
|
||||
await transcript.evaluate((element) => {
|
||||
const viewport = element.getBoundingClientRect();
|
||||
const rows = [...element.querySelectorAll(".transcript__row")];
|
||||
const visible = rows
|
||||
.filter((row) => row.getBoundingClientRect().bottom > viewport.top && row.getBoundingClientRect().top < viewport.bottom)
|
||||
.sort((left, right) => left.getBoundingClientRect().top - right.getBoundingClientRect().top);
|
||||
const above = rows
|
||||
.filter((row) => row.getBoundingClientRect().bottom <= viewport.top)
|
||||
.sort((left, right) => right.getBoundingClientRect().bottom - left.getBoundingClientRect().bottom)[0];
|
||||
if (above instanceof HTMLElement) above.style.paddingBottom = `${Number.parseFloat(above.style.paddingBottom || "0") + 1200}px`;
|
||||
await transcript.evaluate((element, grownKey) => {
|
||||
const above = [...element.querySelectorAll(".transcript__row")]
|
||||
.find((row) => row.dataset.rowKey === grownKey);
|
||||
if (above instanceof HTMLElement) {
|
||||
above.dataset.growthReplayBasePadding = above.style.paddingBottom;
|
||||
above.style.paddingBottom = `${Number.parseFloat(above.style.paddingBottom || "0") + 1200}px`;
|
||||
}
|
||||
window.__reasonixScrollSamples = [];
|
||||
const sample = () => {
|
||||
const currentRows = [...element.querySelectorAll(".transcript__row")];
|
||||
@@ -510,7 +513,7 @@ try {
|
||||
};
|
||||
element.addEventListener("scroll", sample, { passive: true });
|
||||
sample();
|
||||
});
|
||||
}, beforeGrowth.grownKey);
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
const afterGrowth = await transcript.evaluate((element) => ({
|
||||
top: element.scrollTop,
|
||||
@@ -518,6 +521,120 @@ try {
|
||||
}));
|
||||
assert(afterGrowth.top >= gestureStart - 2, `dynamic measurement never reverses an upward gesture into a multi-screen jump (${gestureStart} → ${afterGrowth.top})`);
|
||||
assert(afterGrowth.samples.every((sample) => sample.occupied), "dynamic measurement never exposes a blank transcript viewport");
|
||||
await transcript.evaluate((element, grownKey) => {
|
||||
const grown = [...element.querySelectorAll(".transcript__row")]
|
||||
.find((row) => row.dataset.rowKey === grownKey);
|
||||
if (!(grown instanceof HTMLElement)) return;
|
||||
grown.style.paddingBottom = grown.dataset.growthReplayBasePadding || "";
|
||||
delete grown.dataset.growthReplayBasePadding;
|
||||
}, beforeGrowth.grownKey);
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
|
||||
// Replay the returned 146-row Windows failure with real DOM geometry. A
|
||||
// downward wheel lands while the tail extent briefly loses 2,500px and regains
|
||||
// it 50ms later. Native clamping may move for the collapsed frame, but the
|
||||
// rebound must restore the user's logical direction without blank recovery.
|
||||
await transcript.evaluate((element) => {
|
||||
element.dataset.extentReplayOverflowAnchor = element.style.overflowAnchor;
|
||||
element.style.overflowAnchor = "none";
|
||||
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 500);
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
await transcript.evaluate((element) => new Promise((resolve) => {
|
||||
const style = document.createElement("style");
|
||||
style.id = "reader-extent-replay-style";
|
||||
style.textContent = '.transcript[data-extent-replay-expanded="true"] > [data-viewport-type="element"]::after { content: ""; display: block; height: 2500px; }';
|
||||
document.head.append(style);
|
||||
element.dataset.extentReplayExpanded = "true";
|
||||
let stableFrames = 0;
|
||||
let previousHeight = element.scrollHeight;
|
||||
const settle = () => {
|
||||
const currentHeight = element.scrollHeight;
|
||||
stableFrames = Math.abs(currentHeight - previousHeight) <= 0.5 ? stableFrames + 1 : 0;
|
||||
previousHeight = currentHeight;
|
||||
if (stableFrames >= 4) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(settle);
|
||||
};
|
||||
requestAnimationFrame(settle);
|
||||
}));
|
||||
await page.waitForTimeout(100);
|
||||
await moveToOuterReaderGutter(page, transcript);
|
||||
const beforeExtentReplay = await transcript.evaluate((element) => {
|
||||
const viewport = element.getBoundingClientRect();
|
||||
const rows = [...element.querySelectorAll(".transcript__row")];
|
||||
const visible = rows
|
||||
.filter((row) => row.getBoundingClientRect().bottom > viewport.top && row.getBoundingClientRect().top < viewport.bottom)
|
||||
.sort((left, right) => left.getBoundingClientRect().top - right.getBoundingClientRect().top);
|
||||
const anchor = visible.find((row) => row.getBoundingClientRect().top >= viewport.top) ?? visible[0];
|
||||
window.__readerExtentProbe = { writes: [], samples: [], done: false };
|
||||
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => window.__readerExtentProbe.writes.push(write);
|
||||
const sample = () => {
|
||||
const current = document.querySelector(".transcript");
|
||||
if (!(current instanceof HTMLElement)) return;
|
||||
const rect = current.getBoundingClientRect();
|
||||
const occupied = [...current.querySelectorAll(".transcript__row")].some((row) => {
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
return rowRect.bottom > rect.top && rowRect.top < rect.bottom;
|
||||
});
|
||||
window.__readerExtentProbe.samples.push({ top: current.scrollTop, height: current.scrollHeight, occupied });
|
||||
if (!window.__readerExtentProbe.done) requestAnimationFrame(sample);
|
||||
};
|
||||
element.addEventListener("wheel", () => requestAnimationFrame(() => {
|
||||
delete element.dataset.extentReplayExpanded;
|
||||
const collapsedTop = Math.max(0, element.scrollTop - 2_000);
|
||||
element.scrollTop = collapsedTop;
|
||||
setTimeout(() => {
|
||||
element.dataset.extentReplayExpanded = "true";
|
||||
// Chromium restores its own scroll anchor on macOS. Keep the returned
|
||||
// WebView2 trace's clamped landing so this replay exercises our guard.
|
||||
element.scrollTop = collapsedTop;
|
||||
}, 50);
|
||||
}), { capture: true, once: true });
|
||||
requestAnimationFrame(sample);
|
||||
return {
|
||||
top: element.scrollTop,
|
||||
anchorKey: anchor?.dataset.rowKey ?? null,
|
||||
anchorOffset: anchor ? anchor.getBoundingClientRect().top - viewport.top : null,
|
||||
};
|
||||
});
|
||||
assert(beforeExtentReplay.anchorKey, "extent replay starts from a visible logical anchor");
|
||||
await page.mouse.wheel(0, 133);
|
||||
await page.waitForTimeout(260);
|
||||
const afterExtentReplay = await transcript.evaluate((element, before) => {
|
||||
window.__readerExtentProbe.done = true;
|
||||
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = undefined;
|
||||
element.style.overflowAnchor = element.dataset.extentReplayOverflowAnchor || "";
|
||||
delete element.dataset.extentReplayOverflowAnchor;
|
||||
const anchor = [...element.querySelectorAll(".transcript__row")]
|
||||
.find((row) => row.dataset.rowKey === before.anchorKey);
|
||||
return {
|
||||
top: element.scrollTop,
|
||||
anchorOffset: anchor ? anchor.getBoundingClientRect().top - element.getBoundingClientRect().top : null,
|
||||
writes: window.__readerExtentProbe.writes,
|
||||
samples: window.__readerExtentProbe.samples,
|
||||
mode: element.dataset.scrollMode,
|
||||
};
|
||||
}, beforeExtentReplay);
|
||||
const readerStabilityWrites = afterExtentReplay.writes.filter((write) => write.owner === "reader-stability");
|
||||
const preservedDirection = afterExtentReplay.top >= beforeExtentReplay.top - 2;
|
||||
assert(preservedDirection, preservedDirection
|
||||
? `transient extent rebound cannot reverse a downward wheel (${beforeExtentReplay.top} → ${afterExtentReplay.top})`
|
||||
: `transient extent rebound cannot reverse a downward wheel (${beforeExtentReplay.top} → ${afterExtentReplay.top}; anchor=${beforeExtentReplay.anchorOffset}→${afterExtentReplay.anchorOffset}; mode=${afterExtentReplay.mode}; writes=${JSON.stringify(afterExtentReplay.writes)}; samples=${JSON.stringify(afterExtentReplay.samples)})`);
|
||||
assert(readerStabilityWrites.length === 1, readerStabilityWrites.length === 1
|
||||
? "transient extent rebound uses one bounded reader-stability write"
|
||||
: `transient extent rebound uses one bounded reader-stability write (${readerStabilityWrites.length}; ${JSON.stringify(afterExtentReplay.samples)})`);
|
||||
assert(afterExtentReplay.writes.every((write) => write.owner !== "recovery"),
|
||||
"nonblank extent rebound never enters blank recovery");
|
||||
assert(afterExtentReplay.samples.every((sample) => sample.occupied),
|
||||
"transient extent rebound keeps mounted coverage in every sampled frame");
|
||||
await transcript.evaluate((element) => {
|
||||
delete element.dataset.extentReplayExpanded;
|
||||
document.getElementById("reader-extent-replay-style")?.remove();
|
||||
});
|
||||
|
||||
// Rapid direction changes are the exact user report. Sample every frame and
|
||||
// require that Virtuoso always maintains mounted coverage. Keep a real
|
||||
@@ -587,8 +704,8 @@ try {
|
||||
// A native scrollbar thumb drag owns the browser's scroll range. Keep
|
||||
// Virtuoso's estimated size tree fixed until pointer release so newly
|
||||
// visited variable-height rows cannot resize the thumb under the pointer.
|
||||
// This is deliberately pointer-gutter-specific; the wheel assertions above
|
||||
// continue to exercise ordinary chat-content scrolling and live measuring.
|
||||
// Browser themes clamp the held thumb only after the pointer crosses the
|
||||
// track end, so the target below deliberately overshoots the visible gutter.
|
||||
await transcript.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
@@ -604,7 +721,7 @@ try {
|
||||
return {
|
||||
x: Math.min(rect.right - 1, contentRight + Math.max(1, (rect.right - contentRight) / 2)),
|
||||
y: rect.top + 5,
|
||||
bottomY: rect.bottom - 5,
|
||||
bottomY: Math.min(window.innerHeight - 1, rect.bottom + Math.max(24, rect.height * 0.1)),
|
||||
knownSize: Number.parseFloat(row.dataset.knownSize || "0"),
|
||||
gutter: rect.right - contentRight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
@@ -971,7 +1088,7 @@ try {
|
||||
// frames before watching the idle tail. A single bottom sample can race a
|
||||
// late markdown/row remeasurement on loaded CI runners and contaminate the
|
||||
// idle probe with legitimate convergence motion.
|
||||
await waitForStableTranscriptGeometry(reducedPage, { requireTail: true });
|
||||
await waitForStableTranscriptGeometry(reducedPage, { timeout: 30_000, requireTail: true });
|
||||
const reducedIdle = await reducedPage.evaluate(() => new Promise((resolve) => {
|
||||
const element = document.querySelector(".transcript");
|
||||
const writes = [];
|
||||
@@ -1020,16 +1137,26 @@ try {
|
||||
`reduced-motion tail rests on the physical bottom (${reducedIdle.finalDistance}px)`,
|
||||
);
|
||||
// The #9089 gesture: wheel up, wheel back to the bottom, release, idle.
|
||||
await moveToOuterReaderGutter(reducedPage, reducedPage.locator(".transcript"));
|
||||
const reducedTranscript = reducedPage.locator(".transcript");
|
||||
const reducedBottomTop = await reducedTranscript.evaluate((element) => element.scrollTop);
|
||||
await moveToOuterReaderGutter(reducedPage, reducedTranscript);
|
||||
await reducedPage.mouse.wheel(0, -900);
|
||||
await reducedPage.waitForTimeout(120);
|
||||
await reducedPage.mouse.wheel(0, 100_000);
|
||||
await reducedPage.waitForFunction(() => {
|
||||
await reducedPage.waitForFunction((bottomTop) => {
|
||||
const element = document.querySelector(".transcript");
|
||||
return element instanceof HTMLElement
|
||||
&& element.dataset.scrollMode === "tail-follow"
|
||||
&& element.scrollHeight - element.scrollTop - element.clientHeight <= 4;
|
||||
}, undefined, { timeout: 10_000 });
|
||||
&& element.dataset.scrollMode === "manual"
|
||||
&& element.scrollTop < bottomTop - 1
|
||||
&& element.scrollHeight - element.scrollTop - element.clientHeight > 4;
|
||||
}, reducedBottomTop, { timeout: 5_000 });
|
||||
let reducedReachedBottom = false;
|
||||
for (let attempt = 0; attempt < 20 && !reducedReachedBottom; attempt += 1) {
|
||||
await reducedPage.mouse.wheel(0, 640);
|
||||
await reducedPage.waitForTimeout(50);
|
||||
reducedReachedBottom = await reducedTranscript.evaluate((element) =>
|
||||
element.dataset.scrollMode === "tail-follow"
|
||||
&& element.scrollHeight - element.scrollTop - element.clientHeight <= 4);
|
||||
}
|
||||
assert(reducedReachedBottom, "reduced-motion repeated downward wheels return to the physical bottom (#9089)");
|
||||
const reducedReturn = await reducedPage.evaluate(() => new Promise((resolve) => {
|
||||
const element = document.querySelector(".transcript");
|
||||
const tops = [];
|
||||
|
||||
@@ -19,6 +19,17 @@ function assert(condition, message) {
|
||||
process.stdout.write(` PASS ${message}\n`);
|
||||
}
|
||||
|
||||
async function clickIfPresent(page, selector) {
|
||||
// Keep optional-control discovery and activation in one browser task so a
|
||||
// state update cannot unmount the element between separate Playwright calls.
|
||||
return page.evaluate((target) => {
|
||||
const element = document.querySelector(target);
|
||||
if (!(element instanceof HTMLElement)) return false;
|
||||
element.click();
|
||||
return true;
|
||||
}, selector);
|
||||
}
|
||||
|
||||
async function waitForVisibleSelectionStart(page, { preferHighest, wheelDelta, timeout = 15_000 } = {}) {
|
||||
const box = await page.locator(".transcript").boundingBox();
|
||||
if (box) await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
@@ -161,13 +172,10 @@ try {
|
||||
if (transcript) transcript.scrollTop = 0;
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
const older = page.locator(".transcript__older");
|
||||
if (await older.count() === 0) break;
|
||||
await older.click();
|
||||
if (!(await clickIfPresent(page, ".transcript__older"))) break;
|
||||
await page.waitForTimeout(350);
|
||||
}
|
||||
const jumpBottom = page.locator(".transcript__jump-bottom");
|
||||
if (await jumpBottom.count()) await jumpBottom.click();
|
||||
await clickIfPresent(page, ".transcript__jump-bottom");
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
@@ -195,9 +203,8 @@ try {
|
||||
}
|
||||
// Tool-dense fixtures often land the native tail on non-selectable tool
|
||||
// rows. Wheel off the pinned tail until a mounted "bench turn N" row is
|
||||
// visible, then snapshot the virtual window for the later bound check.
|
||||
// visible before beginning the logical selection gesture.
|
||||
const points = await waitForVisibleSelectionStart(page, { preferHighest: true });
|
||||
const baselineRows = await page.locator(".transcript__row").count();
|
||||
assert(points != null, "bench transcript exposes a selectable visible message");
|
||||
|
||||
await page.evaluate(() => {
|
||||
@@ -337,7 +344,6 @@ try {
|
||||
}, logicalFocusPoint);
|
||||
assert(during.collapsed, `cross-row drag releases the browser Range after logical promotion (${JSON.stringify(during)})`);
|
||||
assert(during.mode === "selection", `cross-page drag remains owned by selection (${during.mode})`);
|
||||
assert(during.rows <= Math.ceil(baselineRows * 1.1) + 2, `logical selection keeps the virtual DOM bounded (${baselineRows} → ${during.rows})`);
|
||||
assert(during.overlayRects > 0, `logical selection paints mounted-row overlay rectangles (${JSON.stringify(during)})`);
|
||||
if (during.writeOwners.some((owner) => owner !== "selection-edge-scroll")) {
|
||||
throw new Error(`logical gesture admitted non-selection scroll owners: ${JSON.stringify(during.writeOwners)}`);
|
||||
@@ -368,8 +374,11 @@ try {
|
||||
transcript.dispatchEvent(new Event("scroll"));
|
||||
}, settled.scrollTop);
|
||||
await page.waitForTimeout(250);
|
||||
const restoredRects = await page.locator(".transcript-selection-overlay__rect").count();
|
||||
assert(restoredRects > 0, "logical overlay restores after selected rows scroll out and back in");
|
||||
const beforeCopy = await page.evaluate(() => ({
|
||||
overlayRects: document.querySelectorAll(".transcript-selection-overlay__rect").length,
|
||||
scrollTop: document.querySelector(".transcript")?.scrollTop ?? 0,
|
||||
}));
|
||||
assert(beforeCopy.overlayRects > 0, "logical overlay restores after selected rows scroll out and back in");
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__logicalClipboardText = null;
|
||||
@@ -392,11 +401,19 @@ try {
|
||||
collapsed: document.getSelection()?.isCollapsed ?? true,
|
||||
rows: document.querySelectorAll(".transcript__row").length,
|
||||
overlayRects: document.querySelectorAll(".transcript-selection-overlay__rect").length,
|
||||
scrollTop: document.querySelector(".transcript")?.scrollTop ?? 0,
|
||||
};
|
||||
});
|
||||
assert(after.collapsed, "logical copy leaves no synthetic browser Range behind");
|
||||
assert(after.overlayRects === 0, "successful copy clears the logical overlay");
|
||||
assert(after.rows <= Math.ceil(baselineRows * 1.1) + 2, "clearing logical selection preserves the normal virtual DOM window");
|
||||
assert(
|
||||
Math.abs(after.scrollTop - beforeCopy.scrollTop) <= 1,
|
||||
`copy cleanup preserves the selection viewport (${beforeCopy.scrollTop} -> ${after.scrollTop})`,
|
||||
);
|
||||
assert(
|
||||
during.rows <= Math.ceil(after.rows * 1.1) + 2,
|
||||
`logical selection keeps the virtual DOM bounded (${after.rows} normal → ${during.rows} selecting)`,
|
||||
);
|
||||
const selectionHeapBaseline = await retainedHeap();
|
||||
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.scrollMode === "manual", undefined, { timeout: 10_000 });
|
||||
@@ -409,8 +426,7 @@ try {
|
||||
const topDeadline = Date.now() + 15_000;
|
||||
let forwardPoints = null;
|
||||
while (Date.now() < topDeadline && (!forwardPoints || forwardPoints.anchorTurn > 17)) {
|
||||
const older = page.locator(".transcript__older");
|
||||
if (await older.count()) await older.click();
|
||||
await clickIfPresent(page, ".transcript__older");
|
||||
await page.mouse.wheel(0, -700);
|
||||
await page.waitForTimeout(80);
|
||||
try {
|
||||
@@ -455,7 +471,6 @@ try {
|
||||
owners: [...new Set((window.__transcriptProgrammaticWrites ?? []).map((write) => write.owner))],
|
||||
}));
|
||||
assert(forwardDuring.mode === "selection", "downward cross-page drag also promotes to logical selection");
|
||||
assert(forwardDuring.rows <= Math.ceil(baselineRows * 1.1) + 2, "forward logical selection also keeps the virtual DOM bounded");
|
||||
assert(forwardDuring.owners.every((owner) => owner === "selection-edge-scroll"), "forward logical gesture preserves scroll ownership");
|
||||
await page.mouse.up();
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+C" : "Control+C");
|
||||
@@ -463,6 +478,11 @@ try {
|
||||
const forwardCopiedTurns = await page.evaluate(() => (window.__logicalClipboardText.match(/bench turn /g) ?? []).length);
|
||||
assert(forwardCopiedTurns >= 20, `forward logical copy resolves a 20+ turn frozen snapshot (${forwardCopiedTurns} turns)`);
|
||||
await page.waitForFunction(() => document.querySelectorAll(".transcript-selection-overlay__rect").length === 0);
|
||||
const forwardAfterRows = await page.locator(".transcript__row").count();
|
||||
assert(
|
||||
forwardDuring.rows <= Math.ceil(forwardAfterRows * 1.1) + 2,
|
||||
`forward logical selection also keeps the virtual DOM bounded (${forwardAfterRows} normal → ${forwardDuring.rows} selecting)`,
|
||||
);
|
||||
const retainedSelectionBytes = Math.max(0, (await retainedHeap()) - selectionHeapBaseline);
|
||||
assert(retainedSelectionBytes <= 2 * 1024 * 1024, `cleared logical selection retains at most 2MiB (${(retainedSelectionBytes / 1024 / 1024).toFixed(2)}MiB)`);
|
||||
await page.evaluate(() => { window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = undefined; });
|
||||
|
||||
@@ -17,14 +17,16 @@
|
||||
"test:terminal": "tsx src/__tests__/terminal-events.test.ts && tsx src/__tests__/terminal-store.test.ts && tsx src/__tests__/terminal-output.test.ts && tsx src/__tests__/terminal-theme.test.ts && tsx src/__tests__/terminal-selection.test.ts && tsx src/__tests__/workspace-layout.test.ts",
|
||||
"test:usage-stats": "tsx src/__tests__/usage-stats-format.test.ts && tsx src/__tests__/usage-stats-panel.test.tsx",
|
||||
"test:task-monitor": "tsx src/components/TaskMonitorPanel.test.tsx && tsx src/__tests__/task-monitor-navigation.test.ts",
|
||||
"test:workspace": "tsx src/__tests__/keyed-resource.test.ts && tsx src/__tests__/workspace-tree-memory.test.ts && tsx src/__tests__/workspace-refresh-store.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-context-menu.test.tsx && tsx src/__tests__/workspace-panel-swr-contract.test.ts",
|
||||
"test:workspace": "tsx src/__tests__/keyed-resource.test.ts && tsx src/__tests__/workspace-tree-memory.test.ts && tsx src/__tests__/workspace-refresh-store.test.ts && tsx src/__tests__/workspace-selection-isolation.test.tsx && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-turn-verification.test.tsx && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/workspace-context-menu.test.tsx && tsx src/__tests__/workspace-resize-interaction.test.tsx && tsx src/__tests__/workspace-panel-swr-contract.test.ts",
|
||||
"test:settings-responsive": "tsx src/__tests__/settings-responsive-layout.test.ts",
|
||||
"test:stream": "tsx src/__tests__/raf-batch.test.ts && tsx src/__tests__/stream-delta-batch.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts",
|
||||
"test:motion": "node scripts/check-motion-ci-contract.mjs && node scripts/check-waapi-contract.mjs --self-test && tsx src/__tests__/native-motion.test.tsx && tsx src/__tests__/approval-animation.test.tsx",
|
||||
"test:motion-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/approval-animation.mjs",
|
||||
"test:transcript": "tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-measured-sizes.test.ts && tsx src/__tests__/transcript-state-snapshot.test.ts && tsx src/__tests__/transcript-layout-recovery.test.ts && tsx src/__tests__/transcript-recovery-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx && tsx src/__tests__/transcript-question-jump.test.tsx",
|
||||
"test:transcript": "tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-measured-sizes.test.ts && tsx src/__tests__/transcript-state-snapshot.test.ts && tsx src/__tests__/transcript-layout-recovery.test.ts && tsx src/__tests__/transcript-reader-extent-stability.test.ts && tsx src/__tests__/transcript-reader-extent-race.test.tsx && tsx src/__tests__/transcript-recovery-race.test.tsx && tsx src/__tests__/transcript-anchor-compensation-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/transcript-question-nav.test.ts && tsx src/__tests__/transcript-question-nav-integration.test.ts && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx && tsx src/__tests__/transcript-question-jump.test.tsx",
|
||||
"test:transcript-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-selection.mjs && PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-scroll-stability.mjs",
|
||||
"pretest": "pnpm test:terminal && pnpm test:task-monitor && tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && tsx src/__tests__/format-tokens.test.ts && pnpm test:usage-stats && pnpm test:settings-responsive && pnpm test:composer-menu-viewport && pnpm test:transcript",
|
||||
"pretest": "pnpm test:terminal && pnpm test:task-monitor && pnpm test:composer && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && tsx src/__tests__/format-tokens.test.ts && pnpm test:usage-stats && pnpm test:settings-responsive && pnpm test:composer-menu-viewport && pnpm test:transcript",
|
||||
"test:composer-menu-viewport": "tsx src/__tests__/composer-menu-viewport.test.ts && tsx src/__tests__/virtual-menu-identity.test.tsx",
|
||||
"test:composer": "tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/composer-resized-input-css.test.ts && tsx src/__tests__/composer-ime.test.tsx && tsx src/__tests__/composer-autosize-scrollbar-css.test.ts",
|
||||
"test": "node scripts/test-todo-visibility.mjs && node scripts/run-tests.mjs",
|
||||
"test:remote": "tsx src/__tests__/remote-workspace-launch.test.ts && tsx src/__tests__/remote-store.test.ts && tsx src/__tests__/remote-error-ux.test.tsx && tsx src/__tests__/remote-hosts-page.test.tsx && tsx src/__tests__/remote-secret-dialog.test.tsx && node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-server-panel.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx",
|
||||
"test:performance": "tsx src/__tests__/history-performance-benchmark.tsx",
|
||||
@@ -55,7 +57,6 @@
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "11.0.0",
|
||||
"remark-rehype": "11.1.2",
|
||||
"svg-pan-zoom": "^3.6.2",
|
||||
"unified": "11.0.5",
|
||||
"unist-util-visit": "^5.1.0",
|
||||
"vfile": "6.0.3",
|
||||
|
||||
Generated
-8
@@ -70,9 +70,6 @@ importers:
|
||||
remark-rehype:
|
||||
specifier: 11.1.2
|
||||
version: 11.1.2
|
||||
svg-pan-zoom:
|
||||
specifier: ^3.6.2
|
||||
version: 3.6.2
|
||||
unified:
|
||||
specifier: 11.0.5
|
||||
version: 11.0.5
|
||||
@@ -1874,9 +1871,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-JwnvRWfVKw/Xzfe6jriFyfey/lWJLq4bUh2jwoR5ChWQuQoOH8FEh1l/bEp46iHHKHEJWIyFJETbazraxNWECg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
@@ -4097,8 +4091,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
|
||||
@@ -31,7 +31,7 @@ function assertBudget(label, actual, budget) {
|
||||
const initialJS = initialAssetPaths(".js");
|
||||
const initialCSS = initialAssetPaths(".css");
|
||||
if (!initialJS.length) throw new Error("no initial JavaScript assets found in dist/index.html");
|
||||
if (!initialCSS.length) throw new Error("no initial CSS assets found in dist/index.html");
|
||||
// initial CSS 允许为空:styles.css 走 ?url 延迟加载,feature 样式走 lazy chunk。
|
||||
|
||||
// main.tsx intentionally loads styles.css before mounting React so the inline
|
||||
// boot shell can paint without waiting for the full application stylesheet.
|
||||
@@ -68,9 +68,32 @@ console.log("\nbundle budgets");
|
||||
// blank-project flow landed; project-topic sort invalidation and request
|
||||
// ordering add another bounded 0.2 KiB. Retain both owner boundaries with a
|
||||
// narrowly rounded 1 KiB ratchet.
|
||||
assertBudget("initial JavaScript gzip", initialJSGzip, 425.0 * 1024);
|
||||
// Diagnostic builds intentionally keep content-free row geometry and scroll
|
||||
// transition probes in the initial transcript path. Stable builds retain the
|
||||
// existing production ratchet. Per-row measurement versions and a bounded
|
||||
// recovery probe add less than 0.1% gzip; retain them with a 0.5 KiB (0.118%)
|
||||
// production ratchet rather than weakening either recovery contract. The
|
||||
// bounded allowance also covers small gzip drift from the embedded build SHA.
|
||||
// Reader extent stabilization adds 1.2 KiB gzip (0.28%) in production for its
|
||||
// bounded input, collapse, rebound, and ownership transaction. Retain it with
|
||||
// a 1.5 KiB (0.35%) ratchet instead of weakening the Windows scroll invariant.
|
||||
// Complete-history navigation adds 0.3 KiB gzip (0.070%) to that production
|
||||
// path while keeping its 1.68 KiB question rail lazy-loaded. Test diagnostics
|
||||
// plus the navigation owner add 0.7 KiB gzip (0.164%) over the merged test gate.
|
||||
// DingTalk channel status and locale wiring move the current-base production
|
||||
// build from 427.2 to 427.7 KiB and test from 428.6 to 429.1 KiB. Keep about
|
||||
// 0.1 KiB of build-SHA headroom with a 0.5 KiB (about 0.117%) ratchet per gate.
|
||||
const initialJSBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 429.2 : 427.8;
|
||||
assertBudget("initial JavaScript gzip", initialJSGzip, initialJSBudgetKiB * 1024);
|
||||
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 280 * 1024);
|
||||
assertBudget("render-blocking CSS gzip", initialCSSGzip, 4 * 1024);
|
||||
// Render-blocking CSS is intentionally absent: styles.css loads deferred via
|
||||
// ?url, and feature styles (heartbeat) live in lazy chunks loaded on demand.
|
||||
// An empty initial CSS list is the desired state, not a build error.
|
||||
if (initialCSS.length > 0) {
|
||||
assertBudget("render-blocking CSS gzip", initialCSSGzip, 4 * 1024);
|
||||
} else {
|
||||
process.stdout.write(" PASS render-blocking CSS: none (all styles deferred)\n");
|
||||
}
|
||||
// Extension surfaces, Task Monitor, and compact decision receipts share the
|
||||
// application stylesheet loaded before React mounts. Keep their combined
|
||||
// allowance bounded even though the file is no longer render-blocking.
|
||||
@@ -89,15 +112,31 @@ for (const path of localeChunks) {
|
||||
// The three StepFun presets add localized names/descriptions (~0.1 KiB
|
||||
// gzip); the two pay-as-you-go presets add the same again. The delivery
|
||||
// floor segmented control adds two labels plus one explanatory tooltip,
|
||||
// measured at 23 B gzip for zh and 8 B for zh-TW. Retain all with the
|
||||
// smallest 0.1 KiB ratchet increments.
|
||||
const budget = name.startsWith("zh-TW-") ? 56.2 * 1024 : 55.5 * 1024;
|
||||
// measured at 23 B gzip for zh and 8 B for zh-TW. Completion receipts add
|
||||
// six short status labels in each locale, requiring another 0.2 KiB per
|
||||
// language. DingTalk setup and mention guidance add at most 0.2 KiB more
|
||||
// (0.36%); retain the complete security and group-chat copy instead of
|
||||
// abbreviating user-facing instructions to fit the old locale ratchet.
|
||||
// Recovery-copy and catalog-only sidebar labels can move the simplified
|
||||
// Chinese chunk across the rounded 55.9 KiB boundary on CI's Node/zlib;
|
||||
// retain a narrow 0.1 KiB headroom rather than making gzip output a
|
||||
// platform-dependent gate.
|
||||
const budget = name.startsWith("zh-TW-") ? 56.6 * 1024 : 56.0 * 1024;
|
||||
assertBudget(`${name} gzip`, gzipBytes(path), budget);
|
||||
}
|
||||
|
||||
const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
|
||||
.reduce((total, path) => total + statSync(path).size, 0);
|
||||
// The maintained Virtuoso engine adds 49.1 KiB raw (2.2%) over the previous
|
||||
// 2268.7 KiB gate. Retain 1% headroom to bound hash/minifier drift.
|
||||
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_341 * 1024);
|
||||
// 2268.7 KiB gate. Navigation remains inside the 2341 KiB production ceiling;
|
||||
// its combined diagnostic wiring adds 2.2 KiB (0.094%) to the test channel.
|
||||
// DingTalk startup wiring moves current-base production from 2341.0 to 2343.6
|
||||
// KiB and test from 2346.2 to 2348.8 KiB; the pinned heading adds 0.5 KiB raw
|
||||
// (0.021%). The workspace panel rework (change-row hover/revert, status badges,
|
||||
// More menu, completion summary) makes the latest-base merge 2353.1 KiB in
|
||||
// production and 2358.3 KiB in test: about 9.0 KiB (0.38%) over main-v2's
|
||||
// channel gates. Retain that attributable UI capacity with 0.1 KiB of build-SHA
|
||||
// headroom without widening the gzip or largest-chunk exceptions.
|
||||
const rawInitialBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 2_358.4 : 2_353.2;
|
||||
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
|
||||
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
|
||||
@@ -5,7 +5,9 @@
|
||||
*
|
||||
* Only the files in ALLOWED_WRITERS may issue imperative scroll calls
|
||||
* (scrollTo / scrollBy / scrollToIndex) against the transcript Virtuoso
|
||||
* handle. Every other module must route through the scroll coordinator's
|
||||
* handle: the arbiter hook plus its extracted controllers (split out for the
|
||||
* file-size budget — still one logical writer, driven only by the arbiter).
|
||||
* Every other module must route through the scroll coordinator's
|
||||
* dispatch/writeOffset API. This guards the "one writer owns scrollTop"
|
||||
* invariant that keeps user scrolls, tail-follow, and anchor recovery from
|
||||
* fighting each other (#8657).
|
||||
@@ -21,12 +23,31 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const SOURCE_ROOT = fileURLToPath(new URL("../src", import.meta.url));
|
||||
|
||||
// Only the scroll-arbiter hook may write to the transcript Virtuoso handle;
|
||||
// every other module routes through its dispatch/writeOffset/recovery API.
|
||||
// Only the scroll-arbiter hook and its extracted controllers may write to the
|
||||
// transcript Virtuoso handle; every other module routes through the
|
||||
// dispatch/writeOffset/recovery API.
|
||||
const ALLOWED_WRITERS = new Set([
|
||||
"lib/useTranscriptScrollArbiter.ts",
|
||||
"lib/transcriptTailSettle.ts",
|
||||
]);
|
||||
|
||||
// Raw `.scrollTop` writes bypass the Virtuoso handle entirely. The allowed
|
||||
// set is deliberate and non-transcript (or natively paired with the arbiter):
|
||||
// - lib/nestedScrollHandoff.ts: the trackpad handoff lane; every write is
|
||||
// paired with onParentScrollIntent so the arbiter sees the gesture.
|
||||
// - components/SettingsPanel.tsx: the settings overlay's own scroller.
|
||||
// - components/WorkspacePanel.tsx: the project tree's own scroller.
|
||||
// - components/editors/LineNumberCode.tsx: the file viewer's own scroller —
|
||||
// resets scroll when a virtual file is replaced by a non-virtual one.
|
||||
// - custom/features/heartbeat/HeartbeatPanel.tsx: the heartbeat list's custom
|
||||
// scrollbar thumb drag, mapped to its own scroller.
|
||||
const ALLOWED_RAW_SCROLLTOP = new Set([
|
||||
"lib/nestedScrollHandoff.ts",
|
||||
"components/SettingsPanel.tsx",
|
||||
"components/WorkspacePanel.tsx",
|
||||
"components/editors/LineNumberCode.tsx",
|
||||
"custom/features/heartbeat/HeartbeatPanel.tsx",
|
||||
]);
|
||||
// Matches imperative scroll calls on the transcript Virtuoso handle, whether
|
||||
// reached through `virtuosoRef.current` directly or a local `handle` alias.
|
||||
const VIRTUOSO_SCROLL_RE = /(?:virtuoso[A-Za-z]*\.current|\bhandle)\??\.\s*scroll(?:To|By|ToIndex)\s*\(/;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Loader hook for tsx-based tests: `.css` imports resolve to an empty module
|
||||
// so component modules (e.g. HeartbeatPanel) can statically import their CSS
|
||||
// without failing under node. Vite handles the real CSS in browser builds.
|
||||
export async function load(url, context, nextLoad) {
|
||||
if (url.endsWith(".css")) {
|
||||
return { format: "module", source: "export default \"\";", shortCircuit: true };
|
||||
}
|
||||
return nextLoad(url, context);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Registers the css-loader hook before tsx boots, so `.css` imports in
|
||||
// component modules resolve to an empty module instead of failing on the
|
||||
// extension. Mirrors svg-loader.mjs; used by css-stub-register.mjs.
|
||||
import { register } from "node:module";
|
||||
register(new URL("./css-loader.mjs", import.meta.url));
|
||||
@@ -8,13 +8,15 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readdirSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { join } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
// Resolve the local tsx entry directly so the runner works both under pnpm
|
||||
// scripts and when invoked as plain `node scripts/run-tests.mjs`.
|
||||
const tsxCli = createRequire(import.meta.url).resolve("tsx/cli");
|
||||
|
||||
const TESTS_DIR = "src/__tests__";
|
||||
const SCRIPTS_DIR = "scripts";
|
||||
|
||||
const OWNED_ELSEWHERE = new Map(Object.entries({
|
||||
"terminal-events.test.ts": "test:terminal",
|
||||
@@ -23,8 +25,11 @@ const OWNED_ELSEWHERE = new Map(Object.entries({
|
||||
"terminal-theme.test.ts": "test:terminal",
|
||||
"task-monitor-navigation.test.ts": "test:task-monitor",
|
||||
"workspace-refresh-store.test.ts": "test:workspace",
|
||||
"workspace-selection-isolation.test.tsx": "test:workspace",
|
||||
"workspace-changes-errors.test.tsx": "test:workspace",
|
||||
"workspace-preview-css.test.ts": "test:workspace",
|
||||
"workspace-context-menu.test.tsx": "test:workspace",
|
||||
"workspace-resize-interaction.test.tsx": "test:workspace",
|
||||
"rich-composer-selection.test.tsx": "pretest",
|
||||
"context-center-contract.test.ts": "pretest",
|
||||
"provider-model-cache.test.ts": "pretest",
|
||||
@@ -32,6 +37,9 @@ const OWNED_ELSEWHERE = new Map(Object.entries({
|
||||
"usage-stats-format.test.ts": "test:usage-stats",
|
||||
"usage-stats-panel.test.tsx": "test:usage-stats",
|
||||
"settings-responsive-layout.test.ts": "test:settings-responsive",
|
||||
"raf-batch.test.ts": "test:stream",
|
||||
"stream-delta-batch.test.ts": "test:stream",
|
||||
"use-controller-stream-progress.test.ts": "test:stream",
|
||||
"transcript-virtuoso-index.test.ts": "test:transcript",
|
||||
"transcript-scroll-release.test.ts": "test:transcript",
|
||||
"transcript-virtualization.test.tsx": "test:transcript",
|
||||
@@ -64,6 +72,10 @@ for (const [name, owner] of OWNED_ELSEWHERE) {
|
||||
}
|
||||
}
|
||||
|
||||
// Suites that statically import CSS (e.g. HeartbeatPanel's heartbeat.css) need
|
||||
// the css-stub loader hook so tsx resolves the import under node.
|
||||
const CSS_STUB_SUITES = new Set(["heartbeat-editor.test.tsx", "heartbeat-next-run.test.ts"]);
|
||||
|
||||
const suites = files.filter((name) => !OWNED_ELSEWHERE.has(name));
|
||||
console.log(`run-tests: ${suites.length} discovered suites (${OWNED_ELSEWHERE.size} owned by dedicated scripts)`);
|
||||
|
||||
@@ -75,7 +87,12 @@ for (const name of suites) {
|
||||
// Node's built-in navigator.language follows the machine's ICU locale, and
|
||||
// suites assert English UI strings.
|
||||
const env = { ...process.env, LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" };
|
||||
const result = spawnSync(process.execPath, [tsxCli, path], { stdio: "inherit", env });
|
||||
const extraArgs = CSS_STUB_SUITES.has(name)
|
||||
// --import needs an absolute file URL: a bare relative path is resolved as
|
||||
// a package specifier by Node and fails with ERR_MODULE_NOT_FOUND.
|
||||
? ["--import", pathToFileURL(resolve(SCRIPTS_DIR, "css-stub-register.mjs")).href]
|
||||
: [];
|
||||
const result = spawnSync(process.execPath, [tsxCli, ...extraArgs, path], { stdio: "inherit", env });
|
||||
if (result.error) console.error(`run-tests: spawn failed for ${path}: ${result.error.message}`);
|
||||
if (result.status !== 0) {
|
||||
if (!keepGoing) {
|
||||
|
||||
+202
-223
@@ -5,7 +5,6 @@ import {
|
||||
Activity,
|
||||
Command,
|
||||
Copy as RestoreIcon,
|
||||
Download,
|
||||
Minus,
|
||||
Search,
|
||||
Server,
|
||||
@@ -13,14 +12,10 @@ import {
|
||||
SquarePen,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
FileDown,
|
||||
FileImage,
|
||||
FileText,
|
||||
FileJson,
|
||||
GitBranch,
|
||||
MessageSquare,
|
||||
Settings as SettingsIcon,
|
||||
Pencil,
|
||||
RotateCw,
|
||||
Trash2,
|
||||
AlarmClock,
|
||||
@@ -71,10 +66,9 @@ import { dismissOnboarding, shouldOpenOnboarding } from "./lib/onboarding";
|
||||
import { AppChrome } from "./components/AppChrome";
|
||||
import { ShortcutsCheatsheet } from "./components/ShortcutsCheatsheet";
|
||||
import { WorktreeBadge } from "./components/WorktreeBadge";
|
||||
import { HeartbeatPanel } from "./custom/features/heartbeat/HeartbeatPanel";
|
||||
import "./custom/features/heartbeat/heartbeat.css";
|
||||
import { CopyButton } from "./components/CopyButton";
|
||||
import { ExternalOpener, shouldMountExternalOpener } from "./components/ExternalOpener";
|
||||
import { TopicbarMoreMenu } from "./components/TopicbarMoreMenu";
|
||||
import { startTerminalEventBridge } from "./lib/terminalEvents";
|
||||
import { applyTerminalThemePreference } from "./lib/terminalTheme";
|
||||
import { formatTerminalOutputForComposer } from "./lib/terminalOutput";
|
||||
@@ -109,8 +103,10 @@ import {
|
||||
type QualityFloor,
|
||||
type TabMeta,
|
||||
type ToolApprovalMode,
|
||||
type WireCompletionSummary,
|
||||
type WorkspaceConflictView,
|
||||
} from "./lib/types";
|
||||
import type { WorkspaceVerificationRevealRequest } from "./components/WorkspacePanel";
|
||||
import type { InvocationMetadataMap, StructuredInvocationSubmit } from "./lib/invocationDisplay";
|
||||
import type { RewindUndoState } from "./lib/rewindTypes";
|
||||
import { formatSelectionReference, type SelectedTextInsertRequest } from "./lib/selectedTextContext";
|
||||
@@ -143,11 +139,7 @@ import {
|
||||
CREATION_RIGHT_DOCK_MIN_RENDER_WIDTH,
|
||||
CREATION_RIGHT_DOCK_TREE_MIN_WIDTH,
|
||||
CREATION_SIDEBAR_MIN_WIDTH,
|
||||
RIGHT_DOCK_MAX_WIDTH,
|
||||
RIGHT_DOCK_MIN_RENDER_WIDTH,
|
||||
RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH,
|
||||
RIGHT_DOCK_PREVIEW_MIN_WIDTH,
|
||||
RIGHT_DOCK_TREE_MAX_WIDTH,
|
||||
RIGHT_DOCK_TREE_MIN_WIDTH,
|
||||
type RightDockMode,
|
||||
SIDEBAR_MAX_WIDTH,
|
||||
@@ -157,7 +149,6 @@ import {
|
||||
applyLayoutStyleDefaults,
|
||||
clampCreationRightDockTreeWidth,
|
||||
clampCreationSidebarWidth,
|
||||
clampRightDockPreviewWidth,
|
||||
clampRightDockTreeWidth,
|
||||
clampSidebarWidth,
|
||||
clampTerminalHeight,
|
||||
@@ -165,7 +156,6 @@ import {
|
||||
defaultCreationSidebarWidth,
|
||||
defaultRightDockTreeWidth,
|
||||
defaultSidebarWidth,
|
||||
saveRightDockPreviewWidth,
|
||||
saveRightDockTreeWidth,
|
||||
saveSidebarCollapsed,
|
||||
saveSidebarWidth,
|
||||
@@ -173,6 +163,7 @@ import {
|
||||
saveTerminalPanelOpen,
|
||||
terminalMaxHeight,
|
||||
saveWorkspacePanelOpen,
|
||||
loadWorkspacePanelOpen,
|
||||
useLayoutStore,
|
||||
} from "./store/layout";
|
||||
import { useOverlayStore } from "./store/overlays";
|
||||
@@ -197,9 +188,9 @@ import { applyConfiguredBaseAppearance, applyThemePack, applyThemeScene, clearTh
|
||||
import { ThemeBackground } from "./components/ThemeBackground";
|
||||
import { applyTextSize, DEFAULT_TEXT_SIZE, getTextSize, nextTextSize } from "./lib/textSize";
|
||||
import { useViewportHeightVar, useWindowStatePersistence } from "./lib/windowState";
|
||||
import { resolveLiveWorkspacePanelWidth, resolveWorkspacePanelPlacement, workspacePanelAriaMinWidth } from "./lib/workspaceLayout";
|
||||
import { availableWorkspacePanelWidth, resolveLiveWorkspacePanelWidth, resolveWorkspacePanelPlacement, workspacePanelAriaMinWidth } from "./lib/workspaceLayout";
|
||||
import { createPointerResizeLifecycle, createRafResizeUpdater } from "./lib/resizeDrag";
|
||||
import { useGlobalShortcut } from "./lib/keyboardShortcuts";
|
||||
import { formatShortcutCombo, resolvedShortcutCombo, useGlobalShortcut } from "./lib/keyboardShortcuts";
|
||||
import { useWarmTerminalPanel } from "./lib/useWarmTerminalPanel";
|
||||
import { topicShortcutIndexFromEvent, useTopicShortcuts, type TopicShortcutEntry } from "./lib/topicShortcuts";
|
||||
import { composerDraftKeyForTab } from "./lib/composerDraftKey";
|
||||
@@ -285,6 +276,7 @@ function NoticePreviewPanel() {
|
||||
const TranscriptSelectionMenu = lazy(() => import("./components/TranscriptSelectionMenu").then((module) => ({ default: module.TranscriptSelectionMenu })));
|
||||
const ContextPanel = lazy(() => import("./components/ContextPanel").then((module) => ({ default: module.ContextPanel })));
|
||||
const HistoryPanel = lazy(() => import("./components/HistoryPanel").then((module) => ({ default: module.HistoryPanel })));
|
||||
const HeartbeatView = lazy(() => import("./custom/features/heartbeat/HeartbeatPanel").then((module) => ({ default: module.HeartbeatView })));
|
||||
const SettingsPanel = lazy(() => import("./components/SettingsPanelEntry").then((module) => ({ default: module.SettingsPanel })));
|
||||
const RemotePanel = lazy(() => import("./components/RemotePanel").then((module) => ({ default: module.RemotePanel })));
|
||||
const TerminalPanel = lazy(() => import("./components/TerminalPanel").then((module) => ({ default: module.TerminalPanel })));
|
||||
@@ -298,7 +290,6 @@ const WorkspacePanel = lazy(async () => {
|
||||
});
|
||||
|
||||
const CHAT_MIN_WIDTH = 400;
|
||||
const CHAT_COMFORT_MIN_WIDTH = 560;
|
||||
const WORKSPACE_RESIZER_WIDTH = 8;
|
||||
|
||||
function stripLegacyGoalBudgetFlags(arg: string): string {
|
||||
@@ -1177,8 +1168,8 @@ export default function App() {
|
||||
const [sidebarImDetailConnectionId, setSidebarImDetailConnectionId] = useState("");
|
||||
const sidebarCollapsed = useLayoutStore((s) => s.sidebarCollapsed);
|
||||
const setSidebarCollapsed = useLayoutStore((s) => s.setSidebarCollapsed);
|
||||
const heartbeatOpen = useOverlayStore((s) => s.heartbeatOpen);
|
||||
const setHeartbeatOpen = useOverlayStore((s) => s.setHeartbeatOpen);
|
||||
const mainView = useOverlayStore((s) => s.mainView);
|
||||
const setMainView = useOverlayStore((s) => s.setMainView);
|
||||
type TimeFilter = "all" | "10" | "20" | "1h" | "3h" | "5h" | "1d";
|
||||
const [topicTimeFilter, setTopicTimeFilter] = useState<TimeFilter>(() => {
|
||||
try {
|
||||
@@ -1202,7 +1193,6 @@ export default function App() {
|
||||
const rightDockTreeWidth = useLayoutStore((s) => s.rightDockTreeWidth);
|
||||
const setRightDockTreeWidth = useLayoutStore((s) => s.setRightDockTreeWidth);
|
||||
const rightDockPreviewWidth = useLayoutStore((s) => s.rightDockPreviewWidth);
|
||||
const setRightDockPreviewWidth = useLayoutStore((s) => s.setRightDockPreviewWidth);
|
||||
const workspacePreviewActive = useLayoutStore((s) => s.workspacePreviewActive);
|
||||
const setWorkspacePreviewActive = useLayoutStore((s) => s.setWorkspacePreviewActive);
|
||||
const attentionChimeEvents = useRef(new Set<string>());
|
||||
@@ -1292,10 +1282,24 @@ export default function App() {
|
||||
const setTopicExportOpen = useOverlayStore((s) => s.setTopicExportOpen);
|
||||
const sidebarSearchOpen = useOverlayStore((s) => s.sidebarSearchOpen);
|
||||
const setSidebarSearchOpen = useOverlayStore((s) => s.setSidebarSearchOpen);
|
||||
|
||||
// Leaving the automation view: any overlay/sidebar surface that signals the
|
||||
// user is returning to the chat workspace switches mainView back to "chat".
|
||||
// Navigation paths (open topic / new session / resume) are covered by
|
||||
// enqueueNavigation.
|
||||
useEffect(() => {
|
||||
if (mainView !== "automation") return;
|
||||
// Any chat-side overlay/surface counts as "returning to the chat
|
||||
// workspace": settings, palette, sidebar search, history, the shortcuts
|
||||
// cheatsheet and the topic export menu all switch mainView back to chat.
|
||||
if (settingsTarget !== null || paletteOpen || sidebarSearchOpen || histView !== null
|
||||
|| shortcutsOpen || topicExportOpen) {
|
||||
setMainView("chat");
|
||||
}
|
||||
}, [mainView, settingsTarget, paletteOpen, sidebarSearchOpen, histView, shortcutsOpen, topicExportOpen, setMainView]);
|
||||
const sidebarSearchFocusSignal = useOverlayStore((s) => s.sidebarSearchFocusSignal);
|
||||
const setSidebarSearchFocusSignal = useOverlayStore((s) => s.setSidebarSearchFocusSignal);
|
||||
const [sidebarTogglePressed, setSidebarTogglePressed] = useState(false);
|
||||
const [workspaceTogglePressed, setWorkspaceTogglePressed] = useState(false);
|
||||
const [clearContextPending, setClearContextPending] = useState(false);
|
||||
const [backgroundRuntimes, setBackgroundRuntimes] = useState<BackgroundRuntimeView[]>([]);
|
||||
const [workspaceConflict, setWorkspaceConflict] = useState<WorkspaceConflictView | null>(null);
|
||||
@@ -1308,7 +1312,6 @@ export default function App() {
|
||||
const layoutRef = useRef<HTMLDivElement>(null);
|
||||
const workspacePanelResizeFinishRef = useRef<(() => void) | null>(null);
|
||||
const sidebarTogglePressTimerRef = useRef<number | null>(null);
|
||||
const workspaceTogglePressTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Persist window geometry across launches.
|
||||
useWindowStatePersistence();
|
||||
@@ -1408,18 +1411,6 @@ export default function App() {
|
||||
}, 260);
|
||||
}, []);
|
||||
|
||||
const pulseWorkspaceToggle = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (workspaceTogglePressTimerRef.current !== null) {
|
||||
window.clearTimeout(workspaceTogglePressTimerRef.current);
|
||||
}
|
||||
setWorkspaceTogglePressed(true);
|
||||
workspaceTogglePressTimerRef.current = window.setTimeout(() => {
|
||||
workspaceTogglePressTimerRef.current = null;
|
||||
setWorkspaceTogglePressed(false);
|
||||
}, 260);
|
||||
}, []);
|
||||
|
||||
const anchorAppScrollToChat = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const el = appRef.current;
|
||||
@@ -1437,9 +1428,6 @@ export default function App() {
|
||||
if (sidebarTogglePressTimerRef.current !== null) {
|
||||
window.clearTimeout(sidebarTogglePressTimerRef.current);
|
||||
}
|
||||
if (workspaceTogglePressTimerRef.current !== null) {
|
||||
window.clearTimeout(workspaceTogglePressTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -1590,17 +1578,30 @@ export default function App() {
|
||||
});
|
||||
}, []);
|
||||
const rightDockDetailActive = rightDockMode !== "context" && workspacePreviewActive;
|
||||
const preferredWorkspacePanelWidth = rightDockDetailActive ? rightDockPreviewWidth : rightDockTreeWidth;
|
||||
// The dock keeps one width across tab switches (context/files/changed):
|
||||
// the tree width is the single source so toggling tabs never resizes the
|
||||
// sidebar. Preview detail stays inside the dock without widening it.
|
||||
const preferredWorkspacePanelWidth = rightDockTreeWidth;
|
||||
const rightDockTreeMinWidth = desktopLayoutStyle === "creation" ? CREATION_RIGHT_DOCK_TREE_MIN_WIDTH : RIGHT_DOCK_TREE_MIN_WIDTH;
|
||||
const rightDockTreeWidthClamp = desktopLayoutStyle === "creation" ? clampCreationRightDockTreeWidth : clampRightDockTreeWidth;
|
||||
const rightDockMinRenderWidth = desktopLayoutStyle === "creation" && !rightDockDetailActive
|
||||
? CREATION_RIGHT_DOCK_MIN_RENDER_WIDTH
|
||||
: RIGHT_DOCK_MIN_RENDER_WIDTH;
|
||||
const workspacePanelMinWidth = rightDockDetailActive ? RIGHT_DOCK_PREVIEW_MIN_WIDTH : rightDockTreeMinWidth;
|
||||
const chatReservedWidth = workspacePanelOpen && !workspacePanelMaximized ? CHAT_COMFORT_MIN_WIDTH : CHAT_MIN_WIDTH;
|
||||
const workspacePanelMinWidth = rightDockTreeMinWidth;
|
||||
const chatReservedWidth = CHAT_MIN_WIDTH;
|
||||
const workspacePanelAvailableWidth = availableWorkspacePanelWidth({
|
||||
viewportWidth,
|
||||
sidebarCollapsed,
|
||||
sidebarWidth,
|
||||
chatMinWidth: chatReservedWidth,
|
||||
resizerWidth: WORKSPACE_RESIZER_WIDTH,
|
||||
});
|
||||
const {
|
||||
renderWidth: workspacePanelRenderWidth,
|
||||
overlay: workspacePanelOverlay,
|
||||
// The automation page fills the main content area; the workbench dock must
|
||||
// not overlay it. main-v2 keeps automation as a popup so its placement
|
||||
// helper has no view concept — apply the exclusion here on top.
|
||||
renderable: workspacePanelRenderable,
|
||||
gridOpen: workspacePanelGridOpen,
|
||||
} = resolveWorkspacePanelPlacement({
|
||||
@@ -1610,6 +1611,9 @@ export default function App() {
|
||||
minWidth: workspacePanelMinWidth, minRenderWidth: rightDockMinRenderWidth,
|
||||
liveWidth: liveWorkspacePanelRenderWidth,
|
||||
});
|
||||
const automationView = mainView === "automation";
|
||||
const effectiveWorkspacePanelRenderable = automationView ? false : workspacePanelRenderable;
|
||||
const effectiveWorkspacePanelGridOpen = automationView ? false : workspacePanelGridOpen;
|
||||
const resolveLiveWorkspacePanelRenderWidth = useCallback(
|
||||
(preferredWidth: number, nextSidebarWidth = sidebarWidth) =>
|
||||
resolveLiveWorkspacePanelWidth({
|
||||
@@ -1665,10 +1669,12 @@ export default function App() {
|
||||
activeTab?.scope ?? "",
|
||||
activeTab?.workspaceRoot ?? state.meta?.cwd ?? "",
|
||||
].join("\u0000");
|
||||
const restoreWorkspaceDockWidths = useCallback((treeWidth: number, previewWidth: number) => {
|
||||
setRightDockTreeWidth(rightDockTreeWidthClamp(treeWidth));
|
||||
setRightDockPreviewWidth(clampRightDockPreviewWidth(previewWidth));
|
||||
}, [rightDockTreeWidthClamp]);
|
||||
const restoreWorkspaceDockWidths = useCallback((treeWidth: number, _previewWidth: number) => {
|
||||
// Single-width dock: only the tree width is meaningful; clamp it to the
|
||||
// dynamic available width (chat keeps its 400px floor), never a fixed
|
||||
// 560 ceiling, so the user's remembered width is preserved when reopened.
|
||||
setRightDockTreeWidth(rightDockTreeWidthClamp(treeWidth, workspacePanelAvailableWidth));
|
||||
}, [rightDockTreeWidthClamp, workspacePanelAvailableWidth]);
|
||||
const sidebarImDetailConnection = useMemo(
|
||||
() => sidebarImConnections.find((connection) => connection.id === sidebarImDetailConnectionId) ?? null,
|
||||
[sidebarImConnections, sidebarImDetailConnectionId],
|
||||
@@ -2715,28 +2721,22 @@ export default function App() {
|
||||
const setSavedWorkspacePanelWidth = useCallback(
|
||||
(width: number) => {
|
||||
closeTransientOverlays();
|
||||
if (rightDockDetailActive) {
|
||||
const next = clampRightDockPreviewWidth(width);
|
||||
setRightDockPreviewWidth(next);
|
||||
saveRightDockPreviewWidth(next);
|
||||
return;
|
||||
}
|
||||
const next = rightDockTreeWidthClamp(width);
|
||||
const next = rightDockTreeWidthClamp(width, workspacePanelAvailableWidth);
|
||||
setRightDockTreeWidth(next);
|
||||
saveRightDockTreeWidth(next);
|
||||
},
|
||||
[closeTransientOverlays, rightDockDetailActive, rightDockTreeWidthClamp],
|
||||
[closeTransientOverlays, rightDockTreeWidthClamp, workspacePanelAvailableWidth],
|
||||
);
|
||||
|
||||
const ensureWorkspacePanelWidth = useCallback(
|
||||
(width: number) => {
|
||||
closeTransientOverlays();
|
||||
if (rightDockMode === "context") return;
|
||||
const next = clampRightDockPreviewWidth(width);
|
||||
setRightDockPreviewWidth(next);
|
||||
saveRightDockPreviewWidth(next);
|
||||
const next = rightDockTreeWidthClamp(width, workspacePanelAvailableWidth);
|
||||
setRightDockTreeWidth(next);
|
||||
saveRightDockTreeWidth(next);
|
||||
},
|
||||
[closeTransientOverlays, rightDockMode],
|
||||
[closeTransientOverlays, rightDockTreeWidthClamp, workspacePanelAvailableWidth],
|
||||
);
|
||||
|
||||
const startWorkspacePanelResize = useCallback(
|
||||
@@ -2762,11 +2762,7 @@ export default function App() {
|
||||
const onMove = (moveEvent: PointerEvent) => {
|
||||
const delta = moveEvent.clientX - startX;
|
||||
nextDockWidth = startDockWidth - delta;
|
||||
if (rightDockDetailActive) {
|
||||
nextDockWidth = clampRightDockPreviewWidth(nextDockWidth);
|
||||
} else {
|
||||
nextDockWidth = rightDockTreeWidthClamp(nextDockWidth);
|
||||
}
|
||||
nextDockWidth = rightDockTreeWidthClamp(nextDockWidth, workspacePanelAvailableWidth);
|
||||
liveResize.schedule(resolveLiveWorkspacePanelRenderWidth(nextDockWidth));
|
||||
};
|
||||
const lifecycle = createPointerResizeLifecycle({
|
||||
@@ -2787,7 +2783,7 @@ export default function App() {
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
},
|
||||
[closeTransientOverlays, resolveLiveWorkspacePanelRenderWidth, rightDockDetailActive, rightDockTreeWidthClamp, setSavedWorkspacePanelWidth, workspacePanelOpen, workspacePanelRenderWidth],
|
||||
[closeTransientOverlays, resolveLiveWorkspacePanelRenderWidth, rightDockDetailActive, rightDockTreeWidthClamp, setSavedWorkspacePanelWidth, workspacePanelAvailableWidth, workspacePanelOpen, workspacePanelRenderWidth],
|
||||
);
|
||||
|
||||
const resizeWorkspacePanelWithKeyboard = useCallback(
|
||||
@@ -2797,13 +2793,13 @@ export default function App() {
|
||||
setSavedWorkspacePanelWidth(workspacePanelRenderWidth + (event.key === "ArrowLeft" ? 16 : -16));
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
setSavedWorkspacePanelWidth(rightDockDetailActive ? RIGHT_DOCK_PREVIEW_MIN_WIDTH : rightDockTreeMinWidth);
|
||||
setSavedWorkspacePanelWidth(rightDockTreeMinWidth);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
setSavedWorkspacePanelWidth(rightDockDetailActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH);
|
||||
setSavedWorkspacePanelWidth(workspacePanelAvailableWidth);
|
||||
}
|
||||
},
|
||||
[rightDockDetailActive, rightDockTreeMinWidth, setSavedWorkspacePanelWidth, workspacePanelRenderWidth],
|
||||
[rightDockDetailActive, rightDockTreeMinWidth, setSavedWorkspacePanelWidth, workspacePanelAvailableWidth, workspacePanelRenderWidth],
|
||||
);
|
||||
|
||||
const terminalRenderHeight = clampTerminalHeight(terminalHeight, viewportHeight);
|
||||
@@ -2874,6 +2870,8 @@ export default function App() {
|
||||
[setSavedTerminalHeight, terminalPanelOpen, terminalRenderHeight, terminalResizeMaxHeight],
|
||||
);
|
||||
|
||||
const activeWorkspaceRoot = activeTab?.workspaceRoot ?? state.meta?.cwd ?? "";
|
||||
|
||||
const openWorkspacePanel = useCallback(
|
||||
(mode: RightDockMode = rightDockMode) => {
|
||||
closeTransientOverlays();
|
||||
@@ -2895,9 +2893,9 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
setWorkspacePanelOpen(true);
|
||||
saveWorkspacePanelOpen(true);
|
||||
saveWorkspacePanelOpen(true, activeWorkspaceRoot);
|
||||
},
|
||||
[closeTransientOverlays, rightDockMode, workspacePanelMaximized, workspacePanelOpen],
|
||||
[activeWorkspaceRoot, closeTransientOverlays, rightDockMode, workspacePanelMaximized, workspacePanelOpen],
|
||||
);
|
||||
|
||||
const closeWorkspacePanel = useCallback(() => {
|
||||
@@ -2908,12 +2906,19 @@ export default function App() {
|
||||
setLiveWorkspacePanelRenderWidth(null);
|
||||
setWorkspacePanelMaximized(false);
|
||||
setWorkspacePanelOpen(false);
|
||||
saveWorkspacePanelOpen(false);
|
||||
}, [closeTransientOverlays, workspacePanelOpen]);
|
||||
saveWorkspacePanelOpen(false, activeWorkspaceRoot);
|
||||
}, [activeWorkspaceRoot, closeTransientOverlays, workspacePanelOpen]);
|
||||
|
||||
// Restore the right dock's open/closed state per project: switching to a
|
||||
// different workspace root (or a global session) restores that scope's own
|
||||
// preference instead of carrying the previous project's state over.
|
||||
useEffect(() => {
|
||||
setWorkspacePanelOpen(loadWorkspacePanelOpen(activeWorkspaceRoot));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeWorkspaceRoot]);
|
||||
|
||||
const toggleWorkspacePanel = useCallback(() => {
|
||||
pulseWorkspaceToggle();
|
||||
if (workspacePanelRenderable) {
|
||||
if (effectiveWorkspacePanelRenderable) {
|
||||
closeWorkspacePanel();
|
||||
return;
|
||||
}
|
||||
@@ -2923,8 +2928,10 @@ export default function App() {
|
||||
openWorkspacePanel(rightDockMode === "changed" ? "changed" : "files");
|
||||
return;
|
||||
}
|
||||
openWorkspacePanel("context");
|
||||
}, [closeWorkspacePanel, desktopLayoutStyle, openWorkspacePanel, pulseWorkspaceToggle, rightDockMode, workspacePanelRenderable]);
|
||||
// Reopen with the previously active tab (rightDockMode is kept in the
|
||||
// store across close/open) instead of forcing "context".
|
||||
openWorkspacePanel();
|
||||
}, [closeWorkspacePanel, desktopLayoutStyle, effectiveWorkspacePanelRenderable, openWorkspacePanel, rightDockMode]);
|
||||
|
||||
const openRightDockMode = useCallback(
|
||||
(mode: RightDockMode) => {
|
||||
@@ -2933,6 +2940,22 @@ export default function App() {
|
||||
[openWorkspacePanel],
|
||||
);
|
||||
|
||||
const verificationRevealSequenceRef = useRef(0);
|
||||
const [verificationRevealRequest, setVerificationRevealRequest] = useState<WorkspaceVerificationRevealRequest | null>(null);
|
||||
const openTurnVerification = useCallback((summary: WireCompletionSummary) => {
|
||||
openRightDockMode("changed");
|
||||
verificationRevealSequenceRef.current += 1;
|
||||
setVerificationRevealRequest({
|
||||
id: verificationRevealSequenceRef.current,
|
||||
summary,
|
||||
tabId: activeTabId ?? "",
|
||||
turnStartAt: state.turnStartAt,
|
||||
currentSummary: state.completionSummary,
|
||||
});
|
||||
}, [activeTabId, openRightDockMode, state.completionSummary, state.turnStartAt]);
|
||||
|
||||
useEffect(() => { setVerificationRevealRequest(null); }, [activeTabId, state.completionSummary, state.turnStartAt]);
|
||||
|
||||
const toggleTerminalPanel = useCallback(() => {
|
||||
setTerminalPanelOpen((prev) => {
|
||||
const next = !prev;
|
||||
@@ -3140,8 +3163,12 @@ export default function App() {
|
||||
const tabSwitchSeqRef = useRef(0);
|
||||
const tabSwitchRunningRef = useRef(false);
|
||||
const tabSwitchPendingRef = useRef<PendingNavigationRequest<{ tabId: string; optimisticTab?: TabMeta; navigationIntentSeq: number }> | null>(null);
|
||||
const enterChatViewForTabNavigation = useCallback(() => {
|
||||
setMainView("chat");
|
||||
}, [setMainView]);
|
||||
const enqueueTabSwitch = useCallback(
|
||||
(tabId: string, optimisticTab?: TabMeta): Promise<void> => {
|
||||
enterChatViewForTabNavigation();
|
||||
// Claim the shared navigation epoch at click time, before this request
|
||||
// can wait behind an older tab switch. That immediately invalidates any
|
||||
// in-flight blank/topic completion from a previous user intent.
|
||||
@@ -3165,10 +3192,11 @@ export default function App() {
|
||||
},
|
||||
);
|
||||
},
|
||||
[beginNavigationSurface, isNavigationIntentCurrent, noteNavigationIntent, refreshTabMetas, settleNavigationSurface, switchTab],
|
||||
[beginNavigationSurface, enterChatViewForTabNavigation, isNavigationIntentCurrent, noteNavigationIntent, refreshTabMetas, settleNavigationSurface, switchTab],
|
||||
);
|
||||
|
||||
const revealBackgroundRuntime = useCallback(async (tabId: string): Promise<void> => {
|
||||
enterChatViewForTabNavigation();
|
||||
const navigationIntentSeq = noteNavigationIntent();
|
||||
beginNavigationSurface(navigationIntentSeq);
|
||||
try {
|
||||
@@ -3191,7 +3219,7 @@ export default function App() {
|
||||
} finally {
|
||||
settleNavigationSurface(navigationIntentSeq);
|
||||
}
|
||||
}, [beginNavigationSurface, isNavigationIntentCurrent, noteNavigationIntent, reassertVisibleTabAfterStaleNavigation, refreshTabMetas, settleNavigationSurface, showToast, switchTab]);
|
||||
}, [beginNavigationSurface, enterChatViewForTabNavigation, isNavigationIntentCurrent, noteNavigationIntent, reassertVisibleTabAfterStaleNavigation, refreshTabMetas, settleNavigationSurface, showToast, switchTab]);
|
||||
|
||||
const handleTabChange = useCallback((id: string) => {
|
||||
closeTransientOverlays();
|
||||
@@ -3258,6 +3286,7 @@ export default function App() {
|
||||
|
||||
const revealWorkspaceWriter = useCallback(async () => {
|
||||
if (!activeTabId) return;
|
||||
enterChatViewForTabNavigation();
|
||||
const navigationIntentSeq = noteNavigationIntent();
|
||||
beginNavigationSurface(navigationIntentSeq);
|
||||
try {
|
||||
@@ -3281,7 +3310,7 @@ export default function App() {
|
||||
} finally {
|
||||
settleNavigationSurface(navigationIntentSeq);
|
||||
}
|
||||
}, [activeTabId, beginNavigationSurface, isNavigationIntentCurrent, noteNavigationIntent, reassertVisibleTabAfterStaleNavigation, refreshTabMetas, settleNavigationSurface, showToast, switchTab]);
|
||||
}, [activeTabId, beginNavigationSurface, enterChatViewForTabNavigation, isNavigationIntentCurrent, noteNavigationIntent, reassertVisibleTabAfterStaleNavigation, refreshTabMetas, settleNavigationSurface, showToast, switchTab]);
|
||||
|
||||
const continueInDeliveryWorktree = useCallback(async () => {
|
||||
const root = state.meta?.workspaceRoot || state.meta?.workspacePath || state.meta?.cwd;
|
||||
@@ -3399,6 +3428,9 @@ export default function App() {
|
||||
!transcriptHydrating &&
|
||||
!hydratePlaceholderActive;
|
||||
const transcriptItems = hydratePlaceholderActive ? state.hydratePlaceholderItems! : state.items;
|
||||
const handleLoadOlderHistory = useCallback((targetTurn?: number) => (
|
||||
activeTabId ? loadOlderHistory(activeTabId, targetTurn) : Promise.resolve(false)
|
||||
), [activeTabId, loadOlderHistory]);
|
||||
|
||||
// Display items: backend history is authoritative after immediate commit.
|
||||
// rewindState only drives the undo banner, not optimistic truncation.
|
||||
@@ -3785,6 +3817,9 @@ export default function App() {
|
||||
}, [beginNavigationSurface, runNavigationRequest, settleNavigationSurface]);
|
||||
|
||||
const enqueueNavigation = useCallback((input: DesktopNavigationIntent): Promise<void> => {
|
||||
// Any navigation (open topic / new session / resume) leaves the automation
|
||||
// view and returns to the chat workspace.
|
||||
setMainView("chat");
|
||||
// Invalidate any in-flight activation's stale apply at ENQUEUE time. The
|
||||
// queue serializes requests, so a click made while another request runs
|
||||
// only advances the controller's navigation epoch when it eventually
|
||||
@@ -3793,7 +3828,7 @@ export default function App() {
|
||||
// newer surface's cached state (#6613 review).
|
||||
const navigationIntentSeq = noteNavigationIntent();
|
||||
return enqueueNavigationWithIntent(input, navigationIntentSeq);
|
||||
}, [enqueueNavigationWithIntent, noteNavigationIntent]);
|
||||
}, [enqueueNavigationWithIntent, noteNavigationIntent, setMainView]);
|
||||
|
||||
const openBlankSession = useCallback((scope: string, workspaceRoot: string): Promise<void> =>
|
||||
enqueueNavigation({ kind: "blank", scope, workspaceRoot: scope === "project" ? workspaceRoot : "" }),
|
||||
@@ -4200,14 +4235,36 @@ export default function App() {
|
||||
const browserPreviewChrome = typeof window !== "undefined" && !window.runtime;
|
||||
const browserMockScenario = browserPreviewChrome ? browserMockScenarioParam() : "";
|
||||
const guidanceQueueMockItems = isGuidanceMockScenario(browserMockScenario) ? GUIDANCE_QUEUE_MOCK_ITEMS : undefined;
|
||||
const workspacePanelResetWidth = rightDockDetailActive
|
||||
? RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH
|
||||
: desktopLayoutStyle === "creation"
|
||||
? defaultCreationRightDockTreeWidth()
|
||||
: defaultRightDockTreeWidth();
|
||||
const workspacePanelResetWidth = desktopLayoutStyle === "creation"
|
||||
? defaultCreationRightDockTreeWidth()
|
||||
: defaultRightDockTreeWidth();
|
||||
const workspacePanelResizeMinWidth = workspacePanelAriaMinWidth(workspacePanelMinWidth, workspacePanelRenderWidth);
|
||||
const workspacePanelMaxWidth = rightDockDetailActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH;
|
||||
const workspacePanelResizeMaxWidth = workspacePanelAvailableWidth;
|
||||
const sidebarCreation = desktopLayoutStyle === "creation";
|
||||
// Command palette shortcut label (⌘K / Ctrl+K), platform-aware.
|
||||
const commandPaletteShortcut = formatShortcutCombo(
|
||||
resolvedShortcutCombo("commandPalette.open", desktopPlatform),
|
||||
desktopPlatform,
|
||||
);
|
||||
// Dock collapse/expand toggle. Rendered in the dock's own tools row when the
|
||||
// dock is open (its top-right corner), and in the topic bar when closed.
|
||||
const dockToggleButton = (
|
||||
<Tooltip label={effectiveWorkspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>
|
||||
<button
|
||||
className={[
|
||||
"topicbar__chrome-btn",
|
||||
"topicbar__chrome-btn--workspace",
|
||||
effectiveWorkspacePanelRenderable ? "topicbar__chrome-btn--active" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
type="button"
|
||||
onClick={toggleWorkspacePanel}
|
||||
aria-label={effectiveWorkspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}
|
||||
aria-pressed={effectiveWorkspacePanelRenderable}
|
||||
>
|
||||
<PanelRight size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
const topicbarTitle = sidebarImDetailConnection ? t("botDetail.title", { name: sidebarImDetailConnection.title }) : topicDisplayTitle(activeTab);
|
||||
const topicbarWorkspaceLabel = sidebarImDetailConnection ? t("botDetail.subtitle") : activeTab ? tabWorkspaceTitle(activeTab) : "";
|
||||
const topicbarWorkspacePath = activeTab?.scope === "project" ? activeTab.workspaceRoot || state.meta?.cwd : "";
|
||||
@@ -4216,7 +4273,7 @@ export default function App() {
|
||||
? sidebarImDetailConnection.platformLabel
|
||||
: topicbarImSource ? t("msg.fromIm", { source: topicbarImSource.label }) : "";
|
||||
const topicbarImSourcePlatform = sidebarImDetailConnection?.platform ?? topicbarImSource?.platform;
|
||||
const topicbarSubtitleVisible = !sidebarCreation && Boolean(topicbarWorkspaceLabel || topicbarImSourceLabel);
|
||||
const topicbarSubtitleVisible = !sidebarCreation && Boolean(activeTab?.isolatedWorktree || topicbarImSourceLabel);
|
||||
const topicbarSubtitleTitle = sidebarImDetailConnection
|
||||
? [topicbarWorkspaceLabel, topicbarImSourceLabel, sidebarImScopeLabel(sidebarImDetailConnection, t)].filter(Boolean).join(" · ")
|
||||
: [topicbarWorkspacePath || topicbarWorkspaceLabel, topicbarImSourceLabel].filter(Boolean).join(" · ");
|
||||
@@ -4268,6 +4325,7 @@ export default function App() {
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
<ThemeBackground />
|
||||
{sidebarWorkbench && <div className="app__dock-toggle">{dockToggleButton}</div>}
|
||||
<div
|
||||
ref={layoutRef}
|
||||
className={[
|
||||
@@ -4278,7 +4336,7 @@ export default function App() {
|
||||
sidebarImDetailConnection ? "layout--statusbar-hidden" : "",
|
||||
sidebarCollapsed ? "layout--sidebar-collapsed" : "",
|
||||
sidebarResizing ? "layout--resizing layout--sidebar-resizing" : "",
|
||||
workspacePanelGridOpen ? "layout--workspace-open" : "",
|
||||
effectiveWorkspacePanelGridOpen ? "layout--workspace-open" : "",
|
||||
workspacePanelOverlay ? "layout--workspace-overlay" : "",
|
||||
"layout--terminal-drawer-open",
|
||||
terminalPanelOpen ? "layout--terminal-drawer-expanded" : "",
|
||||
@@ -4304,9 +4362,8 @@ export default function App() {
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
sidebarToggleTitle={sidebarToggleTitle}
|
||||
workspacePanelMaximized={workspacePanelMaximized}
|
||||
workspacePanelRenderable={workspacePanelRenderable}
|
||||
workspaceTogglePressed={workspaceTogglePressed}
|
||||
workspacePanelLabel={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}
|
||||
workspacePanelRenderable={effectiveWorkspacePanelRenderable}
|
||||
workspacePanelLabel={effectiveWorkspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onToggleWorkspacePanel={toggleWorkspacePanel}
|
||||
onTabChange={(id) => void handleTabChange(id)}
|
||||
@@ -4401,7 +4458,7 @@ export default function App() {
|
||||
<button
|
||||
className="sidebar-feature-zone__item"
|
||||
type="button"
|
||||
onClick={() => setHeartbeatOpen(true)}
|
||||
onClick={() => setMainView("automation")}
|
||||
>
|
||||
<AlarmClock size={14} aria-hidden="true" />
|
||||
<span>{t("sidebar.automation")}</span>
|
||||
@@ -4454,7 +4511,7 @@ export default function App() {
|
||||
<button
|
||||
className="sidebar__utility-button"
|
||||
type="button"
|
||||
onClick={() => setHeartbeatOpen(true)}
|
||||
onClick={() => setMainView("automation")}
|
||||
>
|
||||
<AlarmClock size={16} aria-hidden="true" />
|
||||
<span className="sr-only">{t("sidebar.automation")}</span>
|
||||
@@ -4507,7 +4564,7 @@ export default function App() {
|
||||
<Tooltip label={t("heartbeat.scheduler")} fill side="right" disabled={sidebarNavTooltipDisabled}>
|
||||
<button
|
||||
className="sidebar__navitem"
|
||||
onClick={() => setHeartbeatOpen(true)}
|
||||
onClick={() => setMainView("automation")}
|
||||
>
|
||||
<AlarmClock size={15} />
|
||||
<span>{t("sidebar.automation")}</span>
|
||||
@@ -4557,6 +4614,13 @@ export default function App() {
|
||||
)}
|
||||
|
||||
<section className={`chat-pane${creationEmptyHero ? " chat-pane--creation-empty" : ""}`}>
|
||||
{mainView === "automation" ? (
|
||||
<Suspense fallback={<div className="heartbeat-page" />}>
|
||||
<HeartbeatView onOpenTopic={(scope, workspaceRoot, topicId) => {
|
||||
void handleOpenTopic(scope, workspaceRoot, topicId);
|
||||
}} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<>
|
||||
<header className="topicbar">
|
||||
{workbenchChromeHidden && (
|
||||
@@ -4588,6 +4652,7 @@ export default function App() {
|
||||
size={sidebarCreation ? topicbarTitleEditSize : undefined}
|
||||
value={topicTitleDraft}
|
||||
onChange={(event) => setTopicTitleDraft(event.target.value)}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
@@ -4601,7 +4666,7 @@ export default function App() {
|
||||
onBlur={() => void commitActiveTopicRename()}
|
||||
/>
|
||||
</div>
|
||||
) : sidebarCreation && topicbarCanRename ? (
|
||||
) : topicbarCanRename ? (
|
||||
<h1 title={topicTitle(activeTab)}>
|
||||
<button
|
||||
className="topicbar__title-button"
|
||||
@@ -4615,23 +4680,14 @@ export default function App() {
|
||||
) : (
|
||||
<h1 title={sidebarImDetailConnection ? topicbarTitle : topicTitle(activeTab)}>{topicbarTitle}</h1>
|
||||
)}
|
||||
{!sidebarCreation && (
|
||||
<Tooltip label={t("topicBar.renameSession")}>
|
||||
<button
|
||||
className="topicbar__icon-btn"
|
||||
type="button"
|
||||
disabled={!topicbarCanRename || topicbarEditing}
|
||||
onClick={startActiveTopicRename}
|
||||
aria-label={t("topicBar.renameSession")}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{topicbarWorkspaceLabel && (
|
||||
<span className="topicbar__workspace-label" title={topicbarWorkspaceLabel}>
|
||||
{topicbarWorkspaceLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{topicbarSubtitleVisible && (
|
||||
<div className="topicbar__subtitle" title={topicbarSubtitleTitle}>
|
||||
{topicbarWorkspaceLabel && <span>{topicbarWorkspaceLabel}</span>}
|
||||
{activeTab?.isolatedWorktree && <WorktreeBadge size={11} />}
|
||||
{topicbarImSourcePlatform && (
|
||||
<span className={`topicbar__source-chip topicbar__source-chip--${topicbarImSourcePlatform}`}>
|
||||
@@ -4643,116 +4699,32 @@ export default function App() {
|
||||
</div>
|
||||
<div className="topicbar__spacer" />
|
||||
<div className="topicbar__actions">
|
||||
{sidebarCreation && shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && (
|
||||
<ExternalOpener key={activeTab.id} tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
|
||||
)}
|
||||
{!sidebarImDetailConnection && (
|
||||
<>
|
||||
<Tooltip label={t("topicBar.copyAll")}>
|
||||
<CopyButton
|
||||
getText={getSessionMarkdown}
|
||||
label={t("topicBar.copyAll")}
|
||||
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
|
||||
showInlineLabel={false}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div className={`topicbar__export${topicExportOpen ? " topicbar__export--open" : ""}`}>
|
||||
<Tooltip label={t("topicBar.export")}>
|
||||
<button
|
||||
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
|
||||
type="button"
|
||||
disabled={!sessionHasContent}
|
||||
aria-label={t("topicBar.export")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={topicExportOpen}
|
||||
onClick={() => setTopicExportOpen((open) => !open)}
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{topicExportOpen && (
|
||||
<div className="topicbar__export-menu" role="menu">
|
||||
<button type="button" role="menuitem" onClick={() => void exportSession("markdown")}>
|
||||
<FileText size={13} />
|
||||
<span>{t("topicBar.exportMarkdown")}</span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" onClick={() => void exportSession("json")}>
|
||||
<FileJson size={13} />
|
||||
<span>{t("topicBar.exportJson")}</span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" onClick={() => void exportSession("pdf")}>
|
||||
<FileDown size={13} />
|
||||
<span>{t("topicBar.exportPdf")}</span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" onClick={() => void exportSession("image")}>
|
||||
<FileImage size={13} />
|
||||
<span>{t("topicBar.exportImage")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!sidebarCreation && (
|
||||
<Tooltip label={t("workspace.changedTab")}>
|
||||
<button
|
||||
className="topicbar__action-btn topicbar__action-btn--label"
|
||||
type="button"
|
||||
aria-label={t("workspace.changedTab")}
|
||||
aria-pressed={workspacePanelRenderable && rightDockMode === "changed"}
|
||||
onClick={() => openRightDockMode("changed")}
|
||||
>
|
||||
<GitBranch size={14} />
|
||||
<span>{t("workspace.changedTab")}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!sidebarImDetailConnection && (
|
||||
<Tooltip label={t("rightDock.terminal")}>
|
||||
<button
|
||||
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
|
||||
type="button"
|
||||
aria-label={t("rightDock.terminal")}
|
||||
aria-pressed={terminalPanelOpen}
|
||||
onPointerEnter={prefetchTerminalPanel} onFocus={prefetchTerminalPanel}
|
||||
onClick={toggleTerminalPanel}
|
||||
>
|
||||
<TerminalSquare size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!sidebarCreation && shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && (
|
||||
<ExternalOpener key={activeTab.id} tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
|
||||
)}
|
||||
<Tooltip label={t("summary.session")}>
|
||||
<Tooltip label={`${t("shortcuts.action.commandPalette")} ${commandPaletteShortcut}`}>
|
||||
<button
|
||||
className={`topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility${tasksOpen ? " topicbar__action-btn--active" : ""}`}
|
||||
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
|
||||
type="button"
|
||||
aria-label={t("summary.session")}
|
||||
aria-expanded={Boolean(tasksOpen)}
|
||||
onClick={() => setTasksOpen((open) => open ? false : "session")}
|
||||
aria-label={t("shortcuts.action.commandPalette")}
|
||||
onClick={() => void openPalette()}
|
||||
>
|
||||
<Activity size={14} />
|
||||
<Search size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{(sidebarCreation || workbenchChromeHidden) && (
|
||||
<Tooltip label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>
|
||||
<button
|
||||
className={[
|
||||
"topicbar__chrome-btn",
|
||||
"topicbar__chrome-btn--workspace",
|
||||
workspacePanelRenderable ? "topicbar__chrome-btn--active" : "",
|
||||
workspaceTogglePressed ? "topicbar__chrome-btn--pressed" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
type="button"
|
||||
onClick={toggleWorkspacePanel}
|
||||
aria-label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}
|
||||
aria-pressed={workspacePanelRenderable}
|
||||
>
|
||||
<PanelRight size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && (
|
||||
<ExternalOpener key={activeTab.id} tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
|
||||
)}
|
||||
{!sidebarImDetailConnection && (
|
||||
<TopicbarMoreMenu
|
||||
sessionHasContent={sessionHasContent}
|
||||
getSessionMarkdown={getSessionMarkdown}
|
||||
exportSession={(format) => void exportSession(format)}
|
||||
openChangedDock={() => openRightDockMode("changed")}
|
||||
toggleTerminal={toggleTerminalPanel}
|
||||
prefetchTerminal={prefetchTerminalPanel}
|
||||
openSessionSummary={() => setTasksOpen((open) => open ? false : "session")}
|
||||
tasksOpen={Boolean(tasksOpen)}
|
||||
/>
|
||||
)}
|
||||
{sidebarCreation && dockToggleButton}
|
||||
{tasksOpen && (
|
||||
<div className="taskmonitor-popover" role="dialog" aria-label={t("summary.session")}>
|
||||
<Suspense fallback={null}>
|
||||
@@ -4853,6 +4825,7 @@ export default function App() {
|
||||
onDeliveryContinue={() => void handleDeliveryContinue()}
|
||||
onAcceptDelivery={() => void app.AcceptDeliveryToTab(activeTabIdRef.current ?? "")}
|
||||
onOpenChanges={() => openRightDockMode("changed")}
|
||||
onOpenVerification={openTurnVerification}
|
||||
onEditPrompt={handleEditPrompt}
|
||||
onRewind={handleMessageAction}
|
||||
checkpoints={state.checkpoints}
|
||||
@@ -4860,6 +4833,7 @@ export default function App() {
|
||||
rewindDisabled={Boolean(activeTab?.readOnly) || !controllerReady || hydratePlaceholderActive || rewindState != null || rewindCommitting || state.running || state.messageAction != null || state.approval != null || state.ask != null || clearContextPending}
|
||||
running={state.running || rewindCommitting}
|
||||
turnStartAt={state.turnStartAt}
|
||||
contentRevision={state.historyLayoutRevision}
|
||||
welcomeVariant={sidebarCreation ? "creation" : "default"}
|
||||
creationMode={sidebarCreation}
|
||||
actionHoverMenus={sidebarCreation && !hydratePlaceholderActive && !runtimeTransitioning}
|
||||
@@ -4867,9 +4841,11 @@ export default function App() {
|
||||
revealSignal={transcriptRevealSignal}
|
||||
hydrating={runtimeTransitioning || transcriptHydrating}
|
||||
hasOlderHistory={!runtimeTransitioning && state.historyHasOlder && !rewindState}
|
||||
olderHistoryCount={state.historyStartTurn}
|
||||
historyStartTurn={state.historyStartTurn}
|
||||
historyTotalTurns={state.historyTotalTurns}
|
||||
loadingOlderHistory={state.historyOlderLoading}
|
||||
onLoadOlderHistory={() => activeTabId && loadOlderHistory(activeTabId)}
|
||||
olderHistoryError={state.historyOlderError}
|
||||
onLoadOlderHistory={handleLoadOlderHistory}
|
||||
invocationMetadata={activeTabId ? invocationMetadataByTab[activeTabId] : undefined}
|
||||
/>
|
||||
{!runtimeTransitioning && state.hydrateError ? <div className="history-load-error" role="alert"><span>{state.hydrateError}</span><button type="button" className="btn btn--small" onClick={() => void retrySessionHistory(activeTabId)}>{t("common.retry")}</button></div> : null}
|
||||
@@ -4986,7 +4962,7 @@ export default function App() {
|
||||
title={t("runtime.workspaceConflictTitle")}
|
||||
badge={t("runtime.workspaceConflictBadge")}
|
||||
meta={workspaceConflict.state === "local"
|
||||
? t("runtime.workspaceConflictLocal", { title: workspaceConflict.ownerTitle || t("runtime.unknownTask") })
|
||||
? t("runtime.workspaceConflictLocal", { title: workspaceConflict.ownerTitle || t("runtime.unknownTask"), label: workspaceConflict.ownerLabel || t("workspace.title") })
|
||||
: t("runtime.workspaceConflictExternal")}
|
||||
note={t("runtime.workspaceConflictNote")}
|
||||
onCancel={() => {
|
||||
@@ -5127,9 +5103,10 @@ export default function App() {
|
||||
</footer>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{workspacePanelGridOpen && (
|
||||
{effectiveWorkspacePanelGridOpen && (
|
||||
<button
|
||||
className="workspace-panel-resizer"
|
||||
type="button"
|
||||
@@ -5137,7 +5114,7 @@ export default function App() {
|
||||
aria-orientation="vertical"
|
||||
aria-label={t("rightDock.resize")}
|
||||
aria-valuemin={workspacePanelResizeMinWidth}
|
||||
aria-valuemax={Math.max(workspacePanelMaxWidth, workspacePanelRenderWidth)}
|
||||
aria-valuemax={Math.max(workspacePanelResizeMaxWidth, workspacePanelRenderWidth)}
|
||||
aria-valuenow={workspacePanelRenderWidth}
|
||||
onPointerDown={startWorkspacePanelResize}
|
||||
onKeyDown={resizeWorkspacePanelWithKeyboard}
|
||||
@@ -5145,7 +5122,7 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{workspacePanelRenderable && (
|
||||
{effectiveWorkspacePanelRenderable && (
|
||||
<aside
|
||||
className={[
|
||||
"workbench-dock",
|
||||
@@ -5229,7 +5206,8 @@ export default function App() {
|
||||
) : (
|
||||
<Suspense fallback={null}>
|
||||
<WorkspacePanel
|
||||
open={workspacePanelRenderable}
|
||||
key={workspaceTreeMemoryKey}
|
||||
open={effectiveWorkspacePanelRenderable}
|
||||
tabId={activeTabId}
|
||||
cwd={state.meta?.cwd}
|
||||
workspaceScopeKey={workspaceScopeKey}
|
||||
@@ -5253,6 +5231,9 @@ export default function App() {
|
||||
onOpenInTerminal={openTerminalForPath}
|
||||
initialViewMode={rightDockMode === "changed" ? "changed" : "files"}
|
||||
completionSummary={state.completionSummary}
|
||||
turnStartAt={state.turnStartAt}
|
||||
verificationRevealRequest={verificationRevealRequest}
|
||||
qualityFloor={composerProfile.qualityFloor}
|
||||
showViewTabs={false}
|
||||
creationMode={sidebarCreation}
|
||||
/>
|
||||
@@ -5433,14 +5414,12 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<HeartbeatPanel open={heartbeatOpen} onClose={() => setHeartbeatOpen(false)} onOpenTopic={(scope, workspaceRoot, topicId) => {
|
||||
void handleOpenTopic(scope, workspaceRoot, topicId);
|
||||
}} />
|
||||
<Suspense fallback={null}><TranscriptSelectionMenu
|
||||
enabled={Boolean(activeTabId && !activeTab?.readOnly && !decisionSurface && !sidebarImDetailConnection && !hydratePlaceholderActive)}
|
||||
resetKey={activeTabId ?? ""}
|
||||
onAddToChat={addSelectedTextToComposer}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<TranscriptSelectionMenu
|
||||
enabled={Boolean(activeTabId && !activeTab?.readOnly && !decisionSurface && !sidebarImDetailConnection && !hydratePlaceholderActive)}
|
||||
resetKey={activeTabId ?? ""}
|
||||
onAddToChat={addSelectedTextToComposer}
|
||||
/>
|
||||
</Suspense>
|
||||
{windowsFramelessChrome && (
|
||||
<WindowsWindowControls
|
||||
|
||||
@@ -279,10 +279,10 @@ ok(
|
||||
|
||||
ok(
|
||||
/const WORKSPACE_PANEL_DEFAULT_OPEN = true;/.test(layoutStoreSource) &&
|
||||
/workspacePanelOpen:\s*loadWorkspacePanelOpen\(\)/.test(layoutStoreSource) &&
|
||||
/export function saveWorkspacePanelOpen\(open: boolean\)/.test(layoutStoreSource) &&
|
||||
/workspacePanelOpen:\s*loadWorkspacePanelOpen\(""\)/.test(layoutStoreSource) &&
|
||||
/export function saveWorkspacePanelOpen\(open: boolean, workspaceRoot = ""\)/.test(layoutStoreSource) &&
|
||||
/reasonix\.workspacePanel\.open/.test(layoutStoreSource),
|
||||
"right dock open state is restored from localStorage with expanded first-launch default",
|
||||
"right dock open state is restored from per-project localStorage with expanded first-launch default",
|
||||
);
|
||||
|
||||
ok(
|
||||
@@ -502,7 +502,7 @@ ok(
|
||||
|
||||
ok(
|
||||
/const transcriptHydrating = state\.hydrating && !state\.hydrateHistoryLoaded;/.test(appSource) &&
|
||||
/hydrating=\{transcriptHydrating\}/.test(appSource),
|
||||
/hydrating=\{runtimeTransitioning \|\| transcriptHydrating\}/.test(appSource),
|
||||
"Welcome is suppressed only until transcript history has loaded",
|
||||
);
|
||||
|
||||
@@ -517,7 +517,7 @@ ok(
|
||||
);
|
||||
|
||||
ok(
|
||||
/if \(heroMode\) \{[\s\S]*?const maxHeight = 96;[\s\S]*?setTextareaAutoHeight/.test(composerSource) &&
|
||||
/if \(heroMode\) \{[\s\S]*?const maxHeight = composerHeroInputMaxHeight\(\);[\s\S]*?setTextareaAutoHeight/.test(composerSource) &&
|
||||
!/if \(heroMode\) \{\s*setTextareaAutoHeight\(20\);/.test(composerSource),
|
||||
"Creation hero composer auto-grows multi-line drafts instead of clipping at 20px",
|
||||
);
|
||||
@@ -565,13 +565,21 @@ ok(
|
||||
"topic, blank, IM, and history navigation all use the shared coalescing path",
|
||||
);
|
||||
|
||||
ok(
|
||||
/const enterChatViewForTabNavigation = useCallback\(\(\) => \{\s*setMainView\("chat"\);/.test(appSource) &&
|
||||
/const enqueueTabSwitch = useCallback\([\s\S]*?enterChatViewForTabNavigation\(\);[\s\S]*?enqueueNavigationRequest/.test(appSource) &&
|
||||
/const revealBackgroundRuntime = useCallback[\s\S]*?enterChatViewForTabNavigation\(\);[\s\S]*?RevealBackgroundRuntime/.test(appSource) &&
|
||||
/const revealWorkspaceWriter = useCallback[\s\S]*?enterChatViewForTabNavigation\(\);[\s\S]*?RevealWorkspaceWriterForTab/.test(appSource),
|
||||
"every direct tab activation returns overlay pages to the chat view",
|
||||
);
|
||||
|
||||
ok(
|
||||
!/await resumeSession\(session\.path, targetTab\.id\);/.test(navigationBlock),
|
||||
"history navigation does not re-resume a session that OpenTopicSession already pinned",
|
||||
);
|
||||
|
||||
ok(
|
||||
/<HeartbeatPanel[\s\S]*onOpenTopic=\{\(scope, workspaceRoot, topicId\) => \{[\s\S]*void handleOpenTopic\(scope, workspaceRoot, topicId\);[\s\S]*\}\}/.test(appSource),
|
||||
/<HeartbeatView[\s\S]*onOpenTopic=\{\(scope, workspaceRoot, topicId\) => \{[\s\S]*void handleOpenTopic\(scope, workspaceRoot, topicId\);[\s\S]*\}\}/.test(appSource),
|
||||
"heartbeat topic navigation uses the guarded open-topic path",
|
||||
);
|
||||
|
||||
@@ -689,8 +697,8 @@ ok(
|
||||
);
|
||||
|
||||
ok(
|
||||
finalDeclaration(":root[data-theme-style] .workbench-dock__tab--active::after", "bottom") === "1px",
|
||||
"active dock underline stays inside the visible dock edge",
|
||||
finalDeclaration(":root[data-theme-style] .workbench-dock__tab--active::after", "display") === "none",
|
||||
"active dock tab underline is removed in favor of the rounded-rect selected state",
|
||||
);
|
||||
|
||||
for (const selector of [
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { botConnectionLabel } from "../components/botConnectionSettings";
|
||||
import type { useT } from "../lib/i18n";
|
||||
import type { BotConnectionView } from "../lib/types";
|
||||
|
||||
const t = ((key: string) => key === "settings.botDingtalk" ? "DingTalk" : key) as ReturnType<typeof useT>;
|
||||
const connection = {
|
||||
provider: "dingtalk",
|
||||
domain: "dingtalk",
|
||||
} as BotConnectionView;
|
||||
|
||||
const label = botConnectionLabel(connection, t);
|
||||
if (label !== "DingTalk") {
|
||||
throw new Error(`DingTalk connection label = ${JSON.stringify(label)}, want \"DingTalk\"`);
|
||||
}
|
||||
|
||||
console.log("bot connection settings: DingTalk label passed");
|
||||
@@ -0,0 +1,27 @@
|
||||
import { completionSummaryChangeNotice } from "../lib/completionSummary";
|
||||
import type { DictKey, Translator } from "../lib/i18n";
|
||||
|
||||
const messages: Partial<Record<DictKey, string>> = {
|
||||
"notice.completionChangesTitle": "Changes this turn",
|
||||
"notice.completionChangesBody": "{count} changes",
|
||||
};
|
||||
|
||||
const translate: Translator = (key, vars) => {
|
||||
const value = messages[key] ?? key;
|
||||
return value.replace(/\{(\w+)\}/g, (_, name: string) => String(vars?.[name] ?? `{${name}}`));
|
||||
};
|
||||
|
||||
const notice = completionSummaryChangeNotice({
|
||||
preset: "balanced",
|
||||
verdict: "complete",
|
||||
mutations: 3,
|
||||
checks_passed: 0,
|
||||
checks_failed: 0,
|
||||
checks_suppressed: 0,
|
||||
review: "passed",
|
||||
constraint_degraded: false,
|
||||
}, translate);
|
||||
|
||||
if (notice.body !== "3 changes" || notice.body.includes("file")) {
|
||||
throw new Error(`mutation receipt notice = ${notice.body}, want receipt-neutral changes wording`);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { createTranscriptHarness } from "./transcript-dom-harness";
|
||||
import type { Item } from "../lib/useController";
|
||||
import type { WireCompletionSummary } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -20,22 +21,51 @@ console.log("\ncompletion summary UI");
|
||||
|
||||
const harness = await createTranscriptHarness();
|
||||
let opens = 0;
|
||||
const earlierSummary = {
|
||||
preset: "balanced",
|
||||
verdict: "partial",
|
||||
mutations: 1,
|
||||
checks_passed: 2,
|
||||
checks_failed: 1,
|
||||
checks_suppressed: 0,
|
||||
review: "passed",
|
||||
} satisfies WireCompletionSummary;
|
||||
const laterSummary = {
|
||||
preset: "balanced",
|
||||
verdict: "partial",
|
||||
mutations: 3,
|
||||
checks_passed: 4,
|
||||
checks_failed: 0,
|
||||
checks_suppressed: 1,
|
||||
review: "passed",
|
||||
} satisfies WireCompletionSummary;
|
||||
type CompletionNotice = Extract<Item, { kind: "notice" }> & { completionSummary: WireCompletionSummary };
|
||||
const completionNotice = (id: string, summary: WireCompletionSummary): CompletionNotice => ({
|
||||
kind: "notice",
|
||||
id,
|
||||
level: "warn",
|
||||
variant: "completion",
|
||||
title: "This turn still needs attention",
|
||||
text: "One or more checks did not pass. Review the changes before continuing.",
|
||||
action: "open_changes",
|
||||
completionSummary: summary,
|
||||
});
|
||||
const items: Item[] = [
|
||||
{ kind: "user", id: "u1", text: "update it" },
|
||||
{ kind: "assistant", id: "a1", text: "Updated.", reasoning: "", streaming: false },
|
||||
{
|
||||
kind: "notice",
|
||||
id: "q1",
|
||||
level: "warn",
|
||||
variant: "completion",
|
||||
title: "This turn still needs attention",
|
||||
text: "One or more checks did not pass. Review the changes before continuing.",
|
||||
action: "open_changes",
|
||||
},
|
||||
completionNotice("q1", earlierSummary),
|
||||
{ kind: "user", id: "u2", text: "update it again" },
|
||||
{ kind: "assistant", id: "a2", text: "Updated again.", reasoning: "", streaming: false },
|
||||
completionNotice("q2", laterSummary),
|
||||
];
|
||||
|
||||
try {
|
||||
await harness.render(items, { running: false, onOpenChanges: () => { opens += 1; } });
|
||||
const verificationOpens: WireCompletionSummary[] = [];
|
||||
await harness.render(items, {
|
||||
running: false,
|
||||
onOpenChanges: () => { opens += 1; },
|
||||
onOpenVerification: (summary: WireCompletionSummary) => { verificationOpens.push(summary); },
|
||||
});
|
||||
ok(harness.container.textContent?.includes("This turn still needs attention"), "actionable summary stays visible outside the process fold");
|
||||
ok(!harness.container.textContent?.includes("balanced"), "compact notice exposes no internal enum values");
|
||||
const button = Array.from(harness.container.querySelectorAll("button")).find((node) => node.textContent?.includes("View changes"));
|
||||
@@ -43,6 +73,13 @@ try {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await harness.flush();
|
||||
ok(opens === 1, "View changes delegates to the workspace panel action");
|
||||
const verifyButtons = Array.from(harness.container.querySelectorAll("button")).filter((node) => node.textContent?.includes("Turn verification"));
|
||||
ok(verifyButtons.length === 2, "each completion notice offers a Turn verification action");
|
||||
verifyButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
verifyButtons[1]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await harness.flush();
|
||||
ok(verificationOpens[0] === earlierSummary, "an older notice opens its own turn summary");
|
||||
ok(verificationOpens[1] === laterSummary, "the latest notice opens its own turn summary");
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Run: tsx src/__tests__/composer-autosize-scrollbar-css.test.ts
|
||||
//
|
||||
// Contract: in autosize mode the composer textarea hides its scrollbar while
|
||||
// the content fits, but once the content exceeds the max height (the JS
|
||||
// inline overflow-y flips to auto and the card gains
|
||||
// `.composer-card--auto-overflow`) the thin scrollbar reappears so long
|
||||
// drafts expose their scrollability (#8494/#8742/#9019). The hero (creation)
|
||||
// input cap is min(30vh, 160px), not the old 96px hard clip.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
// Strip comments so declaration parsing never matches prose inside them.
|
||||
const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
const composerSource = readFileSync(resolve(testDir, "../components/Composer.tsx"), "utf8");
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === b) {
|
||||
ok(true, label);
|
||||
} else {
|
||||
ok(false, `${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function matchingBlocks(selector: string): string[] {
|
||||
const blocks: string[] = [];
|
||||
const rule = /([^{}]+)\{([^{}]*)\}/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = rule.exec(styles)) !== null) {
|
||||
const selectors = match[1].split(",").map((part) => part.trim());
|
||||
if (selectors.includes(selector)) blocks.push(match[2]);
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function finalDeclaration(selector: string, property: string): string | undefined {
|
||||
let value: string | undefined;
|
||||
for (const block of matchingBlocks(selector)) {
|
||||
const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g");
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = declaration.exec(block)) !== null) {
|
||||
value = match[1].trim();
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
console.log("\ncomposer autosize scrollbar css");
|
||||
|
||||
eq(finalDeclaration(".composer__input", "scrollbar-width"), "none", "autosize textarea hides the scrollbar while content fits");
|
||||
eq(finalDeclaration(".composer__input::-webkit-scrollbar", "display"), "none", "autosize textarea hides the webkit scrollbar while content fits");
|
||||
eq(finalDeclaration(".composer-card--auto-overflow .composer__input", "scrollbar-width"), "thin", "overflowing autosize textarea restores the thin scrollbar");
|
||||
eq(finalDeclaration(".composer-card--auto-overflow .composer__input::-webkit-scrollbar", "display"), "block", "overflowing autosize textarea restores the webkit scrollbar");
|
||||
eq(finalDeclaration(".composer-card--auto-overflow .composer__input::-webkit-scrollbar", "width"), "6px", "overflowing autosize scrollbar matches the resized-card width");
|
||||
eq(
|
||||
finalDeclaration(".app--creation .composer-wrap--hero .composer__input", "max-height"),
|
||||
"min(30vh, 160px) !important",
|
||||
"hero input cap is min(30vh, 160px) instead of the 96px hard clip",
|
||||
);
|
||||
|
||||
ok(
|
||||
/composerAutoOverflow = composerHeight === null && textareaAutoOverflow/.test(composerSource)
|
||||
&& /composerAutoOverflow \? " composer-card--auto-overflow" : ""/.test(composerSource),
|
||||
"Composer wires the auto-overflow modifier to the autosize overflow state",
|
||||
);
|
||||
ok(
|
||||
/Math\.min\(Math\.floor\(window\.innerHeight \* 0\.3\), 160\)/.test(composerSource),
|
||||
"hero input JS cap is min(30vh, 160px)",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,68 @@
|
||||
// Run: tsx src/__tests__/composer-content-sizing.test.ts
|
||||
|
||||
import { resolveComposerContentSizing } from "../lib/composerSizing";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\ncomposer content sizing");
|
||||
|
||||
const automatic = resolveComposerContentSizing({
|
||||
contentHeight: 86,
|
||||
manualLogicalHeight: null,
|
||||
maxLogicalHeight: 360,
|
||||
reservedHeight: 58,
|
||||
});
|
||||
eq(automatic.inputHeight, 86, "automatic composer follows its content height");
|
||||
eq(automatic.logicalHeight, null, "automatic composer keeps natural card sizing");
|
||||
eq(automatic.overflow, false, "automatic composer stays overflow-free below the cap");
|
||||
|
||||
const shortManual = resolveComposerContentSizing({
|
||||
contentHeight: 22,
|
||||
manualLogicalHeight: 128,
|
||||
maxLogicalHeight: 360,
|
||||
reservedHeight: 58,
|
||||
});
|
||||
eq(shortManual.inputHeight, 70, "manual height remains the minimum input baseline");
|
||||
eq(shortManual.logicalHeight, 128, "short content does not shrink below the manual baseline");
|
||||
|
||||
const growingManual = resolveComposerContentSizing({
|
||||
contentHeight: 108,
|
||||
manualLogicalHeight: 128,
|
||||
maxLogicalHeight: 360,
|
||||
reservedHeight: 58,
|
||||
});
|
||||
eq(growingManual.inputHeight, 108, "content can grow beyond the manual input baseline");
|
||||
eq(growingManual.logicalHeight, 166, "manual composer grows to reveal the longer draft");
|
||||
eq(growingManual.overflow, false, "growing content remains visible before the cap");
|
||||
|
||||
const cappedManual = resolveComposerContentSizing({
|
||||
contentHeight: 420,
|
||||
manualLogicalHeight: 128,
|
||||
maxLogicalHeight: 360,
|
||||
reservedHeight: 58,
|
||||
});
|
||||
eq(cappedManual.inputHeight, 302, "manual composer input stops at the viewport-aware cap");
|
||||
eq(cappedManual.logicalHeight, 360, "manual composer card stops at the logical maximum");
|
||||
eq(cappedManual.overflow, true, "content scrolls internally only after reaching the cap");
|
||||
|
||||
const shrunkManual = resolveComposerContentSizing({
|
||||
contentHeight: 30,
|
||||
manualLogicalHeight: 128,
|
||||
maxLogicalHeight: 360,
|
||||
reservedHeight: 58,
|
||||
});
|
||||
eq(shrunkManual.logicalHeight, 128, "deleting content returns the composer to its manual baseline");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,320 @@
|
||||
// Run: tsx src/__tests__/composer-ime.test.tsx
|
||||
//
|
||||
// The plain composer textarea must survive CJK IME composition (#8593/#8409).
|
||||
// While a composition is active the textarea renders uncontrolled so no
|
||||
// unrelated re-render (autosize, run-strip ticker, selection tracking) can
|
||||
// write node.value and cancel the in-flight composition; compositionend is
|
||||
// the authoritative resync point, and a programmatic setText landing
|
||||
// mid-composition forces a resync instead of being swallowed by the freeze.
|
||||
//
|
||||
// jsdom note: these suites install the DOM after react-dom has loaded, so
|
||||
// React boots with canUseDOM=false and falls back to its keyup-driven
|
||||
// change-detection polyfill — the input event feeds onInputCapture
|
||||
// (inputType) while the trailing keyup is what delivers onChange. The
|
||||
// composition listeners in the IME guard hook are native, so plain
|
||||
// compositionstart/compositionend Events reach them directly.
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) ok(true, label);
|
||||
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
|
||||
function flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.InputEvent = dom.window.InputEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.File = dom.window.File;
|
||||
globalThis.FileReader = dom.window.FileReader;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
matches: true,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
onchange: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const sent: string[] = [];
|
||||
let currentProps: Parameters<typeof Composer>[0] = {
|
||||
running: false,
|
||||
collaborationMode: "normal" as CollaborationMode,
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
onSend: (text) => {
|
||||
sent.push(text);
|
||||
},
|
||||
onCancel: () => {},
|
||||
onCycleMode: () => {},
|
||||
onSetMode: () => {},
|
||||
onSetCollaborationMode: () => {},
|
||||
onSetToolApprovalMode: () => {},
|
||||
onToggleYoloApprovalMode: () => {},
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
|
||||
currentProps = { ...currentProps, ...nextProps };
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<Composer {...currentProps} />
|
||||
</ToastProvider>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
};
|
||||
await paint();
|
||||
return { root, sent, rerender: paint };
|
||||
}
|
||||
|
||||
// React's value tracker only reports a change (and fires onChange) when the
|
||||
// DOM value moved outside its patched setter, so IME mutations must go
|
||||
// through the native prototype setter — exactly how the browser itself
|
||||
// applies provisional and committed composition text.
|
||||
function setNativeValue(ta: HTMLTextAreaElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
|
||||
if (!setter) throw new Error("native textarea value setter unavailable");
|
||||
setter.call(ta, value);
|
||||
}
|
||||
|
||||
function dispatchInput(ta: HTMLTextAreaElement, data: string, inputType: string, isComposing: boolean) {
|
||||
ta.dispatchEvent(new window.InputEvent("input", {
|
||||
bubbles: true,
|
||||
data,
|
||||
inputType,
|
||||
isComposing,
|
||||
}));
|
||||
// The change-detection polyfill delivers onChange on keyup.
|
||||
ta.dispatchEvent(new window.KeyboardEvent("keyup", { key: "Process", bubbles: true }));
|
||||
}
|
||||
|
||||
function pressEnter(ta: HTMLTextAreaElement) {
|
||||
ta.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
function composerTextarea(): HTMLTextAreaElement {
|
||||
const ta = document.querySelector<HTMLTextAreaElement>("#composer-input");
|
||||
if (!ta) throw new Error("plain composer textarea did not render");
|
||||
return ta;
|
||||
}
|
||||
|
||||
console.log("\ncomposer plain textarea IME freeze");
|
||||
|
||||
// compositionstart → provisional DOM text → unrelated re-render → the
|
||||
// provisional text must survive (pre-fix, React's updateTextarea compares
|
||||
// the controlled value against the live DOM value on every commit and
|
||||
// writes the stale state back, cancelling the composition — the swallowed
|
||||
// first keystroke). Commit then resyncs exactly once and leaves a sane caret.
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, sent, rerender } = await renderComposer();
|
||||
const ta = composerTextarea();
|
||||
|
||||
await act(async () => {
|
||||
ta.focus();
|
||||
ta.dispatchEvent(new Event("compositionstart", { bubbles: true }));
|
||||
// WebView2 ordering: the provisional character is already in the DOM
|
||||
// before the first input event reaches React.
|
||||
setNativeValue(ta, "n");
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
// An unrelated re-render (parent prop change, run-strip tick) lands while
|
||||
// the composition is in flight and no input event has fired yet.
|
||||
await rerender({ modelLabel: "DeepSeek-V4" });
|
||||
eq(ta.value, "n", "unrelated re-render mid-composition keeps the provisional text");
|
||||
|
||||
await act(async () => {
|
||||
setNativeValue(ta, "ni");
|
||||
dispatchInput(ta, "i", "insertCompositionText", true);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(ta.value, "ni", "composition input keeps flowing without a DOM clobber");
|
||||
|
||||
await act(async () => {
|
||||
pressEnter(ta);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(sent.length, 0, "Enter during composition does not send");
|
||||
|
||||
await act(async () => {
|
||||
// Commit: the IME replaces the provisional run, fires the commit input
|
||||
// (Chromium fires it before compositionend) and then compositionend.
|
||||
setNativeValue(ta, "你");
|
||||
ta.setSelectionRange(1, 1);
|
||||
dispatchInput(ta, "你", "insertCompositionText", false);
|
||||
ta.dispatchEvent(new Event("compositionend", { bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(ta.value, "你", "committed IME text is intact after compositionend");
|
||||
eq(ta.selectionStart, 1, "caret sits after the committed character");
|
||||
|
||||
await act(async () => {
|
||||
pressEnter(ta);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(sent.length, 0, "Enter inside the post-composition grace window does not send");
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
pressEnter(ta);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(sent.length, 1, "Enter after the grace window sends");
|
||||
eq(sent[0], "你", "the sent text is the committed IME text");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// IME cancel: compositionstart, provisional text, then compositionend with
|
||||
// the DOM restored to the pre-composition value (no commit input). State
|
||||
// must follow the authoritative DOM, and typing afterwards must keep working.
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root } = await renderComposer();
|
||||
const ta = composerTextarea();
|
||||
|
||||
await act(async () => {
|
||||
ta.focus();
|
||||
ta.dispatchEvent(new Event("compositionstart", { bubbles: true }));
|
||||
setNativeValue(ta, "tmp");
|
||||
dispatchInput(ta, "p", "insertCompositionText", true);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(ta.value, "tmp", "provisional text is live during composition");
|
||||
|
||||
await act(async () => {
|
||||
setNativeValue(ta, "");
|
||||
ta.dispatchEvent(new Event("compositionend", { bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(ta.value, "", "IME cancel restores the pre-composition text");
|
||||
|
||||
await act(async () => {
|
||||
setNativeValue(ta, "x");
|
||||
ta.setSelectionRange(1, 1);
|
||||
dispatchInput(ta, "x", "insertText", false);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(ta.value, "x", "typing after an IME cancel keeps working");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Programmatic setText mid-composition (history recall, menu inserts, draft
|
||||
// switches) bypasses the textarea's onChange, so it must force an
|
||||
// authoritative resync through the freeze instead of being swallowed.
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, sent, rerender } = await renderComposer();
|
||||
const ta = composerTextarea();
|
||||
|
||||
await act(async () => {
|
||||
ta.focus();
|
||||
ta.dispatchEvent(new Event("compositionstart", { bubbles: true }));
|
||||
setNativeValue(ta, "n");
|
||||
ta.setSelectionRange(1, 1);
|
||||
dispatchInput(ta, "n", "insertCompositionText", true);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(ta.value, "n", "composition is in flight before the programmatic insert");
|
||||
|
||||
await rerender({ insertRequest: { id: 7, text: "snippet body" } });
|
||||
eq(
|
||||
ta.value,
|
||||
"n\n\nsnippet body\n",
|
||||
"programmatic insert forces a resync and reaches the DOM mid-composition",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
pressEnter(ta);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(sent.length, 1, "the programmatic resync ends the freeze so Enter sends");
|
||||
ok(sent[0]?.includes("snippet body") === true, "the sent text carries the programmatic insert");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,10 +1,9 @@
|
||||
// Run: tsx src/__tests__/composer-resized-input-css.test.ts
|
||||
//
|
||||
// Contract: in the user-resized composer (`.composer-card--resized`), the
|
||||
// input fills the fixed card height and scrolls internally. JS applies no
|
||||
// inline height in resized mode, so `height: auto` would collapse the
|
||||
// rows=1 textarea to a single visible line forever (regression shipped in
|
||||
// desktop-v1.17.11: typing multiline text kept the input one line tall).
|
||||
// input fills the available card height and can scroll internally. These CSS
|
||||
// fallbacks remain authoritative during a live resize, when JS temporarily
|
||||
// removes the content-derived inline height.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
@@ -196,9 +196,8 @@ console.log("\ncomposer run strip");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Execution modes are gone. Composer keeps collaboration (direct/plan/goal)
|
||||
// and tool-approval controls; it must not render a Light/Balanced/Delivery
|
||||
// execution-setting trigger or menu.
|
||||
// Execution modes are gone. Composer keeps collaboration, tool approval, and
|
||||
// the independent quality floor, but no execution-setting trigger or menu.
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root } = await renderComposer();
|
||||
@@ -207,7 +206,6 @@ console.log("\ncomposer run strip");
|
||||
eq(document.querySelector(".composer-profile-menu"), null, "composer has no execution-setting menu");
|
||||
const chrome = document.body.textContent ?? "";
|
||||
eq(chrome.includes("Execution setting"), false, "composer chrome does not mention execution setting");
|
||||
eq(chrome.includes("Delivery"), false, "composer chrome does not mention Delivery mode");
|
||||
|
||||
const intentTrigger = document.querySelector(".composer-task-mode-trigger") as HTMLButtonElement | null;
|
||||
if (!intentTrigger) throw new Error("task intent trigger did not render");
|
||||
@@ -514,8 +512,8 @@ console.log("\ncomposer run strip");
|
||||
|
||||
// Resize consistency: --composer-height always carries the logical height in
|
||||
// every writer (React render, live drag, keyboard), with the run strip's
|
||||
// reservation isolated in a CSS calc — so dragging a resized composer during a
|
||||
// running turn cannot flash-shrink the card.
|
||||
// reservation isolated in a CSS calc. A manual height is the draft's minimum,
|
||||
// so content can grow above it without changing the saved resize baseline.
|
||||
{
|
||||
const stylesSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../styles.css"), "utf8");
|
||||
ok(
|
||||
@@ -557,6 +555,48 @@ console.log("\ncomposer run strip");
|
||||
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "0px", "idle card releases the strip reservation");
|
||||
eq(card.style.getPropertyValue("--composer-height"), "124px", "idle card keeps the user's logical height");
|
||||
|
||||
const textarea = document.querySelector(".composer__input") as HTMLTextAreaElement;
|
||||
let measuredDraftHeight = 108;
|
||||
Object.defineProperty(textarea, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => measuredDraftHeight,
|
||||
});
|
||||
const updateDraft = async (value: string) => {
|
||||
await act(async () => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
const paste = new window.Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", {
|
||||
configurable: true,
|
||||
value: {
|
||||
files: [],
|
||||
items: [],
|
||||
types: ["text/plain"],
|
||||
getData: (kind: string) => (kind === "text" || kind === "text/plain" ? value : ""),
|
||||
},
|
||||
});
|
||||
textarea.dispatchEvent(paste);
|
||||
await flushTimers();
|
||||
});
|
||||
};
|
||||
|
||||
await updateDraft("a longer pasted draft");
|
||||
eq(card.style.getPropertyValue("--composer-height"), "166px", "longer draft grows above the manual baseline");
|
||||
eq(textarea.style.height, "108px", "content-derived input height reveals the longer draft");
|
||||
eq(textarea.style.overflowY, "hidden", "draft stays scrollbar-free below the cap");
|
||||
|
||||
measuredDraftHeight = 22;
|
||||
await updateDraft("short");
|
||||
eq(card.style.getPropertyValue("--composer-height"), "124px", "shorter draft returns to the manual baseline");
|
||||
eq(textarea.style.height, "66px", "manual baseline remains available to short drafts");
|
||||
|
||||
measuredDraftHeight = 420;
|
||||
await updateDraft("an oversized pasted draft");
|
||||
const viewportCap = Math.min(360, Math.floor(window.innerHeight * 0.4));
|
||||
eq(card.style.getPropertyValue("--composer-height"), `${viewportCap}px`, "oversized draft stops at the viewport-aware cap");
|
||||
eq(textarea.style.height, `${viewportCap - 58}px`, "oversized input uses the capped content viewport");
|
||||
eq(textarea.style.overflowY, "auto", "oversized draft scrolls only after reaching the cap");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { act } from "react";
|
||||
import { getTranscriptStore } from "../lib/transcriptStore";
|
||||
import type { HistorySlice } from "../lib/types";
|
||||
import { historySliceFromMessages } from "./mockHistorySlice";
|
||||
|
||||
type DeferredHistory = {
|
||||
promise: Promise<HistorySlice>;
|
||||
resolve: (value: HistorySlice) => void;
|
||||
};
|
||||
|
||||
type OlderHistoryState = {
|
||||
items: Array<{ kind: string; text?: string }>;
|
||||
historyOlderLoading: boolean;
|
||||
historyOlderError?: string;
|
||||
};
|
||||
|
||||
export async function verifyStaleHistoryFingerprint({
|
||||
olderPage,
|
||||
loadOlderHistory,
|
||||
historyCalls,
|
||||
waitFor,
|
||||
flushPromises,
|
||||
equal,
|
||||
getState,
|
||||
}: {
|
||||
olderPage: DeferredHistory;
|
||||
loadOlderHistory: () => Promise<boolean> | undefined;
|
||||
historyCalls: () => number;
|
||||
waitFor: (label: string, predicate: () => boolean) => Promise<void>;
|
||||
flushPromises: () => Promise<void>;
|
||||
equal: (actual: unknown, expected: unknown, label: string) => void;
|
||||
getState: () => OlderHistoryState | undefined;
|
||||
}) {
|
||||
let olderLoad: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
olderLoad = loadOlderHistory();
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("tab-l older page request", () => historyCalls() === 2);
|
||||
olderPage.resolve({
|
||||
...historySliceFromMessages(
|
||||
"tab-l",
|
||||
[{ role: "user", content: "stale older L" }],
|
||||
{ cursor: "", turns: 12 },
|
||||
{ revision: 1, digest: "digest-l-v1" },
|
||||
),
|
||||
hasOlder: true,
|
||||
nextCursor: btoa(JSON.stringify({ v: 1, before: 2 })),
|
||||
});
|
||||
await act(async () => {
|
||||
await olderLoad;
|
||||
await flushPromises();
|
||||
});
|
||||
const state = getState();
|
||||
equal(state?.items.some((item) => item.kind === "user" && item.text === "stale older L") ?? false, false, "stale older page is discarded after session fingerprint changes");
|
||||
equal(state?.historyOlderLoading, false, "stale older page releases its loading state");
|
||||
equal(state?.historyOlderError, "history identity changed", "stale older page enters the explicit retry state instead of silently auto-retrying");
|
||||
}
|
||||
|
||||
export async function verifyDeferredHistoryCloseRace({
|
||||
olderPage,
|
||||
loadOlderHistory,
|
||||
closeTab,
|
||||
historyCalls,
|
||||
waitFor,
|
||||
flushPromises,
|
||||
equal,
|
||||
sessionPath,
|
||||
}: {
|
||||
olderPage: DeferredHistory;
|
||||
loadOlderHistory: () => Promise<boolean> | undefined;
|
||||
closeTab: () => Promise<boolean>;
|
||||
historyCalls: () => number;
|
||||
waitFor: (label: string, predicate: () => boolean) => Promise<void>;
|
||||
flushPromises: () => Promise<void>;
|
||||
equal: (actual: unknown, expected: unknown, label: string) => void;
|
||||
sessionPath: string;
|
||||
}) {
|
||||
let closingOlderLoad: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
closingOlderLoad = loadOlderHistory();
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("tab-l closing older page request", () => historyCalls() === 3);
|
||||
|
||||
const transcriptStore = getTranscriptStore();
|
||||
const originalSetPinned = transcriptStore.setPinned.bind(transcriptStore);
|
||||
let releasedTabStateCommits = 0;
|
||||
transcriptStore.setPinned = (tabId: string, pinned: boolean) => {
|
||||
if (tabId === "tab-l") releasedTabStateCommits += 1;
|
||||
originalSetPinned(tabId, pinned);
|
||||
};
|
||||
let tabClosed = false;
|
||||
await act(async () => {
|
||||
tabClosed = await closeTab();
|
||||
await flushPromises();
|
||||
});
|
||||
equal(tabClosed, true, "tab-l closes while its older page is deferred");
|
||||
|
||||
olderPage.resolve(historySliceFromMessages(
|
||||
"tab-l",
|
||||
[{ role: "user", content: "late page after close" }],
|
||||
{ cursor: "", turns: 12 },
|
||||
{ revision: 2, digest: "digest-l-v2" },
|
||||
));
|
||||
await act(async () => {
|
||||
await closingOlderLoad;
|
||||
await flushPromises();
|
||||
});
|
||||
transcriptStore.setPinned = originalSetPinned;
|
||||
equal(releasedTabStateCommits, 0, "late older-page completion cannot recreate the released tab state");
|
||||
equal(transcriptStore.isResident("tab-l", sessionPath), false, "late older-page completion cannot restore the evicted transcript");
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { en } from "../locales/en";
|
||||
import { zh } from "../locales/zh";
|
||||
import { zhTW } from "../locales/zh-TW";
|
||||
|
||||
assert.equal(en["notice.deliveryIncompleteTitle"], "Task is not complete");
|
||||
assert.match(en["notice.deliveryIncompleteBody"], /task work or checks that are incomplete/);
|
||||
assert.equal(en["notice.deliveryIncompleteContinue"], "Continue checks");
|
||||
assert.equal(en["notice.deliveryRequirementTask"], "remaining implementation work");
|
||||
|
||||
assert.equal(zh["notice.deliveryIncompleteTitle"], "任务尚未完成");
|
||||
assert.match(zh["notice.deliveryIncompleteBody"], /任务或检查未完成/);
|
||||
assert.equal(zh["notice.deliveryIncompleteContinue"], "继续检查");
|
||||
assert.equal(zh["notice.deliveryRequirementTask"], "剩余实施工作");
|
||||
|
||||
assert.equal(zhTW["notice.deliveryIncompleteTitle"], "任務尚未完成");
|
||||
assert.match(zhTW["notice.deliveryIncompleteBody"], /任務或檢查未完成/);
|
||||
assert.equal(zhTW["notice.deliveryIncompleteContinue"], "繼續檢查");
|
||||
assert.equal(zhTW["notice.deliveryRequirementTask"], "剩餘實作工作");
|
||||
|
||||
console.log(" PASS final readiness recovery copy is aligned across locales");
|
||||
@@ -76,7 +76,7 @@ eq(finalDeclaration(".slashmenu", "max-height"), "min(360px, var(--composer-menu
|
||||
|
||||
// AC4 — App.tsx applies footer--decision exactly when a decision surface owns
|
||||
// the footer, and the pre-existing footer--compact wiring stays intact.
|
||||
eq(/decisionSurface \? "footer--decision" : ""/.test(appSource), true, "App.tsx toggles footer--decision on decisionSurface");
|
||||
eq(/visibleDecisionSurface \? "footer--decision" : ""/.test(appSource), true, "App.tsx toggles footer--decision on the visible decision surface");
|
||||
eq(/terminalPanelOpen && !sidebarCreation \? "footer--compact" : ""/.test(appSource), true, "footer--compact wiring stays intact");
|
||||
eq(/ref=\{composerWrapRef\}/.test(composerSource), true, "Composer measures the real menu anchor");
|
||||
eq(/observeComposerMenuViewport\(anchor\)/.test(composerSource), true, "Composer observes geometry while a menu is open");
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// Run: node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/heartbeat-editor.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { HeartbeatView, TaskEditor } from "../custom/features/heartbeat/HeartbeatPanel";
|
||||
import type { HeartbeatTask } from "../custom/features/heartbeat/heartbeat.types";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: unknown, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function flush(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function button(label: string): HTMLButtonElement | undefined {
|
||||
return Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.textContent?.trim() === label);
|
||||
}
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
|
||||
globalThis.HTMLInputElement = dom.window.HTMLInputElement;
|
||||
globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.ResizeObserver = NoopResizeObserver as unknown as typeof ResizeObserver;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
let nextID = 0;
|
||||
let savedUpdate: { tasks?: HeartbeatTask[] } | null = null;
|
||||
let backendTasks: HeartbeatTask[] = [];
|
||||
let saveShouldFail = false;
|
||||
Object.assign(window, {
|
||||
go: {
|
||||
main: {
|
||||
App: {
|
||||
async HeartbeatReloadConfig() { return { revision: 1, etag: "test", tasks: backendTasks }; },
|
||||
async HeartbeatSaveConfig(update: { tasks?: HeartbeatTask[] }) {
|
||||
if (saveShouldFail) throw new Error("conflict");
|
||||
savedUpdate = update;
|
||||
backendTasks = update.tasks ?? [];
|
||||
return { revision: 2, etag: "saved", tasks: backendTasks };
|
||||
},
|
||||
async HeartbeatTriggerNow() {},
|
||||
async HeartbeatGenerateID() { nextID += 1; return `draft-${nextID}`; },
|
||||
async ListWorkspaces() { return [{ name: "Project One", path: "/project-one", current: true }]; },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("missing root");
|
||||
const root = createRoot(rootElement);
|
||||
const noopDelete = async () => true;
|
||||
|
||||
function renderEditor(task: HeartbeatTask, onSave: (task: HeartbeatTask) => Promise<boolean>, key: string) {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<TaskEditor
|
||||
key={key}
|
||||
task={task}
|
||||
onSave={onSave}
|
||||
onDelete={noopDelete}
|
||||
onCloseDetail={() => {}}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\nheartbeat editor state ownership");
|
||||
|
||||
const originalTask: HeartbeatTask = {
|
||||
id: "existing",
|
||||
title: "Saved title",
|
||||
prompt: "Saved prompt",
|
||||
interval: "30m",
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
topicId: "old-topic",
|
||||
lastRunAt: 100,
|
||||
};
|
||||
let submitted: HeartbeatTask | null = null;
|
||||
await act(async () => {
|
||||
renderEditor(originalTask, async (task) => { submitted = task; return true; }, "run-state");
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
button("Daily")?.click();
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
renderEditor({
|
||||
...originalTask,
|
||||
topicId: "fresh-topic",
|
||||
lastRunAt: 200,
|
||||
runHistory: [{ at: 200, topicId: "fresh-topic" }],
|
||||
}, async (task) => { submitted = task; return true; }, "run-state");
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
button("Save")?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(submitted?.interval.startsWith("24h|daily") === true, "trigger completion preserves the user's edited schedule");
|
||||
ok(submitted?.topicId === "fresh-topic" && submitted.lastRunAt === 200, "save carries the latest engine-owned run state");
|
||||
ok(submitted?.runHistory?.[0]?.topicId === "fresh-topic", "save carries run history added while the editor was open");
|
||||
|
||||
console.log("\nheartbeat editor failed save");
|
||||
|
||||
let resolveSave: ((saved: boolean) => void) | undefined;
|
||||
const rejectedSave = () => new Promise<boolean>((resolve) => { resolveSave = resolve; });
|
||||
await act(async () => {
|
||||
renderEditor({ ...originalTask, id: "conflict" }, rejectedSave, "conflict");
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
button("Weekly")?.click();
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
button("Save")?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(button("Save")?.disabled === true, "save stays pending until persistence resolves");
|
||||
await act(async () => {
|
||||
resolveSave?.(false);
|
||||
await flush();
|
||||
});
|
||||
ok(button("Weekly")?.classList.contains("set-seg__btn--on") === true, "failed save preserves the local draft");
|
||||
ok(document.querySelector('[role="alert"]')?.textContent?.includes("Your draft is still here") === true, "failed save reports an actionable error");
|
||||
ok(button("Save") != null, "failed save remains dirty and retryable");
|
||||
|
||||
console.log("\nheartbeat editor frequency conversion");
|
||||
|
||||
await act(async () => {
|
||||
renderEditor({ ...originalTask, id: "weekly-cron", interval: "0 9 * * 1" }, async () => true, "weekly-cron");
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
button("Interval")?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(button("Custom")?.classList.contains("set-seg__btn--on") === true, "lossy cron conversion keeps the Custom frequency selected");
|
||||
ok(document.querySelector<HTMLInputElement>('.heartbeat-editor__freq-input--cron')?.value === "0 9 * * 1", "lossy conversion keeps the original cron expression");
|
||||
ok(document.querySelector('.heartbeat-editor__inline-error')?.textContent?.includes("cannot be converted") === true, "lossy conversion explains why the editor did not switch");
|
||||
|
||||
console.log("\nheartbeat recommendation draft");
|
||||
|
||||
await act(async () => {
|
||||
root.render(<LocaleProvider><HeartbeatView /></LocaleProvider>);
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
document.querySelector<HTMLButtonElement>(".heartbeat-suggestion")?.click();
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
ok(document.querySelector<HTMLInputElement>('[aria-label="Title"]')?.value === "Daily review", "recommendation keeps its prefilled editor open");
|
||||
ok(document.body.textContent?.includes("Select a task to view details") !== true, "recommendation is not cleared by the missing-task cleanup effect");
|
||||
ok(button("Ask")?.classList.contains("set-seg__btn--on") === true, "recommendation defaults to ask approval");
|
||||
ok(button("Global") != null, "recommendation remains a new draft with editable scope");
|
||||
await act(async () => {
|
||||
button("Save")?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(savedUpdate?.tasks?.[0]?.enabled === false, "recommendation stays disabled until the user explicitly enables it");
|
||||
ok(savedUpdate?.tasks?.[0]?.approvalMode === "ask", "recommendation persists the safe ask approval default");
|
||||
|
||||
saveShouldFail = true;
|
||||
await act(async () => {
|
||||
const productSuggestion = Array.from(document.querySelectorAll<HTMLButtonElement>(".heartbeat-suggestion"))
|
||||
.find((item) => item.textContent?.includes("Product update"));
|
||||
productSuggestion?.click();
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
button("Save")?.click();
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
ok(document.querySelector<HTMLInputElement>('[aria-label="Title"]')?.value === "Product update digest", "parent save conflict keeps the unsaved recommendation draft open");
|
||||
ok(document.querySelector('.heartbeat-editor__save-error')?.textContent?.includes("Your draft is still here") === true, "parent save conflict is reported instead of marking the draft clean");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { heartbeatFeatureKeys } from "../custom/features/heartbeat/heartbeat.i18n";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const globalLocales = ["en.ts", "zh.ts", "zh-TW.ts"].map((name) =>
|
||||
readFileSync(resolve(testDir, "../locales", name), "utf8"),
|
||||
);
|
||||
|
||||
assert.ok(heartbeatFeatureKeys.length > 0, "Heartbeat owns feature-local translations");
|
||||
for (const key of heartbeatFeatureKeys) {
|
||||
for (const source of globalLocales) {
|
||||
assert.ok(!source.includes(`"${key}"`), `${key} stays out of the global locale chunks`);
|
||||
}
|
||||
}
|
||||
|
||||
const panel = readFileSync(resolve(testDir, "../custom/features/heartbeat/HeartbeatPanel.tsx"), "utf8");
|
||||
assert.match(panel, /const t = useHeartbeatT\(\)/, "the lazy Heartbeat page resolves its feature-local translations");
|
||||
|
||||
console.log(` PASS ${heartbeatFeatureKeys.length} Heartbeat translations stay feature-local`);
|
||||
@@ -0,0 +1,81 @@
|
||||
// Run: tsx src/__tests__/heartbeat-mutation-queue.test.ts
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { HeartbeatTask } from "../custom/features/heartbeat/heartbeat.types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: unknown, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function flush(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost/" });
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
|
||||
type ConfigUpdate = { revision: number; etag: string; tasks: HeartbeatTask[] };
|
||||
type SaveCall = ConfigUpdate & { resolve: (view: ConfigUpdate) => void };
|
||||
|
||||
const initial: HeartbeatTask[] = [
|
||||
{ id: "a", title: "A", prompt: "a", interval: "1h", enabled: true },
|
||||
{ id: "b", title: "B", prompt: "b", interval: "1h", enabled: true },
|
||||
];
|
||||
const saveCalls: SaveCall[] = [];
|
||||
|
||||
Object.assign(window, {
|
||||
go: {
|
||||
main: {
|
||||
App: {
|
||||
async HeartbeatReloadConfig() {
|
||||
return { revision: 1, etag: "etag-1", tasks: initial };
|
||||
},
|
||||
HeartbeatSaveConfig(update: ConfigUpdate) {
|
||||
return new Promise<ConfigUpdate>((resolve) => saveCalls.push({ ...update, resolve }));
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { heartbeatListTasks, heartbeatMutateTasks } = await import("../custom/features/heartbeat/heartbeat.bridge");
|
||||
|
||||
console.log("\nheartbeat mutation queue");
|
||||
|
||||
await heartbeatListTasks();
|
||||
const toggle = heartbeatMutateTasks((tasks) => tasks.map((task) => (
|
||||
task.id === "a" ? { ...task, enabled: !task.enabled } : task
|
||||
)));
|
||||
const remove = heartbeatMutateTasks((tasks) => tasks.filter((task) => task.id !== "b"));
|
||||
await flush();
|
||||
|
||||
ok(saveCalls.length === 1, "only one full-table mutation is in flight");
|
||||
ok(saveCalls[0]?.revision === 1 && saveCalls[0]?.etag === "etag-1", "first mutation uses the loaded config token");
|
||||
const firstTasks = saveCalls[0]?.tasks ?? [];
|
||||
saveCalls[0]?.resolve({ revision: 2, etag: "etag-2", tasks: firstTasks });
|
||||
await flush();
|
||||
|
||||
ok(saveCalls.length === 2, "second mutation starts after the first save settles");
|
||||
ok(saveCalls[1]?.revision === 2 && saveCalls[1]?.etag === "etag-2", "second mutation uses the new config token");
|
||||
ok(saveCalls[1]?.tasks.length === 1 && saveCalls[1]?.tasks[0]?.id === "a", "second mutation rebases deletion onto the saved task list");
|
||||
ok(saveCalls[1]?.tasks[0]?.enabled === false, "rebased deletion preserves the earlier toggle");
|
||||
const secondTasks = saveCalls[1]?.tasks ?? [];
|
||||
saveCalls[1]?.resolve({ revision: 3, etag: "etag-3", tasks: secondTasks });
|
||||
|
||||
const [afterToggle, afterDelete] = await Promise.all([toggle, remove]);
|
||||
ok(afterToggle.find((task) => task.id === "a")?.enabled === false, "first caller receives its authoritative save");
|
||||
ok(afterDelete.length === 1 && afterDelete[0]?.id === "a", "second caller receives the combined authoritative state");
|
||||
|
||||
dom.window.close();
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,11 +1,23 @@
|
||||
// Run: tsx src/__tests__/heartbeat-next-run.test.ts
|
||||
|
||||
import { heartbeatBuildCycleInterval, heartbeatNextRunAt } from "../custom/features/heartbeat/HeartbeatPanel";
|
||||
import { changeHeartbeatFrequency, cronToInterval, heartbeatBuildCycleInterval, heartbeatNextRunAt, intervalToCron, mergeEngineRunState, nextCycleRunAt, prepareTasksByNextRun } from "../custom/features/heartbeat/HeartbeatPanel";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null && !Array.isArray(a) && !Array.isArray(b)) {
|
||||
const ak = JSON.stringify(a, Object.keys(a as object).sort());
|
||||
const bk = JSON.stringify(b, Object.keys(b as object).sort());
|
||||
if (ak === bk) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${bk}, got ${ak}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (a === b) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
@@ -87,5 +99,329 @@ eq(
|
||||
"weekly default uses one weekday",
|
||||
);
|
||||
|
||||
eq(
|
||||
heartbeatNextRunAt(
|
||||
{ interval: "0 9 * * 1-5", lastRunAt: 0 },
|
||||
localMs(2026, 6, 15, 10, 0),
|
||||
),
|
||||
localMs(2026, 6, 16, 9, 0),
|
||||
"cron weekday schedule computes next business-day run",
|
||||
);
|
||||
|
||||
eq(
|
||||
heartbeatNextRunAt(
|
||||
{ interval: "*/15 * * * *", lastRunAt: 0 },
|
||||
localMs(2026, 6, 18, 10, 7),
|
||||
),
|
||||
localMs(2026, 6, 18, 10, 15),
|
||||
"cron every-15-minutes computes next slot",
|
||||
);
|
||||
|
||||
eq(
|
||||
heartbeatNextRunAt(
|
||||
{ interval: "0 9 * * 1-5", lastRunAt: localMs(2026, 6, 15, 9, 0) },
|
||||
localMs(2026, 6, 15, 9, 0),
|
||||
),
|
||||
localMs(2026, 6, 16, 9, 0),
|
||||
"cron next run ignores lastRunAt and uses wall clock",
|
||||
);
|
||||
|
||||
console.log("\nengine-run merge (SivanCola review: trigger must not clobber draft)");
|
||||
|
||||
// 立即运行返回后:磁盘旧快照只贡献 runHistory/topicId/lastRunAt,等待期间的
|
||||
// 草稿编辑(title/prompt/interval)必须保留
|
||||
eq(
|
||||
mergeEngineRunState(
|
||||
{ id: "t1", title: "草稿新标题", prompt: "草稿新 prompt", interval: "30m", enabled: true, scope: "global", lastRunAt: 100 },
|
||||
{ id: "t1", title: "旧标题", prompt: "旧 prompt", interval: "1h", enabled: true, scope: "global", runHistory: [{ at: 200, topicId: "topic-2" }], topicId: "topic-2", lastRunAt: 200 },
|
||||
),
|
||||
{ id: "t1", title: "草稿新标题", prompt: "草稿新 prompt", interval: "30m", enabled: true, scope: "global", runHistory: [{ at: 200, topicId: "topic-2" }], topicId: "topic-2", lastRunAt: 200 },
|
||||
"trigger merge keeps draft title while adopting engine run fields",
|
||||
);
|
||||
// 没有 runHistory 的旧磁盘快照不得清掉草稿中已有 runHistory
|
||||
eq(
|
||||
mergeEngineRunState(
|
||||
{ id: "t1", title: "草稿标题", prompt: "p", interval: "30m", enabled: true, runHistory: [{ at: 50, topicId: "t" }], lastRunAt: 100 },
|
||||
{ id: "t1", title: "旧标题", prompt: "旧", interval: "1h", enabled: true, lastRunAt: 110 },
|
||||
),
|
||||
{ id: "t1", title: "草稿标题", prompt: "p", interval: "30m", enabled: true, runHistory: [{ at: 50, topicId: "t" }], lastRunAt: 110 },
|
||||
"trigger merge adopts fresh lastRunAt without dropping draft runHistory",
|
||||
);
|
||||
|
||||
// ── 周期转 cron / 周期 next-run 语义(SivanCola review 回归) ──
|
||||
|
||||
console.log("cycle → cron conversion guards");
|
||||
|
||||
// biweekly 无法无损转 cron → null
|
||||
eq(intervalToCron("336h|biweekly:mon@09:00"), null, "biweekly refuses cron conversion");
|
||||
|
||||
// 秒级任务无法转 cron → null
|
||||
eq(intervalToCron("30s"), null, "seconds refuse cron conversion");
|
||||
|
||||
// 跨午夜窗口 22:00–06:00 无法表达 → null
|
||||
eq(intervalToCron("1h", "22:00", "06:00"), null, "cross-midnight window refuses conversion");
|
||||
|
||||
// 时间窗口结束时刻 exclusive:09:00–17:00 → 小时 9-16(不含 17 点)
|
||||
eq(intervalToCron("1h", "09:00", "17:00"), "0 9-16 * * *", "window end hour is exclusive");
|
||||
|
||||
// SivanCola review (2026-08-14): 2h + 窗口不能转成 0 9-16(那会把每2小时
|
||||
// 变成每小时,频率放大)→ 拒绝转换
|
||||
eq(intervalToCron("2h", "09:00", "17:00"), null, "2h window refuses conversion (frequency would grow)");
|
||||
|
||||
// 非整点窗口 09:30-17:30 会被截断成整点 → 拒绝转换
|
||||
eq(intervalToCron("1h", "09:30", "17:30"), null, "non-top-of-hour window refuses conversion");
|
||||
eq(intervalToCron("30m", "09:30", "17:30"), null, "non-top-of-hour window refuses m-interval conversion");
|
||||
|
||||
// 周期任务带残留窗口:周期任务有自己的时刻(@12:00),引擎忽略窗口,
|
||||
// 转 cron 必须保持原时刻,不能折叠成窗口小时范围(否则每天变成每小时)
|
||||
eq(intervalToCron("24h|daily@12:00", "09:00", "17:00"), "0 12 * * *", "daily with stale window keeps its own clock");
|
||||
|
||||
// daily 周期正常转换(无窗口)
|
||||
eq(intervalToCron("24h|daily@09:00"), "0 9 * * *", "daily converts to cron");
|
||||
|
||||
// weekly 周期正常转换
|
||||
eq(intervalToCron("168h|weekly:mon,wed@09:00"), "0 9 * * 1,3", "weekly converts to cron");
|
||||
|
||||
// monthly 周期正常转换
|
||||
eq(intervalToCron("720h|monthly:15@09:00"), "0 9 15 * *", "monthly converts to cron");
|
||||
|
||||
console.log("cycle next-run (schedule semantics, not cron)");
|
||||
|
||||
// daily: 下个 22:00
|
||||
eq(
|
||||
nextCycleRunAt("24h|daily@22:00", localMs(2026, 8, 11, 10, 0)),
|
||||
localMs(2026, 8, 11, 22, 0),
|
||||
"daily next run is today's 22:00",
|
||||
);
|
||||
|
||||
// daily: 已过 22:00 → 明天 22:00
|
||||
eq(
|
||||
nextCycleRunAt("24h|daily@22:00", localMs(2026, 8, 11, 23, 0)),
|
||||
localMs(2026, 8, 12, 22, 0),
|
||||
"daily next run rolls to tomorrow after 22:00",
|
||||
);
|
||||
|
||||
// weekly: 下个周五 16:00(2026-08-14 是周五)
|
||||
eq(
|
||||
nextCycleRunAt("168h|weekly:fri@16:00", localMs(2026, 8, 11, 10, 0)),
|
||||
localMs(2026, 8, 14, 16, 0),
|
||||
"weekly next run is next Friday 16:00",
|
||||
);
|
||||
|
||||
// monthly: 下个 15 号 09:00
|
||||
eq(
|
||||
nextCycleRunAt("720h|monthly:15@09:00", localMs(2026, 8, 11, 10, 0)),
|
||||
localMs(2026, 8, 15, 9, 0),
|
||||
"monthly next run is 15th 09:00",
|
||||
);
|
||||
|
||||
// 月末 clamp:monthly:31 在 2026-04-01 之后的下一次是 2026-04-30(4 月只有
|
||||
// 30 天,后端 monthlyCandidate 将 day=31 clamp 到当月末)——前端镜像后端
|
||||
// 语义,不能跳到 5 月 31 号。
|
||||
eq(
|
||||
nextCycleRunAt("720h|monthly:31@09:00", localMs(2026, 4, 1, 10, 0)),
|
||||
localMs(2026, 4, 30, 9, 0),
|
||||
"monthly day-31 clamps to April 30 (mirrors backend monthlyCandidate)",
|
||||
);
|
||||
// 闰年 2 月:day=29 在非闰年 2027 年 clamp 到 2 月 28 日
|
||||
eq(
|
||||
nextCycleRunAt("720h|monthly:29@09:00", localMs(2027, 2, 1, 10, 0)),
|
||||
localMs(2027, 2, 28, 9, 0),
|
||||
"monthly day-29 clamps to February 28 in a non-leap year",
|
||||
);
|
||||
// 闰年 2028 年 2 月 29 日保持 29 日
|
||||
eq(
|
||||
nextCycleRunAt("720h|monthly:29@09:00", localMs(2028, 2, 1, 10, 0)),
|
||||
localMs(2028, 2, 29, 9, 0),
|
||||
"monthly day-29 keeps February 29 in a leap year",
|
||||
);
|
||||
|
||||
// yearly: 下个 1-1 09:00(2027-01-01)
|
||||
eq(
|
||||
nextCycleRunAt("8760h|yearly:1-1@09:00", localMs(2026, 8, 11, 10, 0)),
|
||||
localMs(2027, 1, 1, 9, 0),
|
||||
"yearly next run is next Jan 1st 09:00",
|
||||
);
|
||||
|
||||
// 离线期间已到期:daily 任务上次运行在 2026-08-16 09:00,schedule 基于
|
||||
// lastRunAt 求下一次 = 2026-08-17 09:00,而当前已到 8/18 10:00 → next 落在
|
||||
// 过去。taskNextRun 据此显示 dueSoon(当前应执行),而不是跳过到明天。
|
||||
eq(
|
||||
heartbeatNextRunAt(
|
||||
{ interval: "24h|daily@09:00", lastRunAt: localMs(2026, 8, 16, 9, 0) },
|
||||
localMs(2026, 8, 18, 10, 0),
|
||||
),
|
||||
localMs(2026, 8, 17, 9, 0),
|
||||
"daily next run falls in the past when the task is overdue offline",
|
||||
);
|
||||
|
||||
// ── review fixes 回归 ──
|
||||
|
||||
console.log("cron dow=7 Sunday alias");
|
||||
|
||||
eq(
|
||||
heartbeatNextRunAt(
|
||||
{ interval: "0 9 * * 7", lastRunAt: 0 },
|
||||
localMs(2026, 8, 10, 10, 0), // Monday
|
||||
),
|
||||
localMs(2026, 8, 16, 9, 0), // next Sunday 09:00
|
||||
"dow=7 (Sunday alias) computes the next Sunday run",
|
||||
);
|
||||
|
||||
eq(
|
||||
heartbeatNextRunAt(
|
||||
{ interval: "0 9 * * 0,7", lastRunAt: 0 },
|
||||
localMs(2026, 8, 10, 10, 0), // Monday
|
||||
),
|
||||
localMs(2026, 8, 16, 9, 0),
|
||||
"dow=0,7 both spellings compute the next Sunday run",
|
||||
);
|
||||
|
||||
console.log("biweekly parity mirrors the Go engine (Monday week anchor)");
|
||||
|
||||
// 2026-08-06 is a Thursday; engine parity is anchored on the Monday of the
|
||||
// creation week (2026-08-03). Candidate Monday 2026-08-10 is week 1 away →
|
||||
// skipped; next fire is Monday 2026-08-17 (week 2).
|
||||
eq(
|
||||
nextCycleRunAt("336h|biweekly:mon@09:00", localMs(2026, 8, 10, 10, 0), localMs(2026, 8, 6, 9, 0)),
|
||||
localMs(2026, 8, 17, 9, 0),
|
||||
"biweekly with Thursday anchor skips the first Monday (Monday-anchored parity)",
|
||||
);
|
||||
|
||||
// Anchor on a Monday itself: creation week = 2026-08-10; next fire is 2026-08-24.
|
||||
eq(
|
||||
nextCycleRunAt("336h|biweekly:mon@09:00", localMs(2026, 8, 10, 10, 0), localMs(2026, 8, 10, 9, 0)),
|
||||
localMs(2026, 8, 24, 9, 0),
|
||||
"biweekly with Monday anchor fires every other Monday",
|
||||
);
|
||||
|
||||
console.log("cronToInterval refuses lossy conversions");
|
||||
|
||||
eq(cronToInterval("0 9 * * 1"), null, "weekly cron refuses interval conversion");
|
||||
eq(cronToInterval("0 9 * * *"), null, "fixed-time cron refuses interval conversion");
|
||||
eq(cronToInterval("0 0 1 * *"), null, "dom-restricted cron refuses interval conversion");
|
||||
eq(cronToInterval("*/15 * * * *"), "15m", "every-N-minutes cron converts");
|
||||
eq(cronToInterval("0 */2 * * *"), "2h", "every-N-hours cron converts");
|
||||
eq(cronToInterval("0 * * * *"), null, "top-of-every-hour is not a simple interval");
|
||||
|
||||
console.log("frontend isCronExpr field bounds (dom/month 1-based, step>0, lo<=hi)");
|
||||
|
||||
eq(
|
||||
heartbeatNextRunAt(
|
||||
{ interval: "0 0 0 * *", lastRunAt: 0 }, // dom=0 — "midnight every day" typo
|
||||
localMs(2026, 8, 10, 10, 0),
|
||||
),
|
||||
null,
|
||||
"dom=0 is rejected: no next run is computed for an invalid expression",
|
||||
);
|
||||
|
||||
// SivanCola review: 永不执行的表达式必须被拒绝
|
||||
eq(
|
||||
heartbeatNextRunAt(
|
||||
{ interval: "*/0 * * * *", lastRunAt: 0 }, // step 0: value % 0 is never 0
|
||||
localMs(2026, 8, 10, 10, 0),
|
||||
),
|
||||
null,
|
||||
"zero step */0 is rejected (never fires)",
|
||||
);
|
||||
eq(
|
||||
heartbeatNextRunAt(
|
||||
{ interval: "0 0 5-1 * *", lastRunAt: 0 }, // descending range never matches
|
||||
localMs(2026, 8, 10, 10, 0),
|
||||
),
|
||||
null,
|
||||
"descending range 5-1 is rejected (never matches)",
|
||||
);
|
||||
eq(
|
||||
heartbeatNextRunAt(
|
||||
{ interval: "0 0 1 * 8", lastRunAt: 0 }, // dow 8 out of range
|
||||
localMs(2026, 8, 10, 10, 0),
|
||||
),
|
||||
null,
|
||||
"dow=8 is rejected (out of range)",
|
||||
);
|
||||
|
||||
eq(
|
||||
heartbeatNextRunAt(
|
||||
{ interval: "0 0 1 0 *", lastRunAt: 0 }, // month=0
|
||||
localMs(2026, 8, 10, 10, 0),
|
||||
),
|
||||
null,
|
||||
"month=0 is rejected",
|
||||
);
|
||||
|
||||
eq(
|
||||
heartbeatNextRunAt(
|
||||
{ interval: "0 0 1 * *", lastRunAt: 0 },
|
||||
localMs(2026, 8, 10, 10, 0),
|
||||
),
|
||||
localMs(2026, 9, 1, 0, 0),
|
||||
"valid dom expression still computes a next run",
|
||||
);
|
||||
|
||||
console.log("cron steps use field minima and retain long-horizon matches");
|
||||
|
||||
eq(
|
||||
heartbeatNextRunAt({ interval: "0 0 * */2 *", lastRunAt: 0 }, localMs(2025, 12, 31, 12, 0)),
|
||||
localMs(2026, 1, 1, 0, 0),
|
||||
"wildcard month step starts from January",
|
||||
);
|
||||
eq(
|
||||
heartbeatNextRunAt({ interval: "0 0 * */2 *", lastRunAt: 0 }, localMs(2026, 1, 31, 0, 0)),
|
||||
localMs(2026, 3, 1, 0, 0),
|
||||
"wildcard month step skips February",
|
||||
);
|
||||
eq(
|
||||
heartbeatNextRunAt({ interval: "1/2 * * * *", lastRunAt: 0 }, localMs(2026, 1, 1, 0, 1)),
|
||||
localMs(2026, 1, 1, 0, 3),
|
||||
"single-value step continues from its explicit start",
|
||||
);
|
||||
eq(
|
||||
heartbeatNextRunAt({ interval: "0 0 29 2 *", lastRunAt: 0 }, localMs(2028, 3, 1, 0, 0)),
|
||||
localMs(2032, 2, 29, 0, 0),
|
||||
"leap-day cron searches beyond one year",
|
||||
);
|
||||
|
||||
console.log("task list computes each next run once");
|
||||
|
||||
const listNow = localMs(2026, 8, 10, 10, 0);
|
||||
const listTasks = [
|
||||
{ id: "late", title: "late", prompt: "", interval: "0 0 29 2 *", enabled: true, lastRunAt: 1 },
|
||||
{ id: "new", title: "new", prompt: "", interval: "0 0 31 2 *", enabled: true },
|
||||
{ id: "paused", title: "paused", prompt: "", interval: "0 0 31 2 *", enabled: false, lastRunAt: 1 },
|
||||
];
|
||||
let nextRunCalls = 0;
|
||||
const preparedTasks = prepareTasksByNextRun(listTasks, listNow, (task) => {
|
||||
nextRunCalls += 1;
|
||||
return task.id === "late" ? listNow + 60000 : null;
|
||||
});
|
||||
eq(nextRunCalls, listTasks.length, "list preparation resolves each task exactly once");
|
||||
eq(preparedTasks.map(({ task }) => task.id).join(","), "new,late,paused", "list preparation preserves next-run ordering");
|
||||
eq(preparedTasks[1].nextRunAt, listNow + 60000, "list rows reuse the precomputed next-run value");
|
||||
|
||||
console.log("frequency conversion keeps the selected editor on lossy paths");
|
||||
|
||||
eq(changeHeartbeatFrequency({ id: "t", title: "t", prompt: "p", enabled: true, interval: "0 9 * * 1" }, "interval"), null, "weekly cron cannot switch to interval");
|
||||
eq(changeHeartbeatFrequency({ id: "t", title: "t", prompt: "p", enabled: true, interval: "30s" }, "cron"), null, "seconds cannot switch to cron");
|
||||
eq(changeHeartbeatFrequency({ id: "t", title: "t", prompt: "p", enabled: true, interval: "336h|biweekly:mon@09:00" }, "cron"), null, "biweekly cannot switch to cron");
|
||||
eq(changeHeartbeatFrequency({ id: "t", title: "t", prompt: "p", enabled: true, interval: "1h", timeWindowStart: "22:00", timeWindowEnd: "06:00" }, "cron"), null, "cross-midnight interval cannot switch to cron");
|
||||
|
||||
console.log("biweekly parity uses civil dates across DST");
|
||||
|
||||
const originalTZ = process.env.TZ;
|
||||
process.env.TZ = "America/New_York";
|
||||
eq(
|
||||
nextCycleRunAt("336h|biweekly:mon@09:00", localMs(2026, 3, 9, 10, 0), localMs(2026, 3, 2, 9, 0)),
|
||||
localMs(2026, 3, 16, 9, 0),
|
||||
"spring-forward keeps the even-week Monday",
|
||||
);
|
||||
eq(
|
||||
nextCycleRunAt("336h|biweekly:mon@09:00", localMs(2026, 11, 2, 10, 0), localMs(2026, 10, 26, 9, 0)),
|
||||
localMs(2026, 11, 9, 9, 0),
|
||||
"fall-back keeps the even-week Monday",
|
||||
);
|
||||
if (originalTZ === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = originalTZ;
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,32 @@
|
||||
// Run: tsx src/__tests__/heartbeat-test-runner-contract.test.ts
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: unknown, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(resolve(testDir, "heartbeat-next-run.test.ts"), "utf8");
|
||||
const exitChecks = Array.from(source.matchAll(/if \(failed > 0\) process\.exit\(1\);/g));
|
||||
const finalAssertion = Math.max(source.lastIndexOf("eq("), source.lastIndexOf("ok("));
|
||||
const finalExit = source.lastIndexOf("if (failed > 0) process.exit(1);");
|
||||
|
||||
console.log("\nheartbeat test runner contract");
|
||||
ok(exitChecks.length === 1, "focused suite has exactly one failing-exit gate");
|
||||
ok(finalExit > finalAssertion, "failing-exit gate runs after every assertion");
|
||||
ok(source.indexOf("passed, ${failed} failed", finalAssertion) > finalAssertion, "final summary runs after every assertion");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -21,7 +21,7 @@ assert.match(
|
||||
/if \(!virtuosoReadyRef\.current \|\| !stick\.current\) return;\n followGrowingTail\(\);\n \}, \[footerHeight, followGrowingTail, stick\]\);/,
|
||||
"footer resize is routed through the coalesced tail-follow path without depending on item appends",
|
||||
);
|
||||
assert.match(controller, /shouldPreferResidentHistory\(reset, options\.preserveCachedHistory\)/, "retry hydrates fetch instead of serving the resident snapshot");
|
||||
assert.match(controller, /shouldPreferResidentHistory\(resetSurface, options\.preserveCachedHistory\)/, "retry hydrates fetch instead of serving the resident snapshot");
|
||||
assert.match(
|
||||
controller,
|
||||
/loadSessionDataForTab\(tabId, false, "startup", \{ preserveCachedHistory: true \}\)/,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import MarkdownHistory from "../components/MarkdownHistory";
|
||||
import { TranscriptScrollWriteProvider } from "../components/TranscriptLayoutIntentContext";
|
||||
import { parseMarkdown, markdownContentRevision } from "../lib/markdownPipeline";
|
||||
import {
|
||||
disposeMarkdownWorkerClient,
|
||||
@@ -219,8 +220,16 @@ console.log("\nmarkdown history rendering");
|
||||
};
|
||||
const text = Array.from({ length: 420 }, (_, i) => `Paragraph ${i} with some *content*.`).join("\n\n");
|
||||
const root5 = createRoot(rootEl);
|
||||
const writeTranscriptOffset = (_owner: "block-window-prepend", top: number) => {
|
||||
rootEl.scrollTop = top;
|
||||
return true;
|
||||
};
|
||||
await act(async () => {
|
||||
root5.render(<MarkdownHistory text={text} entryId="md-history-huge" fallback={null} />);
|
||||
root5.render(
|
||||
<TranscriptScrollWriteProvider value={writeTranscriptOffset}>
|
||||
<MarkdownHistory text={text} entryId="md-history-huge" fallback={null} />
|
||||
</TranscriptScrollWriteProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
const container = rootEl.querySelector(".md[data-markdown-blocks]");
|
||||
@@ -267,7 +276,11 @@ console.log("\nmarkdown history rendering");
|
||||
|
||||
const replacement = Array.from({ length: 420 }, (_, i) => `Replacement ${i} with some *content*.`).join("\n\n");
|
||||
await act(async () => {
|
||||
root5.render(<MarkdownHistory text={replacement} entryId="md-history-huge-replacement" fallback={null} />);
|
||||
root5.render(
|
||||
<TranscriptScrollWriteProvider value={writeTranscriptOffset}>
|
||||
<MarkdownHistory text={replacement} entryId="md-history-huge-replacement" fallback={null} />
|
||||
</TranscriptScrollWriteProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-visible-blocks"), "24", "an equal-sized replacement document resets to its own tail");
|
||||
|
||||
@@ -136,6 +136,51 @@ console.log("\nmarkdown streaming → worker final parse");
|
||||
delete (globalThis as { Worker?: unknown }).Worker;
|
||||
}
|
||||
|
||||
console.log("\nstreaming code fence tail styling");
|
||||
|
||||
// An unclosed fence stays in the streaming tail (never committed early) and
|
||||
// renders with code-block styling until the closing fence arrives (#8843).
|
||||
{
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Markdown text={"intro\n\n"} streaming />);
|
||||
await flush();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Markdown text={"intro\n\n```js\nconst a = 1;"} streaming />);
|
||||
await flush();
|
||||
});
|
||||
const tail = rootEl.querySelector(".md--stream-tail");
|
||||
ok(tail, "an open code fence keeps the uncommitted tail mounted");
|
||||
const pre = tail?.querySelector("pre.code.md--stream-tail-code");
|
||||
ok(pre, "an open code fence renders with code styling before the closing fence arrives");
|
||||
eq(pre?.getAttribute("data-lang"), "js", "the streaming code block carries the fence language");
|
||||
eq(pre?.textContent, "const a = 1;", "the streaming code block shows the fence body without the opener line");
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Markdown text={"intro\n\n```js\nconst a = 1;\nconst b = 2;"} streaming />);
|
||||
await flush();
|
||||
});
|
||||
eq(rootEl.querySelector(".md--stream-tail pre code")?.textContent, "const a = 1;\nconst b = 2;", "the streaming code body grows in place");
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Markdown text={"intro\n\n```js\nconst a = 1;\nconst b = 2;\n```\ndone"} streaming />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
});
|
||||
// The closed fence commits on the next animation frame after the parse
|
||||
// budget; let that frame run inside act before asserting.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
});
|
||||
const settledTail = rootEl.querySelector(".md--stream-tail");
|
||||
ok(!settledTail?.querySelector("pre.md--stream-tail-code"), "a closed fence leaves the code styling to the parsed renderer");
|
||||
eq(settledTail?.textContent, "done", "text after the closing fence rides the plain tail");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
}
|
||||
|
||||
await server.close();
|
||||
dom.window.close();
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { splitStableMarkdownSections, streamingCommitTarget, streamingMarkdownCommitInterval, useRenderedMarkdownText } from "../components/Markdown";
|
||||
import { splitStableMarkdownSections, splitStreamingTailFence, streamingCommitTarget, streamingMarkdownCommitInterval, useRenderedMarkdownText } from "../components/Markdown";
|
||||
import MermaidDiagram from "../components/MermaidDiagram";
|
||||
import {
|
||||
__setMermaidPanZoomFactoryForTest,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
safelySyncPanZoom,
|
||||
sanitizeMermaidSvg,
|
||||
} from "../components/MermaidDiagram";
|
||||
import { createMermaidPanZoom } from "../components/mermaidPanZoom";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { REMOTE_MARKDOWN_IMAGE_PATH } from "../lib/markdownImage";
|
||||
|
||||
@@ -28,6 +29,7 @@ const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8");
|
||||
const markdownRendererSource = readFileSync(resolve(testDir, "../components/MarkdownRenderer.tsx"), "utf8");
|
||||
const markdownComponentsSource = readFileSync(resolve(testDir, "../components/markdownComponents.tsx"), "utf8");
|
||||
const markdownSource = readFileSync(resolve(testDir, "../components/Markdown.tsx"), "utf8");
|
||||
const mermaidDiagramSource = readFileSync(resolve(testDir, "../components/MermaidDiagram.tsx"), "utf8");
|
||||
const messageSource = readFileSync(resolve(testDir, "../components/Message.tsx"), "utf8");
|
||||
|
||||
let passed = 0;
|
||||
@@ -132,6 +134,67 @@ function installDom() {
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Inline pan/zoom (replaces svg-pan-zoom, #8068): transform math on one
|
||||
// viewport <g>, zoom clamped to 0.3–8, wheel/drag/dblclick interactions.
|
||||
{
|
||||
const dom = installDom();
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("viewBox", "0 0 160 80");
|
||||
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
|
||||
const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
|
||||
marker.setAttribute("id", "arrow");
|
||||
defs.appendChild(marker);
|
||||
svg.appendChild(defs);
|
||||
const content = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||
content.setAttribute("id", "drawing");
|
||||
svg.appendChild(content);
|
||||
// 640x360 layout box: the meet mapping fits at scale 4, origin (0, 20).
|
||||
Object.defineProperty(svg, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => ({ x: 0, y: 0, width: 640, height: 360, top: 0, right: 640, bottom: 360, left: 0, toJSON: () => ({}) }),
|
||||
});
|
||||
document.body.appendChild(svg);
|
||||
|
||||
const panZoom = createMermaidPanZoom(svg, { minZoom: 0.3, maxZoom: 8, zoomScaleSensitivity: 0.3 });
|
||||
const viewport = () => svg.querySelector("g[data-mermaid-pan-zoom-viewport]");
|
||||
ok(svg.querySelector("g[data-mermaid-pan-zoom-viewport] g#drawing"), "inline pan/zoom wraps the drawing in one viewport group");
|
||||
ok(defs.parentNode === svg, "SVG definitions stay outside the transformed viewport");
|
||||
ok(!viewport()?.querySelector("defs"), "the viewport does not transform SVG definitions");
|
||||
ok(svg.querySelector("defs marker#arrow") === marker, "definition children remain available by ID");
|
||||
|
||||
panZoom.fit();
|
||||
eq(viewport()?.getAttribute("transform"), "translate(0 0) scale(1)", "fit keeps the browser meet mapping at unit scale");
|
||||
|
||||
svg.dispatchEvent(new dom.window.WheelEvent("wheel", { clientX: 320, clientY: 180, deltaY: -120, bubbles: true, cancelable: true }));
|
||||
eq(viewport()?.getAttribute("transform"), "translate(-24 -12) scale(1.3)", "wheel zooms in around the cursor");
|
||||
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
svg.dispatchEvent(new dom.window.WheelEvent("wheel", { clientX: 320, clientY: 180, deltaY: 120, bubbles: true, cancelable: true }));
|
||||
}
|
||||
ok(viewport()?.getAttribute("transform")?.endsWith("scale(0.3)"), "wheel zoom-out clamps at the minimum zoom");
|
||||
|
||||
panZoom.reset();
|
||||
eq(viewport()?.getAttribute("transform"), "translate(0 0) scale(1)", "reset restores the fitted unit transform");
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
svg.dispatchEvent(new dom.window.WheelEvent("wheel", { clientX: 320, clientY: 180, deltaY: -120, bubbles: true, cancelable: true }));
|
||||
}
|
||||
ok(viewport()?.getAttribute("transform")?.endsWith("scale(8)"), "wheel zoom-in clamps at the maximum zoom");
|
||||
|
||||
panZoom.reset();
|
||||
svg.dispatchEvent(new dom.window.MouseEvent("pointerdown", { button: 0, clientX: 100, clientY: 100, bubbles: true }));
|
||||
svg.dispatchEvent(new dom.window.MouseEvent("pointermove", { clientX: 130, clientY: 110, bubbles: true }));
|
||||
eq(viewport()?.getAttribute("transform"), "translate(7.5 2.5) scale(1)", "pointer drag pans the drawing in screen pixels");
|
||||
svg.dispatchEvent(new dom.window.MouseEvent("pointerup", { clientX: 130, clientY: 110, bubbles: true }));
|
||||
|
||||
panZoom.destroy();
|
||||
svg.dispatchEvent(new dom.window.WheelEvent("wheel", { clientX: 320, clientY: 180, deltaY: -120, bubbles: true, cancelable: true }));
|
||||
eq(viewport()?.getAttribute("transform"), "translate(7.5 2.5) scale(1)", "destroy detaches the interaction listeners");
|
||||
|
||||
ok(!mermaidDiagramSource.includes("svg-pan-zoom"), "MermaidDiagram no longer imports svg-pan-zoom");
|
||||
ok(!styles.includes("svg-pan-zoom"), "styles no longer carry svg-pan-zoom hooks");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
function parseSvg(svg: string): Document {
|
||||
return new DOMParser().parseFromString(svg, "image/svg+xml");
|
||||
}
|
||||
@@ -199,7 +262,7 @@ console.log("\nmermaid rendering");
|
||||
eq(streamingCommitTarget("intro\n\npartial paragraph"), "intro\n\n", "commit target stops at the last completed block");
|
||||
eq(streamingCommitTarget("no blank line yet"), "", "no completed block means nothing to parse yet");
|
||||
eq(streamingCommitTarget("done\n\nalso done\n\n"), "done\n\nalso done\n\n", "trailing boundary commits everything");
|
||||
eq(streamingCommitTarget("t\n\n```js\nstreaming code"), "t\n\n```js\nstreaming code", "an open fence keeps the whole text parsed for live highlighting");
|
||||
eq(streamingCommitTarget("t\n\n```js\nstreaming code"), "t\n\n", "an open fence stays in the tail for code-styled streaming");
|
||||
eq(streamingCommitTarget("t\n\n$$\n\\int_0^1"), "t\n\n$$\n\\int_0^1", "open display math keeps the whole text parsed");
|
||||
eq(streamingCommitTarget("t\n\n```\nc\n```\nafter"), "t\n\n```\nc\n```\n", "a closed fence promotes immediately without waiting for a blank line");
|
||||
eq(streamingCommitTarget("t\n\n$$\nx\n$$\nafter"), "t\n\n$$\nx\n$$\n", "closed display math promotes immediately");
|
||||
@@ -259,7 +322,7 @@ console.log("\nmermaid rendering");
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget("t\n\n```\n- not a list\n- also not\n"),
|
||||
"t\n\n```\n- not a list\n- also not\n",
|
||||
"t\n\n",
|
||||
"list markers inside an open fence do not create commit boundaries",
|
||||
);
|
||||
eq(
|
||||
@@ -551,5 +614,15 @@ console.log("\nmermaid rendering");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
eq(splitStreamingTailFence("still typing"), null, "a plain tail has no code fence split");
|
||||
eq(splitStreamingTailFence("```\nc\n```\nafter"), null, "a closed fence leaves no open code tail");
|
||||
const split = splitStreamingTailFence("```js\nconst a = 1;\nconst b");
|
||||
eq(split?.head, "", "an open fence at the tail start has no plain head");
|
||||
eq(split?.lang, "js", "the open fence split keeps the info-string language");
|
||||
eq(split?.code, "const a = 1;\nconst b", "the open fence split drops the opener line from the code body");
|
||||
eq(splitStreamingTailFence("para\n\n```\nx")?.head, "para\n\n", "text before the open fence stays plain");
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const projectTreeSource = readFileSync(new URL("../components/ProjectTree.tsx", import.meta.url), "utf8");
|
||||
const styles = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
|
||||
|
||||
assert.ok(
|
||||
projectTreeSource.includes('className="project-tree__section-title project-tree__section-title--pinned"'),
|
||||
"pinned heading uses a non-interactive section-title class",
|
||||
);
|
||||
assert.ok(
|
||||
!projectTreeSource.includes('className="project-tree__folder" style={{ cursor'),
|
||||
"pinned heading does not inherit interactive folder hover styles",
|
||||
);
|
||||
assert.ok(
|
||||
projectTreeSource.includes('className="project-tree__section-title-icon" aria-hidden="true"'),
|
||||
"pinned heading marks its decorative icon as hidden from assistive technology",
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.sidebar--workbench \.project-tree__section-title--pinned,[^{]*\{[^}]*display:\s*flex;[^}]*height:\s*32px;/s,
|
||||
"pinned heading keeps the folder-aligned layout without folder interaction styles",
|
||||
);
|
||||
|
||||
console.log(" PASS project tree pinned heading contract");
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { projectTreeTopicRecoveryCopyCount } from "../lib/projectTreeTopic";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
import { en } from "../locales/en";
|
||||
import { zh } from "../locales/zh";
|
||||
import { zhTW } from "../locales/zh-TW";
|
||||
|
||||
const topic = (recoveryCopyCount?: number): ProjectNode => ({
|
||||
key: "topic_t1",
|
||||
kind: "topic",
|
||||
label: "Topic",
|
||||
topicId: "t1",
|
||||
recoveryCopyCount,
|
||||
});
|
||||
|
||||
assert.equal(projectTreeTopicRecoveryCopyCount(topic(2)), 2, "topic rows surface the folded copy count");
|
||||
assert.equal(projectTreeTopicRecoveryCopyCount(topic(0)), 0, "zero renders no badge");
|
||||
assert.equal(projectTreeTopicRecoveryCopyCount(topic(undefined)), 0, "missing count renders no badge");
|
||||
assert.equal(projectTreeTopicRecoveryCopyCount(topic(-1)), 0, "negative count renders no badge");
|
||||
assert.equal(
|
||||
projectTreeTopicRecoveryCopyCount({ key: "session_s1", kind: "session", label: "S", recoveryCopyCount: 3 }),
|
||||
0,
|
||||
"runtime session rows never carry the badge",
|
||||
);
|
||||
|
||||
const styles = readFileSync(fileURLToPath(new URL("../styles.css", import.meta.url)), "utf8");
|
||||
assert.match(
|
||||
styles,
|
||||
/\.project-tree__topic-recovery-copies\s*\{/s,
|
||||
"the folded recovery-copy badge has a muted pill style",
|
||||
);
|
||||
|
||||
for (const [name, dict] of Object.entries({ en, zh, zhTW })) {
|
||||
const label = dict["projectTree.recoveryCopies"];
|
||||
assert.ok(label && label.includes("{count}"), `${name} localizes projectTree.recoveryCopies with a count placeholder`);
|
||||
}
|
||||
|
||||
console.log(" PASS project tree recovery copy badge contract");
|
||||
@@ -0,0 +1,81 @@
|
||||
// Run: tsx src/__tests__/project-tree-rename-optimistic.test.ts
|
||||
|
||||
import { projectTreeWithTopicTitle } from "../lib/projectTreeTopic";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (JSON.stringify(a) === JSON.stringify(b)) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nproject tree optimistic rename");
|
||||
|
||||
eq(
|
||||
projectTreeWithTopicTitle(
|
||||
[
|
||||
{
|
||||
key: "p",
|
||||
kind: "project",
|
||||
label: "P",
|
||||
children: [
|
||||
{ key: "topic_rename", kind: "topic", label: "Old name", topicId: "topic_rename" },
|
||||
{ key: "topic_keep", kind: "topic", label: "Keep me", topicId: "topic_keep" },
|
||||
],
|
||||
},
|
||||
],
|
||||
"topic_rename",
|
||||
"New name",
|
||||
).map((node) => (node.children ?? []).map((child) => child.label)),
|
||||
[["New name", "Keep me"]],
|
||||
"a committed rename repaints the topic row label before the catalog event arrives",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeWithTopicTitle(
|
||||
[{ key: "p", kind: "project", label: "P", children: [{ key: "t", kind: "topic", label: "Same", topicId: "t" }] }],
|
||||
"t",
|
||||
"Same",
|
||||
)[0].children?.[0].label,
|
||||
"Same",
|
||||
"an unchanged rename label leaves the tree identity stable",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeWithTopicTitle(
|
||||
[{ key: "p", kind: "project", label: "P", children: [{ key: "t", kind: "topic", label: "T", topicId: "t" }] }],
|
||||
"topic_missing",
|
||||
"New name",
|
||||
)[0].children?.[0].label,
|
||||
"T",
|
||||
"a rename for a topic outside the loaded tree changes nothing",
|
||||
);
|
||||
|
||||
const projectTreeSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../components/ProjectTree.tsx"), "utf8");
|
||||
eq(
|
||||
projectTreeSource.includes("projectTreeWithTopicTitle(current, topicId, title)"),
|
||||
true,
|
||||
"commitRenameTopic paints the new label optimistically before the refresh round-trip",
|
||||
);
|
||||
eq(
|
||||
projectTreeSource.includes("project-tree__skeleton"),
|
||||
true,
|
||||
"an expanded folder with a loading first topic page renders skeleton rows",
|
||||
);
|
||||
eq(
|
||||
/projectTreeRevisionIsFresh\(latestRevisionRef\.current, event\.revision\)\) \{\s*void refresh\(\)/.test(projectTreeSource),
|
||||
true,
|
||||
"a stale project-tree revision falls back to a full snapshot refetch instead of dropping the event",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -24,5 +24,15 @@ assert.match(
|
||||
/:root\[data-theme-style\] \.sidebar--workbench \.project-tree__topic\s*\{[^}]*height:\s*34px;/s,
|
||||
"workbench topic rows keep a fixed height",
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.project-tree__group-input\s*\{[^}]*font-size:\s*inherit;/s,
|
||||
"group rename inputs keep the group title font size",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
styles,
|
||||
/\.project-tree__group-input\s*\{[^}]*font-size:\s*11px;/s,
|
||||
"group rename inputs cannot restore the old smaller font size",
|
||||
);
|
||||
|
||||
console.log(" PASS project tree row stability contract");
|
||||
@@ -257,6 +257,8 @@ const providerUrlLabel = Array.from(rootEl.querySelectorAll<HTMLLabelElement>("l
|
||||
);
|
||||
ok(Boolean(providerUrlLabel) && providerUrlInput?.getAttribute("aria-describedby") !== null, "provider URL input has a programmatic label and description");
|
||||
ok(rootEl.textContent?.includes("Reasonix uses it unchanged.") === true, "provider URL helper explains exact request behavior");
|
||||
ok(rootEl.querySelector<HTMLInputElement>('input[placeholder="e.g. my-proxy"]')?.disabled !== true, "new custom provider name stays editable");
|
||||
ok(rootEl.querySelector<HTMLInputElement>('input[placeholder="e.g. my-proxy"]')?.nextElementSibling?.classList.contains("mem-hint") !== true, "new custom provider editor omits the rename hint");
|
||||
|
||||
await act(async () => {
|
||||
root.render(<div />);
|
||||
@@ -289,6 +291,8 @@ ok(
|
||||
const customProviderUrlInput = rootEl.querySelector<HTMLInputElement>(".provider-url-input");
|
||||
ok(rootEl.querySelectorAll('input[type="radio"]').length === 0, "existing custom providers no longer expose an address mode selector");
|
||||
ok(customProviderUrlInput?.value === "https://eu.deepseek.com/v1/chat/completions", "legacy base-only providers display their previously effective request URL");
|
||||
ok(rootEl.querySelector<HTMLInputElement>('input[placeholder="e.g. my-proxy"]')?.disabled === true, "existing custom provider name is locked");
|
||||
ok(rootEl.querySelector<HTMLInputElement>('input[placeholder="e.g. my-proxy"]')?.nextElementSibling?.textContent === "Changing the provider name is not supported yet", "existing custom provider editor shows the rename hint");
|
||||
|
||||
let migratedProvider: ProviderView | undefined;
|
||||
await act(async () => {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// Run: tsx src/__tests__/provider-name-readonly.test.tsx
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ProviderEditor } from "../components/SettingsPanel";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { ProviderView } from "../lib/types";
|
||||
import { en } from "../locales/en";
|
||||
import { zh } from "../locales/zh";
|
||||
import { zhTW } from "../locales/zh-TW";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const styles = readFileSync(resolve(here, "../styles.css"), "utf8");
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === b) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function matchingBlocks(selector: string): string[] {
|
||||
const blocks: string[] = [];
|
||||
const rule = /([^{}]+)\{([^{}]*)\}/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = rule.exec(styles)) !== null) {
|
||||
const selectors = match[1].split(",").map((part) => part.trim());
|
||||
if (selectors.includes(selector)) blocks.push(match[2]);
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function finalDeclaration(selector: string, property: string): string | undefined {
|
||||
let value: string | undefined;
|
||||
for (const block of matchingBlocks(selector)) {
|
||||
const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g");
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = declaration.exec(block)) !== null) {
|
||||
value = match[1].trim();
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const customProvider: ProviderView = {
|
||||
name: "my-proxy",
|
||||
builtIn: false,
|
||||
added: true,
|
||||
kind: "openai",
|
||||
baseUrl: "https://example.com/v1",
|
||||
models: ["demo"],
|
||||
visionModels: [],
|
||||
visionModelsConfigured: false,
|
||||
modelsUrl: "",
|
||||
default: "demo",
|
||||
apiKeyEnv: "",
|
||||
keySet: false,
|
||||
balanceUrl: "",
|
||||
contextWindow: 128_000,
|
||||
reasoningProtocol: "",
|
||||
thinking: "",
|
||||
supportedEfforts: [],
|
||||
defaultEffort: "",
|
||||
};
|
||||
|
||||
function nameHint(root: Element): Element | null {
|
||||
const input = root.querySelector<HTMLInputElement>('input[placeholder="e.g. my-proxy"]');
|
||||
const next = input?.nextElementSibling;
|
||||
return next?.classList.contains("mem-hint") ? next : null;
|
||||
}
|
||||
|
||||
function renderEditor(initial?: ProviderView) {
|
||||
return (
|
||||
<LocaleProvider>
|
||||
<ProviderEditor
|
||||
key={initial?.name ?? "new-provider"}
|
||||
initial={initial}
|
||||
kinds={["openai"]}
|
||||
busy={false}
|
||||
onCancel={() => undefined}
|
||||
onSave={() => undefined}
|
||||
/>
|
||||
</LocaleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\nprovider name readonly");
|
||||
|
||||
eq(en["settings.customProviderNameReadonlyHint"], "Changing the provider name is not supported yet", "English rename hint");
|
||||
eq(zh["settings.customProviderNameReadonlyHint"], "暂不支持供应商名称修改", "Simplified Chinese rename hint");
|
||||
eq(zhTW["settings.customProviderNameReadonlyHint"], "暫不支援供應商名稱修改", "Traditional Chinese rename hint");
|
||||
|
||||
eq(finalDeclaration(".provider-name-input:disabled", "opacity"), "0.6", "disabled provider-name input is faded");
|
||||
eq(finalDeclaration(".provider-name-input:disabled", "cursor"), "not-allowed", "disabled provider-name input uses not-allowed cursor");
|
||||
eq(finalDeclaration(".mem-hint.provider-name-readonly-hint", "color"), "var(--fg-dim)", "readonly hint uses the stronger secondary text color");
|
||||
eq(finalDeclaration(".mem-input:disabled", "opacity"), undefined, "no global mem-input:disabled fade rule remains");
|
||||
eq(finalDeclaration(".mem-select:disabled", "opacity"), undefined, "no global mem-select:disabled fade rule remains");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.sessionStorage = dom.window.sessionStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
window.scrollTo = () => {};
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(renderEditor());
|
||||
await flushPromises();
|
||||
});
|
||||
const newNameInput = rootEl.querySelector<HTMLInputElement>('input[placeholder="e.g. my-proxy"]');
|
||||
ok(newNameInput?.disabled !== true, "new custom provider name stays editable");
|
||||
ok(newNameInput?.classList.contains("mem-input") === true, "provider name keeps mem-input base styling");
|
||||
ok(newNameInput?.classList.contains("provider-name-input") === true, "provider name carries the scoped provider-name-input class");
|
||||
ok(Boolean(newNameInput?.id), "new custom provider name has a stable input id");
|
||||
eq(rootEl.querySelector<HTMLLabelElement>(`label[for="${newNameInput?.id}"]`)?.textContent, en["settings.customProviderName"], "new custom provider name has a programmatic label");
|
||||
eq(newNameInput?.getAttribute("aria-describedby"), null, "editable provider name omits the readonly description reference");
|
||||
ok(nameHint(rootEl) === null, "new custom provider editor omits the rename hint");
|
||||
|
||||
await act(async () => {
|
||||
root.render(renderEditor(customProvider));
|
||||
await flushPromises();
|
||||
});
|
||||
const existingNameInput = rootEl.querySelector<HTMLInputElement>('input[placeholder="e.g. my-proxy"]');
|
||||
ok(existingNameInput?.disabled === true, "existing custom provider name is locked");
|
||||
ok(existingNameInput?.classList.contains("mem-input") === true, "locked provider name keeps mem-input base styling");
|
||||
ok(existingNameInput?.classList.contains("provider-name-input") === true, "locked provider name carries the scoped provider-name-input class");
|
||||
eq(rootEl.querySelector<HTMLLabelElement>(`label[for="${existingNameInput?.id}"]`)?.textContent, en["settings.customProviderName"], "locked provider name has a programmatic label");
|
||||
const existingNameHint = nameHint(rootEl);
|
||||
eq(existingNameHint?.textContent, en["settings.customProviderNameReadonlyHint"], "existing custom provider editor shows the rename hint");
|
||||
ok(existingNameHint?.classList.contains("provider-name-readonly-hint") === true, "rename hint carries the stronger contrast class");
|
||||
ok(Boolean(existingNameHint?.id), "rename hint has a stable id");
|
||||
eq(existingNameInput?.getAttribute("aria-describedby"), existingNameHint?.id, "locked provider name references the rename hint");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -3,7 +3,7 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QuestionJumpBar } from "../components/QuestionJumpBar";
|
||||
import { QUESTION_JUMP_MAX_MARKERS, QuestionJumpBar, questionTurnFromRailY, sampledQuestionTurns } from "../components/QuestionJumpBar";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { QuestionAnchor } from "../lib/transcriptGrouping";
|
||||
|
||||
@@ -24,6 +24,10 @@ function flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function rect(top: number, height: number, width: number): DOMRect {
|
||||
return { top, bottom: top + height, left: 0, right: width, height, width } as DOMRect;
|
||||
}
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
@@ -37,49 +41,106 @@ globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value() {},
|
||||
});
|
||||
|
||||
const questions: QuestionAnchor[] = [
|
||||
{ id: "u1", text: "first question", turn: 0 },
|
||||
{ id: "u2", text: "second question", turn: 1 },
|
||||
{ id: "u3", text: "third question", turn: 2 },
|
||||
{ id: "u1", text: "first question", turn: 0, loaded: true },
|
||||
{ id: "u2", text: "second question", turn: 1, loaded: true },
|
||||
{ id: "u3", text: "third question", turn: 2, loaded: true },
|
||||
];
|
||||
const jumps: QuestionAnchor[] = [];
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
|
||||
function ControlledJumpBar({
|
||||
loadedQuestions,
|
||||
totalQuestions,
|
||||
}: {
|
||||
loadedQuestions: QuestionAnchor[];
|
||||
totalQuestions: number;
|
||||
}) {
|
||||
const [activeTurn, setActiveTurn] = React.useState<number | null>(totalQuestions - 1);
|
||||
return (
|
||||
<QuestionJumpBar
|
||||
loadedQuestions={loadedQuestions}
|
||||
totalQuestions={totalQuestions}
|
||||
activeTurn={activeTurn}
|
||||
onJump={(question) => {
|
||||
jumps.push(question);
|
||||
setActiveTurn(question.turn);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\nquestion jump bar");
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<QuestionJumpBar questions={questions} onJump={(question) => jumps.push(question)} />
|
||||
<ControlledJumpBar key="short" loadedQuestions={questions} totalQuestions={3} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
const rail = document.querySelector(".jump-scroll") as HTMLElement;
|
||||
let bar = document.querySelector(".jump-bar") as HTMLElement;
|
||||
let rail = document.querySelector(".jump-scroll") as HTMLElement;
|
||||
bar.getBoundingClientRect = () => rect(0, 240, 56);
|
||||
rail.getBoundingClientRect = () => rect(0, 240, 32);
|
||||
|
||||
await act(async () => {
|
||||
// A real mouse activation emits both events. The rail must claim the
|
||||
// gesture only once or the second navigation can overwrite the first.
|
||||
rail.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, clientY: 0 }));
|
||||
rail.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, clientY: 0, detail: 1 }));
|
||||
// A real mouse activation emits both events. Only mousedown owns the jump.
|
||||
rail.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY: 1 }));
|
||||
rail.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY: 1, detail: 1 }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(jumps.length, 1, "one physical rail click emits one question jump");
|
||||
eq(jumps[0]?.id, "u1", "the rail keeps the question selected on pointer down");
|
||||
eq(jumps[0]?.id, "u1", "the rail maps its top directly to the first question");
|
||||
|
||||
const second = document.querySelector('[data-turn="1"]') as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
second.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, detail: 0 }));
|
||||
rail.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowDown" }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(jumps.length, 2, "keyboard activation still emits one question jump");
|
||||
eq(jumps[1]?.id, "u2", "keyboard activation jumps to the focused question");
|
||||
eq(jumps.length, 2, "one slider keypress emits one question jump");
|
||||
eq(jumps[1]?.id, "u2", "slider keyboard navigation advances by one exact question");
|
||||
eq(rail.getAttribute("aria-valuenow"), "2", "the slider exposes its active absolute question");
|
||||
|
||||
const sampled = sampledQuestionTurns(10_000, 6_789);
|
||||
eq(sampled.length, QUESTION_JUMP_MAX_MARKERS, "very long sessions keep a fixed marker count");
|
||||
eq(sampled[0], 0, "aggregated markers retain the first question");
|
||||
eq(sampled.at(-1), 9_999, "aggregated markers retain the last question");
|
||||
eq(sampled.includes(6_789), true, "aggregated markers retain the exact active question");
|
||||
eq(questionTurnFromRailY(120, 0, 240, 10_000), 5_000, "rail geometry maps to an absolute turn without reading marker layout");
|
||||
|
||||
const longQuestions: QuestionAnchor[] = [questions[0], { id: "u10000", text: "last loaded question", turn: 9_999, loaded: true }];
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<ControlledJumpBar key="long" loadedQuestions={longQuestions} totalQuestions={10_000} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
bar = document.querySelector(".jump-bar") as HTMLElement;
|
||||
rail = document.querySelector(".jump-scroll") as HTMLElement;
|
||||
bar.getBoundingClientRect = () => rect(0, 240, 56);
|
||||
rail.getBoundingClientRect = () => rect(0, 240, 32);
|
||||
const markers = Array.from(document.querySelectorAll<HTMLElement>(".jump-item"));
|
||||
eq(markers.length, QUESTION_JUMP_MAX_MARKERS, "10,000 questions render only the bounded marker set");
|
||||
for (const marker of markers) {
|
||||
marker.getBoundingClientRect = () => { throw new Error("marker geometry must not be read"); };
|
||||
}
|
||||
|
||||
const unloadedTurn = 6_789;
|
||||
await act(async () => {
|
||||
const clientY = ((unloadedTurn + 0.5) / 10_000) * 240;
|
||||
rail.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientY }));
|
||||
rail.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(jumps.at(-1)?.turn, unloadedTurn, "a long aggregated rail still targets the exact unloaded question");
|
||||
eq(jumps.at(-1)?.loaded, false, "an unloaded aggregate target keeps the lazy-load contract");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Run: tsx src/__tests__/raf-batch.test.ts
|
||||
//
|
||||
// rafBatch must flush coalesced deltas once per animation frame in the visible
|
||||
// path and request a best-effort timer flush when rAF stops while the JS task
|
||||
// queue still runs. The fake scheduler verifies the requested 200ms ordering;
|
||||
// browser throttling or a blocked main thread may deliver either callback later.
|
||||
|
||||
import { createRafBatch } from "../lib/rafBatch";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === b) {
|
||||
process.stdout.write(` PASS ${label}
|
||||
`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}
|
||||
`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
type RafCb = () => void;
|
||||
|
||||
// Fake scheduler: rAF callbacks fire only on frame(), timers only on
|
||||
// advance(), so a stalled-rAF scenario is fully controllable.
|
||||
class Scheduler {
|
||||
rafQueue = new Map<number, RafCb>();
|
||||
timers = new Map<number, { at: number; cb: RafCb }>();
|
||||
now = 0;
|
||||
nextId = 1;
|
||||
|
||||
install() {
|
||||
const s = this;
|
||||
(globalThis as Record<string, unknown>).requestAnimationFrame = (cb: RafCb) => {
|
||||
const id = s.nextId++;
|
||||
s.rafQueue.set(id, cb);
|
||||
return id;
|
||||
};
|
||||
(globalThis as Record<string, unknown>).cancelAnimationFrame = (id: number) => {
|
||||
s.rafQueue.delete(id);
|
||||
};
|
||||
(globalThis as Record<string, unknown>).setTimeout = (cb: RafCb, ms?: number) => {
|
||||
const id = s.nextId++;
|
||||
s.timers.set(id, { at: s.now + (ms ?? 0), cb });
|
||||
return id;
|
||||
};
|
||||
(globalThis as Record<string, unknown>).clearTimeout = (id: number) => {
|
||||
s.timers.delete(id);
|
||||
};
|
||||
}
|
||||
|
||||
uninstall() {
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
delete g.requestAnimationFrame;
|
||||
delete g.cancelAnimationFrame;
|
||||
delete g.setTimeout;
|
||||
delete g.clearTimeout;
|
||||
}
|
||||
|
||||
uninstallRafOnly() {
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
delete g.requestAnimationFrame;
|
||||
delete g.cancelAnimationFrame;
|
||||
}
|
||||
|
||||
frame() {
|
||||
const pending = [...this.rafQueue.keys()];
|
||||
for (const id of pending) {
|
||||
const cb = this.rafQueue.get(id);
|
||||
this.rafQueue.delete(id);
|
||||
cb?.();
|
||||
}
|
||||
}
|
||||
|
||||
advance(ms: number) {
|
||||
const target = this.now + ms;
|
||||
for (;;) {
|
||||
const due = [...this.timers.entries()]
|
||||
.filter(([, t]) => t.at <= target)
|
||||
.sort((a, b) => a[1].at - b[1].at);
|
||||
if (due.length === 0) break;
|
||||
const [id, t] = due[0];
|
||||
this.timers.delete(id);
|
||||
this.now = t.at;
|
||||
t.cb();
|
||||
}
|
||||
this.now = target;
|
||||
}
|
||||
}
|
||||
|
||||
const sched = new Scheduler();
|
||||
sched.install();
|
||||
|
||||
// --- one flush per animation frame in the visible path ---
|
||||
{
|
||||
const flushed: string[][] = [];
|
||||
const batch = createRafBatch<string>((out) => flushed.push(out));
|
||||
batch.push("a");
|
||||
batch.push("b");
|
||||
eq(flushed.length, 0, "deltas wait for a frame (rAF still scheduled)");
|
||||
sched.frame();
|
||||
eq(flushed.length, 1, "one frame produces exactly one flush");
|
||||
eq(JSON.stringify(flushed[0]), JSON.stringify(["a", "b"]), "same-frame deltas coalesce into one batch");
|
||||
batch.push("c");
|
||||
sched.frame();
|
||||
eq(flushed.length, 2, "next frame flushes the next batch");
|
||||
eq(JSON.stringify(flushed[1]), JSON.stringify(["c"]), "subsequent push lands in its own batch");
|
||||
// The stall timer must have been cancelled by the rAF flushes.
|
||||
sched.advance(1000);
|
||||
eq(flushed.length, 2, "advancing time does not double-flush after rAF flushes");
|
||||
}
|
||||
|
||||
// --- rAF stalls: the stall timer flushes after STALL_TIMEOUT_MS ---
|
||||
{
|
||||
const flushed: string[][] = [];
|
||||
const batch = createRafBatch<string>((out) => flushed.push(out));
|
||||
batch.push("thinking");
|
||||
batch.push("chunk");
|
||||
eq(flushed.length, 0, "nothing flushes while rAF is stalled");
|
||||
sched.advance(199);
|
||||
eq(flushed.length, 0, "still nothing before the stall timeout");
|
||||
sched.advance(1);
|
||||
eq(flushed.length, 1, "stall timer flushes the accumulated deltas");
|
||||
eq(JSON.stringify(flushed[0]), JSON.stringify(["thinking", "chunk"]), "stall flush delivers everything buffered");
|
||||
sched.advance(1000);
|
||||
eq(flushed.length, 1, "stall flush is a one-shot; no repeated flushes");
|
||||
}
|
||||
|
||||
// --- drain() flushes immediately and cancels the pending stall timer ---
|
||||
{
|
||||
const flushed: string[][] = [];
|
||||
const batch = createRafBatch<string>((out) => flushed.push(out));
|
||||
batch.push("x");
|
||||
batch.drain();
|
||||
eq(flushed.length, 1, "drain flushes immediately");
|
||||
eq(JSON.stringify(flushed[0]), JSON.stringify(["x"]), "drain delivers the buffered delta");
|
||||
eq(batch.size(), 0, "buffer is empty after drain");
|
||||
sched.advance(1000);
|
||||
eq(flushed.length, 1, "drain cancels rAF and the stall timer");
|
||||
}
|
||||
|
||||
// --- re-entrant push() during flush lands in the next batch ---
|
||||
{
|
||||
const flushed: string[][] = [];
|
||||
let nextRef: { push: (v: string) => void } | undefined;
|
||||
const batch = createRafBatch<string>((out) => {
|
||||
flushed.push(out);
|
||||
if (out[0] === "first" && nextRef) nextRef.push("reentrant");
|
||||
});
|
||||
nextRef = batch;
|
||||
batch.push("first");
|
||||
sched.frame();
|
||||
eq(flushed.length, 1, "first flush ran");
|
||||
sched.frame();
|
||||
eq(flushed.length, 2, "re-entrant push was flushed on the next frame");
|
||||
eq(JSON.stringify(flushed[1]), JSON.stringify(["reentrant"]), "re-entrant delta coalesces into its own batch");
|
||||
}
|
||||
|
||||
// --- microtask fallback still works without rAF (SSR / JSDOM) ---
|
||||
{
|
||||
sched.uninstallRafOnly(); // keep fake timers; only rAF is absent
|
||||
const flushed: string[][] = [];
|
||||
const batch = createRafBatch<string>((out) => flushed.push(out));
|
||||
batch.push("y");
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
eq(flushed.length, 1, "microtask fallback flushes without rAF");
|
||||
sched.install();
|
||||
}
|
||||
|
||||
process.stdout.write(`
|
||||
${passed} passed, ${failed} failed
|
||||
`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -32,6 +32,12 @@ eq(
|
||||
eq(formatSearchFootnotesMarkdown([{ title: "bad", url: "javascript:alert(1)" }]), "\n- **bad**\n", "unsafe URLs are dropped");
|
||||
eq(parseSearchSources("新闻本文\nhttps://example.com/a\nNo URL").length, 2, "output parser keeps title-only hits");
|
||||
eq(parseSearchSources("新闻本文\nhttps://example.com/a")[0]?.title, "新闻本文", "output parser keeps the title");
|
||||
eq(parseSearchSources("新闻本文\r\nhttps://example.com/a\r\n")[0]?.url, "https://example.com/a", "output parser tolerates CRLF line endings");
|
||||
const degraded = parseSearchSources("- **新闻本文**\n <https://example.com/a>");
|
||||
eq(degraded.length, 1, "degraded footnote-markdown dump still resolves to one source");
|
||||
eq(degraded[0]?.title, "新闻本文", "degraded dump strips the bullet and bold markers from the title");
|
||||
eq(degraded[0]?.url, "https://example.com/a", "degraded dump unwraps the autolink URL");
|
||||
eq(parseSearchSources("<https://example.com/a>")[0]?.url, "https://example.com/a", "autolink-only lines parse as URL sources");
|
||||
eq(mergeSearchSources([{ title: "A", url: "https://a.example" }], [{ title: "A", url: "https://a.example" }]).length, 1, "duplicate hits collapse");
|
||||
|
||||
const history = historyMessagesToItems([{
|
||||
|
||||
@@ -525,6 +525,14 @@ const botsRootEl = document.createElement("div");
|
||||
document.body.appendChild(botsRootEl);
|
||||
const botsRoot = createRoot(botsRootEl);
|
||||
const botsSettings = baseSettings("standard");
|
||||
botsSettings.bot.dingtalk = {
|
||||
enabled: true,
|
||||
clientId: "dinghuspf88znepnhwfp",
|
||||
clientSecretEnv: "DINGTALK_CLIENT_SECRET",
|
||||
secretSet: true,
|
||||
botName: "",
|
||||
requireMention: true,
|
||||
};
|
||||
botsSettings.bot.connections = [
|
||||
{
|
||||
id: "conn-feishu-1",
|
||||
@@ -569,7 +577,7 @@ ok(!document.getElementById("bot-step-access"), "bots tab omits the old global a
|
||||
ok(!document.getElementById("bot-step-behavior"), "bots tab omits global default behavior card");
|
||||
eq(document.querySelectorAll(".bot-step-chip").length, 0, "hero no longer shows the old two-step chips");
|
||||
|
||||
eq(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]").length, 4, "bot manager uses four fixed channel tabs on the left");
|
||||
eq(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]").length, 5, "bot manager uses five fixed channel tabs on the left");
|
||||
ok(document.querySelector(".bot-channel-setup-card")?.textContent?.includes("Configure QQ") === true, "unconfigured QQ tab shows key setup on the right");
|
||||
ok(document.body.textContent?.includes("Back to entry") === false, "bot manager does not show a return-to-entry action");
|
||||
|
||||
@@ -598,6 +606,109 @@ ok(document.body.textContent?.includes("ou_mock_user_001") === true, "selected b
|
||||
ok(document.body.textContent?.includes("Legacy global allowlist") === true, "advanced area keeps the legacy global allowlist");
|
||||
ok(document.querySelector(".bot-simple-advanced")?.textContent?.includes("local control API") === false, "advanced area no longer owns mobile/control API setup");
|
||||
|
||||
// DingTalk channel: a persisted ClientID must round-trip back into the UI.
|
||||
// The unconfigured setup form and the configured detail card both show it;
|
||||
// blur-save and reload must not blank the field.
|
||||
const persistedDingtalkSettings = () => {
|
||||
const s = baseSettings("standard");
|
||||
s.bot.dingtalk = {
|
||||
enabled: true,
|
||||
clientId: "dinghuspf88znepnhwfp",
|
||||
clientSecretEnv: "DINGTALK_CLIENT_SECRET",
|
||||
secretSet: true,
|
||||
botName: "",
|
||||
requireMention: true,
|
||||
};
|
||||
return s;
|
||||
};
|
||||
let dingtalkSettings = persistedDingtalkSettings();
|
||||
let dingtalkTestCalls = 0;
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
Settings: async () => dingtalkSettings,
|
||||
SetBotSettings: async (next: typeof dingtalkSettings) => {
|
||||
dingtalkSettings = next;
|
||||
},
|
||||
SetBotSecret: async () => {},
|
||||
TestDingtalkBot: async () => {
|
||||
dingtalkTestCalls += 1;
|
||||
return { id: "dingtalk", label: "DingTalk", status: "ok", message: "测试消息已发送,请检查钉钉会话。", messageId: "mock-dingtalk-id", phase: "send", code: "dingtalk_test_send_ok", reportKind: "", reportDetail: "", occurredAt: new Date().toISOString() };
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
const dingtalkTab = Array.from(botsRootEl.querySelectorAll(".bot-channel-tabs [role=\"tab\"]")).find((button) => button.textContent?.includes("DingTalk")) as HTMLButtonElement | undefined;
|
||||
if (!dingtalkTab) throw new Error("DingTalk channel tab did not render");
|
||||
await act(async () => {
|
||||
dingtalkTab.click();
|
||||
await flushPromises();
|
||||
});
|
||||
// Secret is set and the bot is enabled: the configured detail card
|
||||
// shows the persisted ClientID (the "input disappeared" regression).
|
||||
await waitFor("DingTalk detail card with ClientID", () => {
|
||||
const card = botsRootEl.querySelector(".bot-channel-manager__detail .bot-detail-card");
|
||||
const hasClientId = card?.textContent?.includes("dinghuspf88znepnhwfp") === true;
|
||||
return Boolean(card) && hasClientId;
|
||||
});
|
||||
const dingtalkDetailClientId = Array.from(botsRootEl.querySelectorAll("input[aria-label]")).find((input) => input.getAttribute("aria-label")?.includes("Client ID")) as HTMLInputElement | undefined;
|
||||
eq(dingtalkDetailClientId?.value, "dinghuspf88znepnhwfp", "persisted ClientID is visible in the DingTalk detail card");
|
||||
// DingTalk test-send entry: the detail card exposes a test-send button that
|
||||
// calls TestDingtalkBot and surfaces the result notice.
|
||||
const dingtalkTestButtons = Array.from(botsRootEl.querySelectorAll(".bot-channel-manager__detail .bot-detail-card__actions .btn")).filter((button) => /test|测试|測試|傳送/i.test(button.textContent ?? ""));
|
||||
eq(dingtalkTestButtons.length, 1, "DingTalk detail card exposes a test-send button");
|
||||
await act(async () => {
|
||||
(dingtalkTestButtons[0] as HTMLButtonElement).click();
|
||||
await flushPromises();
|
||||
});
|
||||
eq(dingtalkTestCalls, 1, "test-send button invokes TestDingtalkBot");
|
||||
await waitFor("DingTalk test-send result notice", () =>
|
||||
botsRootEl.querySelector(".bot-channel-manager__detail .bot-detail-notice")?.textContent?.includes("测试消息已发送") === true);
|
||||
// Regression: a ClientID alone must NOT flip the channel into its configured
|
||||
// detail card. Only an enabled bot with a set secret does. Mount with
|
||||
// enabled=false + secretSet=true + clientId set; the setup panel (not the
|
||||
// detail card) must show.
|
||||
const notEnabledRootEl = document.createElement("div");
|
||||
document.body.appendChild(notEnabledRootEl);
|
||||
const notEnabledRoot = createRoot(notEnabledRootEl);
|
||||
const notEnabledSettings = baseSettings("standard");
|
||||
notEnabledSettings.bot.dingtalk = {
|
||||
enabled: false,
|
||||
clientId: "dinghuspf88znepnhwfp",
|
||||
clientSecretEnv: "DINGTALK_CLIENT_SECRET",
|
||||
secretSet: true,
|
||||
botName: "",
|
||||
requireMention: true,
|
||||
};
|
||||
window.go = {
|
||||
main: { App: { Settings: async () => notEnabledSettings } } as Partial<AppBindings> as AppBindings,
|
||||
};
|
||||
await act(async () => {
|
||||
notEnabledRoot.render(
|
||||
<LocaleProvider>
|
||||
<SettingsPanel initialTab="bots" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
const notEnabledTab = Array.from(notEnabledRootEl.querySelectorAll(".bot-channel-tabs [role=\"tab\"]")).find((button) => button.textContent?.includes("DingTalk")) as HTMLButtonElement | undefined;
|
||||
if (!notEnabledTab) throw new Error("DingTalk channel tab did not render (not-enabled case)");
|
||||
await act(async () => {
|
||||
notEnabledTab.click();
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("DingTalk setup panel instead of detail card when not enabled", () => {
|
||||
const detailCard = notEnabledRootEl.querySelector(".bot-channel-manager__detail .bot-detail-card");
|
||||
const setupCard = notEnabledRootEl.querySelector(".bot-channel-manager__detail .bot-channel-setup-card");
|
||||
return Boolean(setupCard) && detailCard === null;
|
||||
});
|
||||
await act(async () => {
|
||||
notEnabledRoot.unmount();
|
||||
});
|
||||
window.go = {
|
||||
main: { App: { Settings: async () => botsSettings } } as Partial<AppBindings> as AppBindings,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
botsRoot.unmount();
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { useController } from "../lib/useController";
|
||||
import { verifyDeferredHistoryCloseRace, verifyStaleHistoryFingerprint } from "./deferred-history-close-race";
|
||||
import { historySliceFromMessages } from "./mockHistorySlice";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySlice, HistorySliceRequest, JobView, Meta, TabMeta, TopicActivationEvent, TopicActivationRequest, WireEvent } from "../lib/types";
|
||||
|
||||
@@ -381,6 +382,11 @@ window.go = {
|
||||
cancelCalls.push(tabID);
|
||||
runningTabs.delete(tabID);
|
||||
},
|
||||
CloseTabWithPolicy: async (tabID: string) => {
|
||||
tabsById.delete(tabID);
|
||||
runningTabs.delete(tabID);
|
||||
if (backendActiveId === tabID) backendActiveId = "tab-a";
|
||||
},
|
||||
SubmitToTab: async (tabID: string) => {
|
||||
runningTabs.add(tabID);
|
||||
if (tabID === "tab-c") await submitTabCGate.promise;
|
||||
@@ -904,24 +910,18 @@ await act(async () => {
|
||||
});
|
||||
await waitFor("tab-l metadata advances", () => controller?.state.meta?.sessionRevision === 2);
|
||||
historyLOlder = deferred<HistorySlice>();
|
||||
let olderLoad: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
olderLoad = controller?.loadOlderHistory("tab-l");
|
||||
await flushPromises();
|
||||
await verifyStaleHistoryFingerprint({
|
||||
olderPage: historyLOlder, loadOlderHistory: () => controller?.loadOlderHistory("tab-l"),
|
||||
historyCalls: () => historyLCalls, waitFor, flushPromises, equal: eq,
|
||||
getState: () => controller?.state,
|
||||
});
|
||||
await waitFor("tab-l older page request", () => historyLCalls === 2);
|
||||
historyLOlder.resolve(historySliceFromMessages(
|
||||
"tab-l",
|
||||
[userMessage("stale older L")],
|
||||
{ cursor: "", turns: 12 },
|
||||
{ revision: 1, digest: "digest-l-v1" },
|
||||
));
|
||||
await act(async () => {
|
||||
await olderLoad;
|
||||
await flushPromises();
|
||||
|
||||
historyLOlder = deferred<HistorySlice>();
|
||||
await verifyDeferredHistoryCloseRace({
|
||||
olderPage: historyLOlder, loadOlderHistory: () => controller?.loadOlderHistory("tab-l"),
|
||||
closeTab: async () => Boolean(await controller?.closeTab("tab-l", "stop_and_close")),
|
||||
historyCalls: () => historyLCalls, waitFor, flushPromises, equal: eq, sessionPath: tabL.sessionPath,
|
||||
});
|
||||
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "stale older L") ?? false), "stale older page is discarded after session fingerprint changes");
|
||||
eq(controller?.state.historyOlderLoading, false, "stale older page releases its loading state");
|
||||
|
||||
// The backend transcript and sidecar are separate durable files. If a save
|
||||
// advances between those reads, hydration must reconcile the pair instead of
|
||||
|
||||
@@ -7,13 +7,17 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
|
||||
const moreMenuSource = [
|
||||
readFileSync(resolve(testDir, "../components/TopicbarMoreMenu.tsx"), "utf8"),
|
||||
readFileSync(resolve(testDir, "../components/TopicbarMoreMenuContent.tsx"), "utf8"),
|
||||
].join("\n");
|
||||
|
||||
assert.doesNotMatch(appSource, /t\("shortcuts\.cheatsheetTitle"\)|t\("topicBar\.command"\)/);
|
||||
|
||||
const taskSummaryControlIndex = appSource.indexOf('<Tooltip label={t("summary.session")}>');
|
||||
const workspaceToggleIndex = appSource.indexOf('<Tooltip label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>');
|
||||
const taskSummaryControlIndex = moreMenuSource.indexOf('t("summary.session")');
|
||||
const workspaceToggleIndex = appSource.indexOf('<Tooltip label={effectiveWorkspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>');
|
||||
assert.ok(taskSummaryControlIndex >= 0, "topic bar renders the localized Session summary control");
|
||||
assert.ok(workspaceToggleIndex > taskSummaryControlIndex, "Session summary precedes the right-edge workspace toggle");
|
||||
assert.ok(!appSource.includes('aria-label="Session summary"'), "Session summary does not use a hard-coded English label");
|
||||
assert.ok(workspaceToggleIndex >= 0, "topic bar keeps the right-edge workspace toggle");
|
||||
assert.ok(!moreMenuSource.includes('aria-label="Session summary"'), "Session summary does not use a hard-coded English label");
|
||||
|
||||
process.stdout.write("topicbar controls: 2 contracts passed\n");
|
||||
@@ -0,0 +1,170 @@
|
||||
// Run: tsx src/__tests__/topicbar-more-menu.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import { registerHooks } from "node:module";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { TopicbarMoreMenu } from "../components/TopicbarMoreMenu";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.endsWith(".css")) {
|
||||
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
function press(target: Element, key: string) {
|
||||
target.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
function directMenuItems(menu: HTMLElement): HTMLButtonElement[] {
|
||||
return Array.from(menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'))
|
||||
.filter((item) => item.closest('[role="menu"]') === menu);
|
||||
}
|
||||
|
||||
console.log("\ntopicbar more menu keyboard navigation");
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("missing root");
|
||||
const root = createRoot(rootElement);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<TopicbarMoreMenu
|
||||
sessionHasContent
|
||||
getSessionMarkdown={() => "# Session"}
|
||||
exportSession={() => {}}
|
||||
openChangedDock={() => {}}
|
||||
toggleTerminal={() => {}}
|
||||
openSessionSummary={() => {}}
|
||||
tasksOpen={false}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
const trigger = rootElement.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]');
|
||||
if (!trigger) throw new Error("missing more-menu trigger");
|
||||
|
||||
await act(async () => {
|
||||
trigger.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("lazy menu content", () => rootElement.querySelector('[role="menu"]') !== null);
|
||||
|
||||
let menu = rootElement.querySelector<HTMLElement>('.topicbar__more-menu[role="menu"]');
|
||||
if (!menu) throw new Error("missing more menu");
|
||||
let items = directMenuItems(menu);
|
||||
ok(document.activeElement === items[0], "opening the menu moves focus to the first action");
|
||||
|
||||
await act(async () => {
|
||||
press(items[0]!, "ArrowDown");
|
||||
});
|
||||
ok(document.activeElement === items[1], "ArrowDown advances focus to the next action");
|
||||
|
||||
await act(async () => {
|
||||
press(items[1]!, "End");
|
||||
});
|
||||
ok(document.activeElement === items[items.length - 1], "End moves focus to the final action");
|
||||
|
||||
await act(async () => {
|
||||
press(items[items.length - 1]!, "Home");
|
||||
});
|
||||
ok(document.activeElement === items[0], "Home returns focus to the first action");
|
||||
|
||||
await act(async () => {
|
||||
items[1]?.focus();
|
||||
press(items[1]!, "ArrowRight");
|
||||
await flushTimers();
|
||||
});
|
||||
const exportMenu = rootElement.querySelector<HTMLElement>('.topicbar__more-export-menu[role="menu"]');
|
||||
if (!exportMenu) throw new Error("missing export submenu");
|
||||
const exportItems = directMenuItems(exportMenu);
|
||||
ok(document.activeElement === exportItems[0], "ArrowRight opens the export submenu and focuses its first action");
|
||||
|
||||
await act(async () => {
|
||||
press(exportItems[0]!, "ArrowDown");
|
||||
});
|
||||
ok(document.activeElement === exportItems[1], "submenu ArrowDown stays within the submenu");
|
||||
|
||||
await act(async () => {
|
||||
press(exportItems[1]!, "ArrowLeft");
|
||||
await flushTimers();
|
||||
});
|
||||
ok(rootElement.querySelector(".topicbar__more-export-menu") === null, "ArrowLeft closes the export submenu");
|
||||
ok(document.activeElement === items[1], "closing the export submenu restores focus to its trigger");
|
||||
|
||||
await act(async () => {
|
||||
press(items[1]!, "Escape");
|
||||
await flushTimers();
|
||||
});
|
||||
ok(rootElement.querySelector(".topicbar__more-menu") === null, "Escape closes the main menu");
|
||||
ok(document.activeElement === trigger, "closing the main menu restores focus to the More trigger");
|
||||
|
||||
await act(async () => {
|
||||
press(trigger, "ArrowUp");
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("keyboard-opened menu", () => rootElement.querySelector(".topicbar__more-menu") !== null);
|
||||
menu = rootElement.querySelector<HTMLElement>('.topicbar__more-menu[role="menu"]');
|
||||
if (!menu) throw new Error("missing keyboard-opened menu");
|
||||
items = directMenuItems(menu);
|
||||
ok(document.activeElement === items[items.length - 1], "ArrowUp on the trigger opens the menu at its final action");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
console.log(`\ntopicbar more menu: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,287 @@
|
||||
// Run: tsx src/__tests__/transcript-anchor-compensation-race.test.tsx
|
||||
//
|
||||
// W1 scroll-policy races, split out of transcript-recovery-race.test.tsx
|
||||
// (800-line test-file ceiling): the bottom-hold tail-follow re-entry policy
|
||||
// (#8709/#9099) and the steady-state manual-mode anchor compensation
|
||||
// (#8438/#8488/#8897). Same JSDOM + fake rAF/clock harness with a stubbed
|
||||
// VirtuosoHandle as the recovery race file.
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { VirtuosoHandle } from "react-virtuoso";
|
||||
import type { TranscriptScrollWriteRecord } from "../lib/transcriptScrollProbe";
|
||||
import { useTranscriptScrollArbiter } from "../lib/useTranscriptScrollArbiter";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function check(condition: unknown, label: string) {
|
||||
if (condition) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\ntranscript bottom-hold and anchor compensation races");
|
||||
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div><div id="scroll"><div class="transcript__row" data-row-key="row-a"></div></div></body></html>', {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.Node = dom.window.Node;
|
||||
|
||||
let nextFrame = 1;
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
const requestFrame = (callback: FrameRequestCallback) => {
|
||||
const id = nextFrame;
|
||||
nextFrame += 1;
|
||||
frames.set(id, callback);
|
||||
return id;
|
||||
};
|
||||
const cancelFrame = (id: number) => void frames.delete(id);
|
||||
globalThis.requestAnimationFrame = requestFrame;
|
||||
globalThis.cancelAnimationFrame = cancelFrame;
|
||||
dom.window.requestAnimationFrame = requestFrame;
|
||||
dom.window.cancelAnimationFrame = cancelFrame;
|
||||
|
||||
let clockNow = 10_000;
|
||||
let nextTimer = 1;
|
||||
const timers = new Map<number, { dueAt: number; run: () => void }>();
|
||||
const originalDateNow = Date.now;
|
||||
const originalSetTimeout = dom.window.setTimeout;
|
||||
const originalClearTimeout = dom.window.clearTimeout;
|
||||
Date.now = () => clockNow;
|
||||
dom.window.setTimeout = ((handler: TimerHandler, timeout = 0, ...args: unknown[]) => {
|
||||
const id = nextTimer;
|
||||
nextTimer += 1;
|
||||
const run = typeof handler === "function"
|
||||
? () => handler(...args)
|
||||
: () => { throw new Error("string timer handlers are unsupported in this test"); };
|
||||
timers.set(id, { dueAt: clockNow + Math.max(0, timeout), run });
|
||||
return id;
|
||||
}) as typeof dom.window.setTimeout;
|
||||
dom.window.clearTimeout = ((id: number | undefined) => {
|
||||
if (id !== undefined) timers.delete(id);
|
||||
}) as typeof dom.window.clearTimeout;
|
||||
|
||||
async function advanceClock(milliseconds: number) {
|
||||
await act(async () => {
|
||||
const target = clockNow + milliseconds;
|
||||
while (true) {
|
||||
const next = [...timers.entries()]
|
||||
.filter(([, timer]) => timer.dueAt <= target)
|
||||
.sort(([leftID, left], [rightID, right]) => left.dueAt - right.dueAt || leftID - rightID)[0];
|
||||
if (!next) break;
|
||||
const [id, timer] = next;
|
||||
timers.delete(id);
|
||||
clockNow = timer.dueAt;
|
||||
timer.run();
|
||||
}
|
||||
clockNow = target;
|
||||
});
|
||||
}
|
||||
|
||||
async function flushFrames() {
|
||||
const pending = [...frames.entries()];
|
||||
frames.clear();
|
||||
await act(async () => pending.forEach(([, callback]) => callback(performance.now())));
|
||||
}
|
||||
|
||||
// Runtime capture of every imperative scroll write (Phase 0 probe).
|
||||
const scrollWrites: TranscriptScrollWriteRecord[] = [];
|
||||
dom.window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => { scrollWrites.push(write); };
|
||||
|
||||
const rectAt = (top: number) => ({ top, bottom: top + 100, height: 100, left: 0, right: 800, width: 800, x: 0, y: top, toJSON: () => ({}) });
|
||||
|
||||
const scrollElement = dom.window.document.getElementById("scroll") as HTMLDivElement;
|
||||
const rowElement = scrollElement.querySelector<HTMLElement>(".transcript__row")!;
|
||||
scrollElement.getBoundingClientRect = () => rectAt(0);
|
||||
rowElement.getBoundingClientRect = () => rectAt(200);
|
||||
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 100 });
|
||||
let scrollExtent = 500;
|
||||
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, get: () => scrollExtent });
|
||||
Object.defineProperty(scrollElement, "scrollTop", { configurable: true, writable: true, value: 0 });
|
||||
Object.defineProperty(scrollElement, "offsetWidth", { configurable: true, value: 800 });
|
||||
Object.defineProperty(scrollElement, "clientWidth", { configurable: true, value: 780 });
|
||||
Object.defineProperty(scrollElement, "clientLeft", { configurable: true, value: 0 });
|
||||
|
||||
const virtuosoHandle = {
|
||||
scrollBy: () => {},
|
||||
scrollToIndex: () => {},
|
||||
// Browser semantics: an offset write clamps against the current extent.
|
||||
scrollTo: (options?: { top?: number }) => {
|
||||
const top = options?.top ?? 0;
|
||||
scrollElement.scrollTop = Math.max(0, Math.min(scrollExtent - scrollElement.clientHeight, top));
|
||||
},
|
||||
getState: () => {},
|
||||
} as unknown as VirtuosoHandle;
|
||||
let arbiter: ReturnType<typeof useTranscriptScrollArbiter> | undefined;
|
||||
|
||||
function Probe() {
|
||||
arbiter = useTranscriptScrollArbiter();
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(dom.window.document.getElementById("root")!);
|
||||
await act(async () => root.render(<Probe />));
|
||||
await act(async () => {
|
||||
(arbiter!.virtuosoRef as { current: VirtuosoHandle | null }).current = virtuosoHandle;
|
||||
arbiter!.scrollerRef(scrollElement);
|
||||
});
|
||||
|
||||
const wheel = async (deltaY: number) => act(async () => {
|
||||
arbiter?.onWheelIntent({
|
||||
ctrlKey: false,
|
||||
deltaX: 0,
|
||||
deltaY,
|
||||
target: scrollElement,
|
||||
} as React.WheelEvent<HTMLElement>);
|
||||
});
|
||||
const wheelDown = () => wheel(40);
|
||||
|
||||
// ── Bottom-hold re-entry (#8709/#9099): auto re-entry into tail-follow
|
||||
// requires the bottom to be HELD — two consecutive at-bottom deliveries inside
|
||||
// one reader-intent window with no upward gesture in between. A single
|
||||
// touch-down claims the gesture but stays manual.
|
||||
scrollElement.scrollTop = 400;
|
||||
await act(async () => arbiter?.releaseTailFollow());
|
||||
await wheelDown();
|
||||
check(arbiter?.modeRef.current === "manual", "a single downward touch-down stays manual");
|
||||
await wheelDown();
|
||||
check(arbiter?.modeRef.current === "tail-follow", "a held bottom (second delivery) re-enters tail-follow");
|
||||
|
||||
// An upward gesture between at-bottom deliveries breaks the hold streak; the
|
||||
// next downward gesture restarts it from zero.
|
||||
await act(async () => arbiter?.releaseTailFollow());
|
||||
await wheelDown();
|
||||
check(arbiter?.modeRef.current === "manual", "one at-bottom delivery starts the hold without re-entering");
|
||||
await wheel(-40);
|
||||
scrollElement.scrollTop = 300;
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
scrollElement.scrollTop = 400;
|
||||
await wheelDown();
|
||||
check(arbiter?.modeRef.current === "manual", "an upward gesture between at-bottom deliveries breaks the hold");
|
||||
await wheelDown();
|
||||
check(arbiter?.modeRef.current === "tail-follow", "two consecutive held deliveries after the reset re-enter tail-follow");
|
||||
|
||||
// The 180ms idle close performs one final native delivery so a large wheel or
|
||||
// touch gesture that clamps at the physical bottom can complete the hold even
|
||||
// when the browser emits no second scroll event. The completed transition
|
||||
// still resets the streak before a fresh reader-intent window begins.
|
||||
await act(async () => arbiter?.releaseTailFollow());
|
||||
await wheelDown();
|
||||
await advanceClock(200);
|
||||
check(arbiter?.modeRef.current === "tail-follow", "idle close re-samples a held physical bottom before ending intent");
|
||||
await act(async () => arbiter?.releaseTailFollow());
|
||||
await wheelDown();
|
||||
check(arbiter?.modeRef.current === "manual", "a fresh intent window rebuilds the bottom hold from zero");
|
||||
await wheelDown();
|
||||
check(arbiter?.modeRef.current === "tail-follow", "the fresh window re-enters tail-follow after its second delivery");
|
||||
|
||||
// A thumb gesture that reaches the frozen native bottom claims the tail when
|
||||
// the drag's own deliveries already held the bottom before release resumes
|
||||
// real row measurements and changes the extent.
|
||||
await act(async () => arbiter?.reset());
|
||||
scrollElement.scrollTop = 0;
|
||||
await act(async () => arbiter?.onPointerDownIntent({
|
||||
button: 0,
|
||||
nativeEvent: { button: 0, clientX: 795 },
|
||||
} as React.PointerEvent<HTMLElement>));
|
||||
scrollElement.scrollTop = 400;
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
await act(async () => window.dispatchEvent(new dom.window.Event("pointerup")));
|
||||
check(arbiter?.modeRef.current === "tail-follow", "native thumb release after a held physical bottom resumes tail-follow");
|
||||
scrollExtent = 900;
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
await flushFrames();
|
||||
await flushFrames();
|
||||
await flushFrames();
|
||||
check(scrollElement.scrollTop === 800, "post-release remeasurement reconverges the claimed native bottom");
|
||||
scrollExtent = 500;
|
||||
|
||||
// ── Manual-mode viewport anchor compensation (#8438/#8488/#8897): an
|
||||
// above-viewport height change (fold auto-collapse, history patch) must not
|
||||
// push the reading position. The drift is measured against the anchor sampled
|
||||
// on the last delivered scroll and corrected through exactly one arbiter-owned
|
||||
// offset write; growth below the viewport and ownership changes earn none.
|
||||
await act(async () => arbiter?.reset());
|
||||
scrollElement.appendChild(rowElement);
|
||||
// Real-geometry stub: the row's client position tracks scrollTop like a
|
||||
// browser's would (document top 150).
|
||||
rowElement.getBoundingClientRect = () => rectAt(150 - scrollElement.scrollTop);
|
||||
scrollElement.scrollTop = 100;
|
||||
await act(async () => arbiter?.setMode("manual", "test-anchor-compensation"));
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
scrollWrites.length = 0;
|
||||
// A fold above the viewport expands: extent and the row both move +200.
|
||||
scrollExtent = 700;
|
||||
rowElement.getBoundingClientRect = () => rectAt(350 - scrollElement.scrollTop);
|
||||
await act(async () => arbiter?.followGrowingTail());
|
||||
await flushFrames(); // followGrowingTail frame: LAYOUT_HEIGHT_CHANGED + compensation scheduled
|
||||
await flushFrames(); // compensation measures drift and writes once
|
||||
await flushFrames(); // stable frame 1
|
||||
await flushFrames(); // stable frame 2: done
|
||||
const compensationWrites = scrollWrites.filter((write) => write.owner === "anchor-compensation");
|
||||
check(compensationWrites.length === 1, `above-viewport growth in manual mode emits exactly one anchor-compensation write (${compensationWrites.length})`);
|
||||
check(compensationWrites[0]?.top === 300 && scrollElement.scrollTop === 300, "the compensation restores the anchor row's viewport offset");
|
||||
check(arbiter?.modeRef.current === "manual", "anchor compensation preserves manual reading ownership");
|
||||
check(rowElement.getBoundingClientRect().top === 50, "the anchor row is physically back at its pre-change offset");
|
||||
|
||||
// Growth below the viewport (streaming tail) leaves the anchor row put:
|
||||
// zero measured drift, zero writes.
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
scrollWrites.length = 0;
|
||||
scrollExtent = 900;
|
||||
await act(async () => arbiter?.followGrowingTail());
|
||||
for (let i = 0; i < 4; i += 1) await flushFrames();
|
||||
check(
|
||||
scrollWrites.filter((write) => write.owner === "anchor-compensation").length === 0,
|
||||
"below-viewport growth earns no compensation write",
|
||||
);
|
||||
check(scrollElement.scrollTop === 300, "below-viewport growth leaves the reading position untouched");
|
||||
|
||||
// A collapse above the viewport (CONTENT_SHRINK path) compensates upward.
|
||||
scrollWrites.length = 0;
|
||||
scrollExtent = 600;
|
||||
rowElement.getBoundingClientRect = () => rectAt(150 - scrollElement.scrollTop);
|
||||
await act(async () => arbiter?.followGrowingTail());
|
||||
for (let i = 0; i < 4; i += 1) await flushFrames();
|
||||
const shrinkWrites = scrollWrites.filter((write) => write.owner === "anchor-compensation");
|
||||
check(shrinkWrites.length === 1 && shrinkWrites[0]?.top === 100, "an above-viewport collapse compensates upward exactly once");
|
||||
check(scrollElement.scrollTop === 100, "the upward compensation restores the anchor offset");
|
||||
|
||||
// A user gesture mid-compensation cancels the loop: the reader owns the
|
||||
// viewport from there on.
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
scrollWrites.length = 0;
|
||||
scrollExtent = 800;
|
||||
rowElement.getBoundingClientRect = () => rectAt(350 - scrollElement.scrollTop);
|
||||
await act(async () => arbiter?.followGrowingTail());
|
||||
await flushFrames(); // schedules the compensation
|
||||
await act(async () => arbiter?.releaseTailFollow());
|
||||
for (let i = 0; i < 4; i += 1) await flushFrames();
|
||||
check(
|
||||
scrollWrites.filter((write) => write.owner === "anchor-compensation").length === 0,
|
||||
"user scroll intent cancels a pending anchor compensation",
|
||||
);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
Date.now = originalDateNow;
|
||||
dom.window.setTimeout = originalSetTimeout;
|
||||
dom.window.clearTimeout = originalClearTimeout;
|
||||
dom.window.close();
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n${failed} transcript anchor compensation race test(s) failed; ${passed} passed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\n${passed} transcript anchor compensation race tests passed.`);
|
||||
@@ -12,8 +12,17 @@ import {
|
||||
estimateTranscriptTextHeight,
|
||||
TRANSCRIPT_ESTIMATED_LINE_COLUMNS,
|
||||
} from "../lib/transcriptRowEstimates";
|
||||
import { estimateTranscriptRowSize, type TranscriptRow } from "../lib/transcriptRows";
|
||||
import {
|
||||
buildTranscriptRows,
|
||||
buildTurnModels,
|
||||
EMPTY_FOLDS,
|
||||
estimateTranscriptRowSize,
|
||||
transcriptRowMeasurementVersion,
|
||||
type FoldMap,
|
||||
type TranscriptRow,
|
||||
} from "../lib/transcriptRows";
|
||||
import { createTranscriptMeasuredSizes } from "../lib/transcriptMeasuredSizes";
|
||||
import { initialState, reducer, type Item } from "../lib/useController";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -96,15 +105,111 @@ function fakeRow(key: string, kind: TranscriptRow["kind"], text: string): Transc
|
||||
return { key, kind, item: { id: key, text } } as unknown as TranscriptRow;
|
||||
}
|
||||
|
||||
function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, row: TranscriptRow, width?: number) {
|
||||
return store.synthesize([row], width)[0];
|
||||
}
|
||||
|
||||
{
|
||||
const store = createTranscriptMeasuredSizes();
|
||||
const row = fakeRow("r1", "answer", "hello");
|
||||
ok(store.estimateFor(row) === estimateTranscriptRowSize(row), "no data falls back to the static prior");
|
||||
store.record("r1", "answer", 432);
|
||||
eq(store.estimateFor(row), 432, "exact rowKey measurement wins");
|
||||
ok(estimateFor(store, row) === estimateTranscriptRowSize(row), "no data falls back to the static prior");
|
||||
store.record("r1", "answer", 432, undefined, transcriptRowMeasurementVersion(row));
|
||||
eq(estimateFor(store, row), 432, "exact rowKey measurement wins");
|
||||
store.record("r1", "answer", 0);
|
||||
store.record("r1", "answer", Number.NaN);
|
||||
eq(store.estimateFor(row), 432, "non-positive and NaN measurements are ignored");
|
||||
eq(estimateFor(store, row), 432, "non-positive and NaN measurements are ignored");
|
||||
}
|
||||
|
||||
{
|
||||
const store = createTranscriptMeasuredSizes();
|
||||
const before = fakeRow("late", "answer", "preview");
|
||||
const after = fakeRow("late", "answer", "resolved content");
|
||||
store.record("late", "answer", 777, 960, transcriptRowMeasurementVersion(before));
|
||||
eq(estimateFor(store, before, 960), 777, "an exact sample matches its row content version");
|
||||
ok(estimateFor(store, after, 960) !== 777, "the same row key and width reject an old content version");
|
||||
}
|
||||
|
||||
{
|
||||
const store = createTranscriptMeasuredSizes();
|
||||
const changedBefore = fakeRow("changed", "answer", "preview");
|
||||
const changedAfter = fakeRow("changed", "answer", "resolved");
|
||||
const untouched = fakeRow("untouched", "answer", "stable");
|
||||
store.record("changed", "answer", 900, 960, transcriptRowMeasurementVersion(changedBefore));
|
||||
store.record("untouched", "answer", 240, 960, transcriptRowMeasurementVersion(untouched));
|
||||
const estimates = store.synthesize([changedAfter, untouched], 960);
|
||||
ok(estimates[0] !== 900, "patching one row invalidates its own exact sample");
|
||||
eq(estimates[1], 240, "patching one row preserves another row's exact sample");
|
||||
}
|
||||
|
||||
{
|
||||
const store = createTranscriptMeasuredSizes();
|
||||
const staleBefore = fakeRow("stale", "answer", "preview");
|
||||
const stale = fakeRow("stale", "answer", "resolved");
|
||||
const unseen = fakeRow("unseen", "answer", "fresh");
|
||||
store.record("stale", "answer", 999, 960, transcriptRowMeasurementVersion(staleBefore));
|
||||
const estimates = store.synthesize([stale, unseen], 960);
|
||||
ok(estimates[1] !== 999, "a stale-version sample is excluded from the kind median");
|
||||
}
|
||||
|
||||
{
|
||||
const user: Item = { kind: "user", id: "user-1", text: "question" };
|
||||
const firstTool: Item = { kind: "tool", id: "tool-1", name: "read_file", args: "{}", readOnly: true, status: "done" };
|
||||
const secondTool: Item = { kind: "tool", id: "tool-2", name: "grep", args: "{}", readOnly: true, status: "done" };
|
||||
const answer: Item = { kind: "assistant", id: "answer-1", text: "done", reasoning: "", streaming: false };
|
||||
const models = buildTurnModels([user, firstTool, secondTool, answer]);
|
||||
const folds: FoldMap = new Map([[
|
||||
"tool-1",
|
||||
{ open: true, userOverridden: true, running: false },
|
||||
]]);
|
||||
const options = {
|
||||
folds,
|
||||
foldPreference: "auto" as const,
|
||||
hasOlderHistory: false,
|
||||
turnForUser: () => 0,
|
||||
};
|
||||
const batch = buildTranscriptRows(models, { ...options, creationMode: false }).find((row) => row.kind === "tool-batch");
|
||||
const group = buildTranscriptRows(models, { ...options, creationMode: true }).find((row) => row.kind === "tool-group");
|
||||
const batchVersion = batch && transcriptRowMeasurementVersion(batch);
|
||||
const groupVersion = group && transcriptRowMeasurementVersion(group);
|
||||
ok(batchVersion === groupVersion, "batch and group rows combine the same member versions");
|
||||
const patchedTool = { ...secondTool, output: "late member content" };
|
||||
const patchedModels = buildTurnModels([user, firstTool, patchedTool, answer]);
|
||||
const patchedBatch = buildTranscriptRows(patchedModels, { ...options, creationMode: false }).find((row) => row.kind === "tool-batch");
|
||||
const patchedGroup = buildTranscriptRows(patchedModels, { ...options, creationMode: true }).find((row) => row.kind === "tool-group");
|
||||
ok(patchedBatch && transcriptRowMeasurementVersion(patchedBatch) !== batchVersion, "a member patch advances its read-only batch version");
|
||||
ok(patchedGroup && transcriptRowMeasurementVersion(patchedGroup) !== groupVersion, "a member patch advances its creation group version");
|
||||
}
|
||||
|
||||
{
|
||||
const preview: Item = { kind: "assistant", id: "he:late-answer", text: "preview", reasoning: "", streaming: false };
|
||||
const before = { ...initialState, items: [preview] };
|
||||
const beforeRows = buildTranscriptRows(buildTurnModels(before.items), {
|
||||
folds: EMPTY_FOLDS,
|
||||
foldPreference: "auto",
|
||||
hasOlderHistory: false,
|
||||
creationMode: false,
|
||||
turnForUser: () => undefined,
|
||||
});
|
||||
const beforeRow = beforeRows.find((row) => row.kind === "answer")!;
|
||||
const store = createTranscriptMeasuredSizes();
|
||||
const beforeVersion = transcriptRowMeasurementVersion(beforeRow);
|
||||
store.record(String(beforeRow.key), beforeRow.kind, 88, 960, beforeVersion);
|
||||
const after = reducer(before, {
|
||||
type: "history_items_patch",
|
||||
patches: { [preview.id]: { ...preview, text: "resolved ".repeat(2_000) } },
|
||||
});
|
||||
const afterRows = buildTranscriptRows(buildTurnModels(after.items), {
|
||||
folds: EMPTY_FOLDS,
|
||||
foldPreference: "auto",
|
||||
hasOlderHistory: false,
|
||||
creationMode: false,
|
||||
turnForUser: () => undefined,
|
||||
});
|
||||
const afterRow = afterRows.find((row) => row.kind === "answer")!;
|
||||
const afterVersion = transcriptRowMeasurementVersion(afterRow);
|
||||
eq(String(afterRow.key), String(beforeRow.key), "late content preserves the stable React row key");
|
||||
ok(afterVersion !== beforeVersion, "late content advances only the row measurement version");
|
||||
ok(estimateFor(store, afterRow, 960) !== 88, "an offscreen late patch cannot reuse the preview height after remount");
|
||||
}
|
||||
|
||||
{
|
||||
@@ -113,15 +218,39 @@ function fakeRow(key: string, kind: TranscriptRow["kind"], text: string): Transc
|
||||
store.record("a2", "answer", 300);
|
||||
store.record("a3", "answer", 200);
|
||||
const unseen = fakeRow("a4", "answer", "some answer text that is long enough to matter");
|
||||
eq(store.estimateFor(unseen), 200, "unseen row of a sampled kind uses the kind median");
|
||||
eq(estimateFor(store, unseen), 200, "unseen row of a sampled kind uses the kind median");
|
||||
const toolRow = fakeRow("t1", "tool", "");
|
||||
eq(store.estimateFor(toolRow), estimateTranscriptRowSize(toolRow), "unsampled kind still uses the static prior");
|
||||
eq(estimateFor(store, toolRow), estimateTranscriptRowSize(toolRow), "unsampled kind still uses the static prior");
|
||||
}
|
||||
|
||||
{
|
||||
const store = createTranscriptMeasuredSizes();
|
||||
const first = fakeRow("a1", "answer", "first answer");
|
||||
const second = fakeRow("a2", "answer", "second answer");
|
||||
const unseen = fakeRow("a3", "answer", "unseen answer");
|
||||
store.record("a1", "answer", 291, 960, transcriptRowMeasurementVersion(first));
|
||||
store.record("a1", "answer", 632, 960, transcriptRowMeasurementVersion(first));
|
||||
store.record("a2", "answer", 400, 960, transcriptRowMeasurementVersion(second));
|
||||
eq(estimateFor(store, first, 960), 632, "a later real measurement replaces the stale estimate for the same row");
|
||||
eq(estimateFor(store, second, 960), 400, "a second row keeps its own latest measurement");
|
||||
eq(estimateFor(store, unseen, 960), 516, "kind fallback uses one latest sample per row instead of duplicate observations");
|
||||
}
|
||||
|
||||
{
|
||||
const store = createTranscriptMeasuredSizes();
|
||||
const row = fakeRow("width-sensitive", "answer", "wrapped answer");
|
||||
store.record("width-sensitive", "answer", 632, 960, transcriptRowMeasurementVersion(row));
|
||||
eq(estimateFor(store, row, 960), 632, "an exact measurement is reused at the measured width");
|
||||
ok(
|
||||
estimateFor(store, row, 760) === estimateTranscriptRowSize(row),
|
||||
"a measurement from another content width is not reused after responsive reflow",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const store = createTranscriptMeasuredSizes();
|
||||
const rows = [fakeRow("r1", "user", "hi"), fakeRow("r2", "answer", "hello there")];
|
||||
store.record("r1", "user", 64);
|
||||
store.record("r1", "user", 64, undefined, transcriptRowMeasurementVersion(rows[0]));
|
||||
const estimates = store.synthesize(rows);
|
||||
eq(estimates.length, 2, "synthesize stays aligned with the row array");
|
||||
eq(estimates[0], 64, "synthesize uses the exact measurement where present");
|
||||
@@ -133,7 +262,7 @@ function fakeRow(key: string, kind: TranscriptRow["kind"], text: string): Transc
|
||||
store.record("r1", "answer", 500);
|
||||
store.clear();
|
||||
const row = fakeRow("r1", "answer", "hello");
|
||||
ok(store.estimateFor(row) === estimateTranscriptRowSize(row), "clear() drops measurements and medians");
|
||||
ok(estimateFor(store, row) === estimateTranscriptRowSize(row), "clear() drops measurements and medians");
|
||||
}
|
||||
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// Run: pnpm exec tsx src/__tests__/transcript-native-scrollbar.test.ts
|
||||
|
||||
import { equal } from "node:assert/strict";
|
||||
import { deepEqual, equal } from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import {
|
||||
isNativeVerticalScrollbarPointer,
|
||||
measureTranscriptVirtuosoItem,
|
||||
} from "../lib/transcriptNativeScrollbar";
|
||||
import { noteTranscriptRowMeasurement, setTranscriptScrollDiagnosticSink } from "../lib/transcriptScrollProbe";
|
||||
|
||||
let passed = 0;
|
||||
function check(actual: unknown, expected: unknown, label: string) {
|
||||
@@ -14,7 +15,7 @@ function check(actual: unknown, expected: unknown, label: string) {
|
||||
passed += 1;
|
||||
}
|
||||
|
||||
const dom = new JSDOM('<div class="transcript"><div id="row" data-known-size="160"></div></div>');
|
||||
const dom = new JSDOM('<div class="transcript"><div id="row" data-index="44" data-item-index="1000044" data-logical-index="44" data-row-kind="answer" data-estimated-size="1800" data-known-size="160" data-content-revision="3"><button aria-expanded="false"></button></div></div>');
|
||||
const transcript = dom.window.document.querySelector<HTMLElement>(".transcript")!;
|
||||
const row = dom.window.document.querySelector<HTMLElement>("#row")!;
|
||||
Object.defineProperties(transcript, {
|
||||
@@ -59,4 +60,45 @@ check(measureTranscriptVirtuosoItem(row, "offsetHeight", false), 640, "ordinary
|
||||
check(measureTranscriptVirtuosoItem(row, "offsetHeight", true), 160, "native thumb drag keeps the existing Virtuoso size");
|
||||
check(measureTranscriptVirtuosoItem(row, "offsetHeight", false), 640, "real measurement resumes after thumb release");
|
||||
|
||||
const measurementEvents: Array<{ type: string; fields: Record<string, unknown> }> = [];
|
||||
setTranscriptScrollDiagnosticSink((type, fields) => measurementEvents.push({ type, fields }));
|
||||
noteTranscriptRowMeasurement(row, "offsetHeight", 640);
|
||||
deepEqual(measurementEvents, [{
|
||||
type: "row-measure",
|
||||
fields: {
|
||||
rowIndex: 44,
|
||||
rowKind: "answer",
|
||||
estimatedSize: 1800,
|
||||
previousSize: 160,
|
||||
measuredSize: 640,
|
||||
sizeDelta: 480,
|
||||
contentRevision: 3,
|
||||
foldState: "closed",
|
||||
disclosureCount: 1,
|
||||
},
|
||||
}], "row measurement records only geometry and fixed classifications");
|
||||
passed += 1;
|
||||
delete row.dataset.knownSize;
|
||||
noteTranscriptRowMeasurement(row, "offsetHeight", 420);
|
||||
deepEqual(measurementEvents[measurementEvents.length - 1], {
|
||||
type: "row-measure",
|
||||
fields: {
|
||||
rowIndex: 44,
|
||||
rowKind: "answer",
|
||||
estimatedSize: 1800,
|
||||
previousSize: undefined,
|
||||
measuredSize: 420,
|
||||
sizeDelta: -1380,
|
||||
contentRevision: 3,
|
||||
foldState: "closed",
|
||||
disclosureCount: 1,
|
||||
},
|
||||
}, "first real measurement records its estimate delta with the logical row index");
|
||||
passed += 1;
|
||||
row.dataset.knownSize = "160";
|
||||
noteTranscriptRowMeasurement(row, "offsetHeight", 160);
|
||||
check(measurementEvents.length, 2, "unchanged row size emits no diagnostic event");
|
||||
noteTranscriptRowMeasurement(row, "offsetWidth", 800);
|
||||
check(measurementEvents.length, 2, "horizontal measurements emit no row-height diagnostic event");
|
||||
|
||||
process.stdout.write(`\n${passed} passed\n`);
|
||||
@@ -7,6 +7,7 @@
|
||||
// actually mounted in the viewport, not just that a jump was dispatched.
|
||||
|
||||
import { createTranscriptHarness } from "./transcript-dom-harness";
|
||||
import { QUESTION_JUMP_MAX_MARKERS } from "../components/QuestionJumpBar";
|
||||
import type { Item } from "../lib/useController";
|
||||
import { act } from "react";
|
||||
|
||||
@@ -32,6 +33,14 @@ function turns(count: number, prefix = ""): Item[] {
|
||||
return items;
|
||||
}
|
||||
|
||||
function historyTurns(start: number, end: number): Item[] {
|
||||
const items: Item[] = [];
|
||||
for (let turn = start; turn <= end; turn += 1) {
|
||||
items.push({ kind: "user", id: `history-u${turn}`, text: `history question ${turn}`, historyTurn: turn });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function dispatchScroll(el: HTMLElement) {
|
||||
el.dispatchEvent(new Event("scroll"));
|
||||
}
|
||||
@@ -41,14 +50,16 @@ function stubRailGeometry(container: HTMLElement, count: number) {
|
||||
const jumpScroll = container.querySelector<HTMLElement>(".jump-scroll");
|
||||
if (!jumpBar || !jumpScroll) throw new Error("question jump bar is not mounted");
|
||||
jumpBar.getBoundingClientRect = () => ({ top: 0, bottom: 240, left: 0, right: 56, height: 240, width: 56 } as DOMRect);
|
||||
const items = jumpScroll.querySelectorAll<HTMLButtonElement>(".jump-item");
|
||||
items.forEach((item, index) => {
|
||||
item.getBoundingClientRect = () => ({ top: index * 20, bottom: index * 20 + 14, left: 0, right: 32, height: 14, width: 32 } as DOMRect);
|
||||
});
|
||||
jumpScroll.getBoundingClientRect = () => ({ top: 0, bottom: 240, left: 0, right: 32, height: 240, width: 32 } as DOMRect);
|
||||
const items = jumpScroll.querySelectorAll<HTMLElement>(".jump-item");
|
||||
if (items.length !== count) throw new Error(`expected ${count} jump markers, found ${items.length}`);
|
||||
return jumpScroll;
|
||||
}
|
||||
|
||||
function railClientY(turn: number, total: number): number {
|
||||
return ((turn + 0.5) / total) * 240;
|
||||
}
|
||||
|
||||
function visibleRows(container: HTMLElement): string[] {
|
||||
const el = container.querySelector<HTMLElement>(".transcript");
|
||||
if (!el) return [];
|
||||
@@ -99,7 +110,7 @@ console.log("\ntranscript question jump landing");
|
||||
|
||||
const targetIndices = [0, 31, 3, 36, 12, 28, 39];
|
||||
for (const targetIndex of targetIndices) {
|
||||
const clientY = targetIndex * 20 + 7;
|
||||
const clientY = railClientY(targetIndex, 40);
|
||||
await act(async () => {
|
||||
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY }));
|
||||
jumpScroll.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY, detail: 1 }));
|
||||
@@ -148,8 +159,8 @@ console.log("\ntranscript question jump landing");
|
||||
|
||||
const targetIndex = 4;
|
||||
await act(async () => {
|
||||
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY: targetIndex * 20 + 7 }));
|
||||
jumpScroll.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY: targetIndex * 20 + 7, detail: 1 }));
|
||||
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY: railClientY(targetIndex, 20) }));
|
||||
jumpScroll.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY: railClientY(targetIndex, 20), detail: 1 }));
|
||||
dispatchScroll(el);
|
||||
});
|
||||
await harness.waitFor(
|
||||
@@ -211,8 +222,8 @@ console.log("\ntranscript question jump landing");
|
||||
|
||||
const targetIndex = 9; // old-u1 sits at marker 3; pick a post-prepend question
|
||||
await act(async () => {
|
||||
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY: targetIndex * 20 + 7 }));
|
||||
jumpScroll.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY: targetIndex * 20 + 7, detail: 1 }));
|
||||
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY: railClientY(targetIndex, 18) }));
|
||||
jumpScroll.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY: railClientY(targetIndex, 18), detail: 1 }));
|
||||
dispatchScroll(el);
|
||||
});
|
||||
await harness.waitFor(
|
||||
@@ -227,5 +238,144 @@ console.log("\ntranscript question jump landing");
|
||||
}
|
||||
}
|
||||
|
||||
// ── A one-turn remainder auto-loads without restoring the old fold button ───
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
try {
|
||||
HTMLElement.prototype.scrollIntoView = () => {};
|
||||
let loads = 0;
|
||||
await harness.render(historyTurns(2, 61), {
|
||||
running: false,
|
||||
questionNavigator: true,
|
||||
hasOlderHistory: true,
|
||||
historyStartTurn: 2,
|
||||
historyTotalTurns: 61,
|
||||
onLoadOlderHistory: async () => {
|
||||
loads += 1;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
await harness.waitFor(() => loads > 0, "the final missing history turn to auto-load");
|
||||
ok(harness.container.querySelectorAll(".jump-item").length === 61, "the first page renders a marker for every session question");
|
||||
ok(!harness.container.querySelector(".transcript__older"), "the ordinary show-earlier fold button is absent");
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extreme sessions keep a bounded rail and exact lazy-load targeting ──────
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
try {
|
||||
HTMLElement.prototype.scrollIntoView = () => {};
|
||||
const requestedTurns: Array<number | undefined> = [];
|
||||
await harness.render(historyTurns(9_941, 10_000), {
|
||||
running: false,
|
||||
questionNavigator: true,
|
||||
hasOlderHistory: true,
|
||||
historyStartTurn: 9_941,
|
||||
historyTotalTurns: 10_000,
|
||||
olderHistoryError: "pause automatic loading for this rail-only fixture",
|
||||
onLoadOlderHistory: async (targetTurn) => {
|
||||
requestedTurns.push(targetTurn);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
await harness.settle();
|
||||
const jumpScroll = stubRailGeometry(harness.container, QUESTION_JUMP_MAX_MARKERS);
|
||||
ok(harness.container.querySelectorAll(".jump-item").length === QUESTION_JUMP_MAX_MARKERS, "10,000 turns keep a fixed-size question rail");
|
||||
|
||||
const targetTurn = 1_234;
|
||||
await act(async () => {
|
||||
jumpScroll.dispatchEvent(new MouseEvent("mousedown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
clientY: railClientY(targetTurn, 10_000),
|
||||
}));
|
||||
});
|
||||
await harness.waitFor(() => requestedTurns.includes(targetTurn + 1), "the aggregate rail to request the exact absolute turn");
|
||||
ok(requestedTurns.includes(targetTurn + 1), "the bounded rail preserves exact unloaded-question targeting");
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Clicking an unloaded complete-rail marker loads and lands on that turn ──
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
try {
|
||||
HTMLElement.prototype.scrollIntoView = () => {};
|
||||
const requestedTurns: Array<number | undefined> = [];
|
||||
const onLoadOlderHistory = async (targetTurn?: number) => {
|
||||
requestedTurns.push(targetTurn);
|
||||
return true;
|
||||
};
|
||||
await harness.render(historyTurns(81, 100), {
|
||||
running: false,
|
||||
questionNavigator: true,
|
||||
hasOlderHistory: true,
|
||||
historyStartTurn: 81,
|
||||
historyTotalTurns: 100,
|
||||
onLoadOlderHistory,
|
||||
});
|
||||
await harness.settle();
|
||||
const jumpScroll = stubRailGeometry(harness.container, 100);
|
||||
await act(async () => {
|
||||
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY: railClientY(10, 100) }));
|
||||
});
|
||||
await harness.waitFor(() => requestedTurns.includes(11), "the unloaded marker to request its absolute turn");
|
||||
|
||||
await harness.render(historyTurns(11, 100), {
|
||||
running: false,
|
||||
questionNavigator: true,
|
||||
hasOlderHistory: false,
|
||||
historyStartTurn: 11,
|
||||
historyTotalTurns: 100,
|
||||
onLoadOlderHistory,
|
||||
});
|
||||
await harness.waitFor(
|
||||
() => Boolean(harness.container.querySelector("#question-anchor-history-u11")),
|
||||
"the newly loaded question to land in the viewport",
|
||||
);
|
||||
ok(Boolean(harness.container.querySelector("#question-anchor-history-u11")), "an unloaded marker loads and lands on the selected question");
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Failed automatic loading stops and exposes a compact retry state ─────────
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
try {
|
||||
HTMLElement.prototype.scrollIntoView = () => {};
|
||||
let retries = 0;
|
||||
await harness.render(historyTurns(81, 100), {
|
||||
running: false,
|
||||
questionNavigator: true,
|
||||
hasOlderHistory: true,
|
||||
historyStartTurn: 81,
|
||||
historyTotalTurns: 100,
|
||||
olderHistoryError: "backend detail stays out of the UI",
|
||||
onLoadOlderHistory: async () => {
|
||||
retries += 1;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const retry = Array.from(harness.container.querySelectorAll<HTMLButtonElement>("button"))
|
||||
.find((button) => button.textContent?.trim() === "Retry");
|
||||
ok(Boolean(retry), "a failed older-history load exposes a retry action");
|
||||
ok(harness.container.textContent?.includes("Earlier conversation could not be loaded") ?? false, "the failure uses a path-free user-facing message");
|
||||
await act(async () => retry?.click());
|
||||
await harness.waitFor(() => retries === 1, "the older-history retry to run once");
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,72 @@
|
||||
// Run: node --import tsx src/__tests__/transcript-question-nav-integration.test.ts
|
||||
|
||||
import { createTranscriptHarness } from "./transcript-dom-harness";
|
||||
import type { Item } from "../lib/useController";
|
||||
import { act } from "react";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(condition: unknown, label: string) {
|
||||
if (condition) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function turns(count: number): Item[] {
|
||||
const items: Item[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
items.push({
|
||||
kind: "user",
|
||||
id: `u${i}`,
|
||||
text: `question ${i}`,
|
||||
historyTurn: i + 1,
|
||||
checkpointTurn: 1_000 + i,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
console.log("\ntranscript question navigation integration");
|
||||
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 20 });
|
||||
try {
|
||||
HTMLElement.prototype.scrollIntoView = () => {};
|
||||
await harness.render(turns(8), { running: false, questionNavigator: true });
|
||||
await harness.settle();
|
||||
|
||||
const transcript = harness.scrollElement();
|
||||
transcript.getBoundingClientRect = () => ({ top: 100, bottom: 300, left: 0, right: 800, height: 200, width: 800 } as DOMRect);
|
||||
const setAnchorPositions = (activeTurn: number) => {
|
||||
harness.container.querySelectorAll<HTMLElement>("[data-question-anchor]").forEach((anchor, turn) => {
|
||||
const top = turn <= activeTurn ? -20 - (activeTurn - turn) * 20 : 40 + (turn - activeTurn) * 20;
|
||||
anchor.getBoundingClientRect = () => ({ top: 100 + top, bottom: 120 + top, left: 0, right: 400, height: 20, width: 400 } as DOMRect);
|
||||
});
|
||||
};
|
||||
|
||||
const dots = () => Array.from(harness.container.querySelectorAll<HTMLElement>(".jump-dot"));
|
||||
ok(dots().length === 8, "question navigator renders one marker per question");
|
||||
|
||||
setAnchorPositions(5);
|
||||
await act(async () => {
|
||||
transcript.dispatchEvent(new Event("scroll"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
});
|
||||
ok(dots()[5]?.style.width === "18px", "manual scroll moves the active marker to the visible question");
|
||||
ok(dots()[7]?.style.width === "12px", "the old tail marker is no longer active after manual scroll");
|
||||
ok(
|
||||
harness.container.querySelector<HTMLElement>('.jump-item[data-turn="5"] .jump-dot')?.style.width === "18px",
|
||||
"scroll sync uses the absolute question index instead of the unrelated checkpoint turn",
|
||||
);
|
||||
await harness.settle();
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,48 @@
|
||||
// Run: node --import tsx src/__tests__/transcript-question-nav.test.ts
|
||||
|
||||
import { activeQuestionTurn, type QuestionAnchorPosition } from "../lib/transcriptGrouping";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function equal(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\ntranscript question navigation");
|
||||
|
||||
equal(
|
||||
activeQuestionTurn([
|
||||
{ turn: 0, top: -520 },
|
||||
{ turn: 1, top: -36 },
|
||||
{ turn: 2, top: 240 },
|
||||
] satisfies QuestionAnchorPosition[], 0),
|
||||
1,
|
||||
"active question follows the last anchor above the viewport",
|
||||
);
|
||||
|
||||
equal(
|
||||
activeQuestionTurn([
|
||||
{ turn: 1, top: 28 },
|
||||
{ turn: 2, top: 300 },
|
||||
] satisfies QuestionAnchorPosition[], 0),
|
||||
1,
|
||||
"active question falls back to the first mounted anchor at the top boundary",
|
||||
);
|
||||
|
||||
equal(
|
||||
activeQuestionTurn([{ turn: 2, top: -12 }] satisfies QuestionAnchorPosition[], 0),
|
||||
2,
|
||||
"virtualized scrolling can update from the mounted question window",
|
||||
);
|
||||
|
||||
equal(activeQuestionTurn([], 0), undefined, "missing anchors leave the active question unchanged");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,221 @@
|
||||
// Run: tsx src/__tests__/transcript-reader-extent-race.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { VirtuosoHandle } from "react-virtuoso";
|
||||
import type { TranscriptScrollWriteRecord } from "../lib/transcriptScrollProbe";
|
||||
import { useTranscriptScrollArbiter } from "../lib/useTranscriptScrollArbiter";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function check(condition: unknown, label: string) {
|
||||
if (condition) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\ntranscript reader extent races");
|
||||
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div><div id="scroll"><div class="transcript__row" data-row-key="row-a"></div></div></body></html>', {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.Node = dom.window.Node;
|
||||
|
||||
let nextFrame = 1;
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
const requestFrame = (callback: FrameRequestCallback) => {
|
||||
const id = nextFrame;
|
||||
nextFrame += 1;
|
||||
frames.set(id, callback);
|
||||
return id;
|
||||
};
|
||||
const cancelFrame = (id: number) => void frames.delete(id);
|
||||
globalThis.requestAnimationFrame = requestFrame;
|
||||
globalThis.cancelAnimationFrame = cancelFrame;
|
||||
dom.window.requestAnimationFrame = requestFrame;
|
||||
dom.window.cancelAnimationFrame = cancelFrame;
|
||||
|
||||
async function flushFrames() {
|
||||
const pending = [...frames.values()];
|
||||
frames.clear();
|
||||
await act(async () => pending.forEach((callback) => callback(performance.now())));
|
||||
}
|
||||
|
||||
const scrollWrites: TranscriptScrollWriteRecord[] = [];
|
||||
dom.window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => { scrollWrites.push(write); };
|
||||
|
||||
const rectAt = (top: number) => ({
|
||||
top,
|
||||
bottom: top + 100,
|
||||
height: 100,
|
||||
left: 0,
|
||||
right: 800,
|
||||
width: 800,
|
||||
x: 0,
|
||||
y: top,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
const scrollElement = dom.window.document.getElementById("scroll") as HTMLDivElement;
|
||||
const rowElement = scrollElement.querySelector<HTMLElement>(".transcript__row")!;
|
||||
scrollElement.getBoundingClientRect = () => rectAt(0);
|
||||
rowElement.getBoundingClientRect = () => rectAt(20);
|
||||
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 725 });
|
||||
let scrollExtent = 15_829;
|
||||
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, get: () => scrollExtent });
|
||||
Object.defineProperty(scrollElement, "scrollTop", { configurable: true, writable: true, value: 14_567.47 });
|
||||
|
||||
let scrollByCalls = 0;
|
||||
let lastScrollByTop = 0;
|
||||
const virtuosoHandle = {
|
||||
scrollBy: (options?: { top?: number }) => {
|
||||
scrollByCalls += 1;
|
||||
lastScrollByTop = options?.top ?? 0;
|
||||
scrollElement.scrollTop += lastScrollByTop;
|
||||
},
|
||||
scrollTo: () => {},
|
||||
scrollToIndex: () => {},
|
||||
getState: () => {},
|
||||
} as unknown as VirtuosoHandle;
|
||||
|
||||
let arbiter: ReturnType<typeof useTranscriptScrollArbiter> | undefined;
|
||||
function Probe() {
|
||||
arbiter = useTranscriptScrollArbiter();
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(dom.window.document.getElementById("root")!);
|
||||
await act(async () => root.render(<Probe />));
|
||||
await act(async () => {
|
||||
(arbiter!.virtuosoRef as { current: VirtuosoHandle | null }).current = virtuosoHandle;
|
||||
arbiter!.scrollerRef(scrollElement);
|
||||
});
|
||||
|
||||
// Composer wrap shrinks the in-flow viewport. Tail-follow must pin the native
|
||||
// tail synchronously so jump-bottom cannot flash before the coalesced frame.
|
||||
await act(async () => arbiter?.reset());
|
||||
scrollExtent = 500;
|
||||
scrollElement.scrollTop = 400;
|
||||
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 100 });
|
||||
await act(async () => arbiter?.followGrowingTail());
|
||||
await flushFrames();
|
||||
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 80 });
|
||||
await act(async () => arbiter?.followGrowingTail());
|
||||
check(scrollElement.scrollTop === 420, "footer-driven viewport shrink pins the native tail before rAF");
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
check(arbiter?.isAtBottom === true, "tail-follow keeps isAtBottom through a composer-wrap gap");
|
||||
await flushFrames();
|
||||
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 725 });
|
||||
scrollExtent = 15_829;
|
||||
scrollElement.scrollTop = 14_567.47;
|
||||
|
||||
// Returned Windows geometry: the native extent collapses after a downward
|
||||
// wheel and rebounds while scrollTop remains clamped 1,949px too high.
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
await act(async () => arbiter?.releaseTailFollow());
|
||||
scrollWrites.length = 0;
|
||||
await act(async () => arbiter?.onWheelIntent({
|
||||
ctrlKey: false,
|
||||
deltaMode: 0,
|
||||
deltaX: 0,
|
||||
deltaY: 133.33,
|
||||
target: scrollElement,
|
||||
} as React.WheelEvent<HTMLElement>));
|
||||
scrollExtent = 13_344;
|
||||
scrollElement.scrollTop = 12_618.67;
|
||||
rowElement.getBoundingClientRect = () => rectAt(1_836);
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
check(arbiter?.modeRef.current === "manual",
|
||||
"a transient physical-bottom clamp cannot claim tail ownership");
|
||||
await act(async () => arbiter?.finishProgrammaticScroll());
|
||||
await act(async () => arbiter?.followGrowingTail());
|
||||
await flushFrames();
|
||||
check(scrollByCalls === 0, "the transaction waits while the native extent remains collapsed");
|
||||
scrollExtent = 15_829;
|
||||
await act(async () => arbiter?.followGrowingTail());
|
||||
await flushFrames();
|
||||
check(scrollByCalls === 1 && lastScrollByTop > 1_900,
|
||||
`the rebound restores the logical anchor exactly once (${lastScrollByTop}px)`);
|
||||
check(scrollWrites.length === 1 && scrollWrites[0].owner === "reader-stability",
|
||||
"the correction is owned by reader stability rather than recovery or tail-follow");
|
||||
check(arbiter?.modeRef.current === "manual", "the correction preserves manual reader ownership");
|
||||
|
||||
// Touch movement is incremental: the second touchmove protects only its own
|
||||
// segment rather than replaying the distance from the original touchstart.
|
||||
await act(async () => arbiter?.reset());
|
||||
scrollExtent = 5_000;
|
||||
scrollElement.scrollTop = 2_000;
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
await act(async () => arbiter?.releaseTailFollow());
|
||||
await act(async () => arbiter?.onTouchStartIntent({
|
||||
touches: [{ clientY: 100 }],
|
||||
} as unknown as React.TouchEvent<HTMLElement>));
|
||||
await act(async () => arbiter?.onTouchMoveIntent({
|
||||
touches: [{ clientY: 90 }],
|
||||
} as unknown as React.TouchEvent<HTMLElement>));
|
||||
scrollElement.scrollTop = 2_010;
|
||||
await act(async () => arbiter?.onTouchMoveIntent({
|
||||
touches: [{ clientY: 80 }],
|
||||
} as unknown as React.TouchEvent<HTMLElement>));
|
||||
scrollExtent = 4_000;
|
||||
scrollElement.scrollTop = 1_000;
|
||||
rowElement.remove();
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
scrollExtent = 5_000;
|
||||
scrollByCalls = 0;
|
||||
lastScrollByTop = 0;
|
||||
await flushFrames();
|
||||
check(scrollByCalls === 1 && lastScrollByTop === 1_020,
|
||||
`consecutive touch segments use incremental geometry (${lastScrollByTop}px)`);
|
||||
scrollElement.append(rowElement);
|
||||
|
||||
// Ordinary sub-viewport measurement jitter stays browser-owned, and a higher
|
||||
// priority selection cancels the still-pending transaction.
|
||||
await act(async () => arbiter?.reset());
|
||||
scrollExtent = 5_000;
|
||||
scrollElement.scrollTop = 2_000;
|
||||
rowElement.getBoundingClientRect = () => rectAt(20);
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
await act(async () => arbiter?.releaseTailFollow());
|
||||
scrollWrites.length = 0;
|
||||
scrollByCalls = 0;
|
||||
await act(async () => arbiter?.onWheelIntent({
|
||||
ctrlKey: false,
|
||||
deltaMode: 0,
|
||||
deltaX: 0,
|
||||
deltaY: 133.33,
|
||||
target: scrollElement,
|
||||
} as React.WheelEvent<HTMLElement>));
|
||||
scrollElement.scrollTop = 1_960;
|
||||
rowElement.getBoundingClientRect = () => rectAt(60);
|
||||
await act(async () => arbiter?.followGrowingTail());
|
||||
await flushFrames();
|
||||
check(scrollByCalls === 0 && scrollWrites.length === 0,
|
||||
"sub-viewport reverse jitter never earns a correction");
|
||||
await act(async () => arbiter?.setMode("selection", "test-reader-stability-preemption"));
|
||||
scrollElement.scrollTop = 1_000;
|
||||
rowElement.getBoundingClientRect = () => rectAt(1_060);
|
||||
await act(async () => arbiter?.followGrowingTail());
|
||||
await flushFrames();
|
||||
check(scrollByCalls === 0 && scrollWrites.length === 0,
|
||||
"selection ownership cancels a pending reader transaction");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n${failed} transcript reader extent race test(s) failed; ${passed} passed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\n${passed} transcript reader extent race tests passed.`);
|
||||
@@ -0,0 +1,155 @@
|
||||
// Run: tsx src/__tests__/transcript-reader-extent-stability.test.ts
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import type { TranscriptScrollEvent } from "../lib/transcriptScrollArbiter";
|
||||
import {
|
||||
createTranscriptReaderExtentGuard,
|
||||
observeTranscriptReaderExtent,
|
||||
resolveTranscriptReaderExtentCorrection,
|
||||
transcriptScrollEventCancelsReaderExtentGuard,
|
||||
transcriptKeyboardScrollDelta,
|
||||
transcriptReaderExtentCanCorrect,
|
||||
} from "../lib/transcriptReaderExtentStability";
|
||||
|
||||
console.log("\ntranscript reader extent stability");
|
||||
|
||||
const reported = createTranscriptReaderExtentGuard(
|
||||
{ scrollTop: 14_567.47, scrollHeight: 15_829, clientHeight: 725 },
|
||||
{ mode: "manual", rowKey: "visible-row", offset: 20 },
|
||||
133.33,
|
||||
)!;
|
||||
observeTranscriptReaderExtent(
|
||||
reported,
|
||||
{ scrollTop: 12_618.67, scrollHeight: 13_344, clientHeight: 725 },
|
||||
);
|
||||
assert.equal(
|
||||
resolveTranscriptReaderExtentCorrection(
|
||||
reported,
|
||||
{ scrollTop: 12_618.67, scrollHeight: 13_344, clientHeight: 725 },
|
||||
1_836,
|
||||
),
|
||||
undefined,
|
||||
"a still-collapsed extent cannot consume the correction budget",
|
||||
);
|
||||
const reportedCorrection = resolveTranscriptReaderExtentCorrection(
|
||||
reported,
|
||||
{ scrollTop: 12_618.67, scrollHeight: 15_829, clientHeight: 725 },
|
||||
1_836,
|
||||
);
|
||||
assert.ok(reportedCorrection !== undefined && reportedCorrection > 1_900,
|
||||
`the returned Windows geometry restores its logical anchor (${reportedCorrection})`);
|
||||
|
||||
const fallbackCorrection = resolveTranscriptReaderExtentCorrection(
|
||||
reported,
|
||||
{ scrollTop: 12_618.67, scrollHeight: 15_829, clientHeight: 725 },
|
||||
);
|
||||
assert.equal(Math.round(fallbackCorrection ?? 0), 2_082,
|
||||
"an unmounted anchor falls back to the expected native wheel landing");
|
||||
|
||||
assert.equal(
|
||||
transcriptReaderExtentCanCorrect(
|
||||
reported,
|
||||
{ scrollTop: 14_500, scrollHeight: 15_829, clientHeight: 725 },
|
||||
),
|
||||
false,
|
||||
"sub-viewport reverse jitter remains browser-owned",
|
||||
);
|
||||
assert.equal(
|
||||
transcriptReaderExtentCanCorrect(
|
||||
reported,
|
||||
{ scrollTop: 12_618.67, scrollHeight: 13_344, clientHeight: 725 },
|
||||
),
|
||||
false,
|
||||
"a real persistent content shrink is not mistaken for a transient rebound",
|
||||
);
|
||||
assert.equal(
|
||||
transcriptReaderExtentCanCorrect(
|
||||
reported,
|
||||
{ scrollTop: 12_618.67, scrollHeight: 15_829, clientHeight: 900 },
|
||||
),
|
||||
false,
|
||||
"viewport resize invalidates the reader geometry transaction",
|
||||
);
|
||||
|
||||
const upward = createTranscriptReaderExtentGuard(
|
||||
{ scrollTop: 2_000, scrollHeight: 5_000, clientHeight: 800 },
|
||||
{ mode: "manual", rowKey: "visible-row", offset: 20 },
|
||||
-120,
|
||||
)!;
|
||||
observeTranscriptReaderExtent(
|
||||
upward,
|
||||
{ scrollTop: 1_400, scrollHeight: 4_200, clientHeight: 800 },
|
||||
);
|
||||
assert.equal(
|
||||
resolveTranscriptReaderExtentCorrection(
|
||||
upward,
|
||||
{ scrollTop: 2_600, scrollHeight: 5_000, clientHeight: 800 },
|
||||
-580,
|
||||
),
|
||||
-720,
|
||||
"an upward gesture corrects only a catastrophic downward reversal",
|
||||
);
|
||||
|
||||
const prepend = createTranscriptReaderExtentGuard(
|
||||
{ scrollTop: 2_000, scrollHeight: 5_000, clientHeight: 800 },
|
||||
{ mode: "manual", rowKey: "visible-row", offset: 20 },
|
||||
-40,
|
||||
)!;
|
||||
observeTranscriptReaderExtent(
|
||||
prepend,
|
||||
{ scrollTop: 3_500, scrollHeight: 6_500, clientHeight: 800 },
|
||||
);
|
||||
assert.equal(
|
||||
resolveTranscriptReaderExtentCorrection(
|
||||
prepend,
|
||||
{ scrollTop: 3_500, scrollHeight: 6_500, clientHeight: 800 },
|
||||
20,
|
||||
),
|
||||
undefined,
|
||||
"prepended history growth preserves Virtuoso's logical-anchor compensation",
|
||||
);
|
||||
|
||||
const keyboardSnapshot = { scrollTop: 2_000, scrollHeight: 5_000, clientHeight: 800 };
|
||||
assert.equal(transcriptKeyboardScrollDelta(" ", true, keyboardSnapshot), -720,
|
||||
"Shift+Space uses the browser's upward direction");
|
||||
assert.equal(transcriptKeyboardScrollDelta(" ", false, keyboardSnapshot), 720,
|
||||
"Space uses the browser's downward direction");
|
||||
assert.equal(transcriptKeyboardScrollDelta("Home", false, keyboardSnapshot), -2_000,
|
||||
"Home targets the native top");
|
||||
assert.equal(transcriptKeyboardScrollDelta("End", false, keyboardSnapshot), 2_200,
|
||||
"End targets the native bottom");
|
||||
const cancellingEvents: TranscriptScrollEvent["type"][] = [
|
||||
"RESET",
|
||||
"USER_SCROLL_INTENT",
|
||||
"MANUAL_READING",
|
||||
"VIEWPORT_RESIZED",
|
||||
"USER_RESIZE_BEGIN",
|
||||
"SELECTION_BEGIN",
|
||||
"PROGRAMMATIC_BEGIN",
|
||||
"JUMP_TO_BOTTOM",
|
||||
"JUMP_TO_INDEX",
|
||||
"SCROLL_TO_OFFSET",
|
||||
"RECOVERY_BEGIN",
|
||||
];
|
||||
for (const event of cancellingEvents) {
|
||||
assert.equal(transcriptScrollEventCancelsReaderExtentGuard(event), true,
|
||||
`${event} cancels stale reader geometry`);
|
||||
}
|
||||
|
||||
const observingEvents: TranscriptScrollEvent["type"][] = [
|
||||
"READER_INTENT_ENDED",
|
||||
"SCROLL_DELIVERED",
|
||||
"TAIL_CONTENT_CHANGED",
|
||||
"CONTENT_SHRANK",
|
||||
"LAYOUT_HEIGHT_CHANGED",
|
||||
"USER_RESIZE_END",
|
||||
"SELECTION_END",
|
||||
"PROGRAMMATIC_END",
|
||||
"RECOVERY_END",
|
||||
];
|
||||
for (const event of observingEvents) {
|
||||
assert.equal(transcriptScrollEventCancelsReaderExtentGuard(event), false,
|
||||
`${event} leaves rebound observation active`);
|
||||
}
|
||||
|
||||
console.log("transcript reader extent stability tests passed");
|
||||
@@ -99,6 +99,7 @@ dom.window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => { scrollWrites.push
|
||||
|
||||
// Terminal-state capture: Transcript wires this into session diagnostics.
|
||||
const terminals: TranscriptRecoveryTerminal[] = [];
|
||||
const rowMeasurements: Array<{ rowKey: string; kind: TranscriptRow["kind"]; height: number; width: number }> = [];
|
||||
|
||||
const rectAt = (top: number) => ({ top, bottom: top + 100, height: 100, left: 0, right: 800, width: 800, x: 0, y: top, toJSON: () => ({}) });
|
||||
|
||||
@@ -140,9 +141,10 @@ const virtuosoHandle = {
|
||||
let arbiter: ReturnType<typeof useTranscriptScrollArbiter> | undefined;
|
||||
let integrity: ReturnType<typeof useTranscriptLayoutIntegrity> | undefined;
|
||||
|
||||
function Probe({ surfaceKey, rows = baseRows }: { surfaceKey: string; rows?: TranscriptRow[] }) {
|
||||
function Probe({ surfaceKey, rows = baseRows, layoutWidth = 800 }: { surfaceKey: string; rows?: TranscriptRow[]; layoutWidth?: number }) {
|
||||
const scroll = useTranscriptScrollArbiter({
|
||||
onRecoveryTerminal: (terminal) => { terminals.push(terminal); },
|
||||
onItemMeasured: (rowKey, kind, height, width) => { rowMeasurements.push({ rowKey, kind, height, width }); },
|
||||
});
|
||||
const layout = useTranscriptLayoutIntegrity({
|
||||
surfaceKey,
|
||||
@@ -156,6 +158,8 @@ function Probe({ surfaceKey, rows = baseRows }: { surfaceKey: string; rows?: Tra
|
||||
retryRecoveryRequest: scroll.retryRecoveryRequest,
|
||||
lastGoodAnchorRef: scroll.lastGoodAnchorRef,
|
||||
captureStateSnapshot: scroll.captureStateSnapshot,
|
||||
layoutTransientRef: scroll.layoutTransientRef,
|
||||
layoutWidth,
|
||||
});
|
||||
arbiter = scroll;
|
||||
integrity = layout;
|
||||
@@ -187,33 +191,44 @@ const root = createRoot(dom.window.document.getElementById("root")!);
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-a" />));
|
||||
await act(async () => {
|
||||
(arbiter!.virtuosoRef as { current: VirtuosoHandle | null }).current = virtuosoHandle;
|
||||
});
|
||||
|
||||
// A first-mount bottom request may race the Virtuoso scroller ref. It must not
|
||||
// strand the blank watchdog in a permanent layout-transient state.
|
||||
await act(async () => arbiter?.scrollToBottom());
|
||||
check(
|
||||
arbiter?.layoutTransientRef.current === false,
|
||||
"a pre-scroller tail request cannot strand layout-transient suppression",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
arbiter!.scrollerRef(scrollElement);
|
||||
});
|
||||
|
||||
// itemSize is the measurement source of truth. data-known-size may still hold
|
||||
// the estimate Virtuoso started from, so the cache callback must receive the
|
||||
// returned DOM height instead.
|
||||
rowElement.dataset.rowKind = "answer";
|
||||
rowElement.dataset.knownSize = "291";
|
||||
rowElement.getBoundingClientRect = () => ({ ...rectAt(200), height: 632, bottom: 832, width: 960, right: 960 });
|
||||
rowMeasurements.length = 0;
|
||||
arbiter?.itemSize(rowElement, "offsetHeight");
|
||||
check(
|
||||
rowMeasurements.length === 1
|
||||
&& rowMeasurements[0].rowKey === "row-a"
|
||||
&& rowMeasurements[0].kind === "answer"
|
||||
&& rowMeasurements[0].height === 632
|
||||
&& rowMeasurements[0].width === 960,
|
||||
"itemSize publishes the real DOM height instead of data-known-size",
|
||||
);
|
||||
rowElement.getBoundingClientRect = () => rectAt(200);
|
||||
|
||||
// The native extent is authoritative even when Virtuoso reports a stale
|
||||
// logical atBottom value after delayed row measurement.
|
||||
scrollElement.scrollTop = 400;
|
||||
await act(async () => arbiter?.atBottomStateChange(false));
|
||||
check(arbiter?.isAtBottom === true, "physical bottom overrides a stale Virtuoso atBottom=false report");
|
||||
|
||||
// A thumb gesture that reaches the frozen native bottom must claim the tail
|
||||
// before release resumes real row measurements and changes the extent.
|
||||
scrollElement.scrollTop = 0;
|
||||
await act(async () => arbiter?.onPointerDownIntent({
|
||||
button: 0,
|
||||
nativeEvent: { button: 0, clientX: 795 },
|
||||
} as React.PointerEvent<HTMLElement>));
|
||||
scrollElement.scrollTop = 400;
|
||||
await act(async () => window.dispatchEvent(new dom.window.Event("pointerup")));
|
||||
check(arbiter?.modeRef.current === "tail-follow", "native thumb release at the physical bottom resumes tail-follow");
|
||||
scrollExtent = 900;
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
await flushFrames();
|
||||
await flushFrames();
|
||||
await flushFrames();
|
||||
check(scrollElement.scrollTop === 800, "post-release remeasurement reconverges the claimed native bottom");
|
||||
scrollExtent = 500;
|
||||
|
||||
// A nested code/tool scrollport owns the wheel until it reaches its edge.
|
||||
// Capturing the event on Transcript must not release tail-follow early.
|
||||
const nestedScroller = dom.window.document.createElement("div");
|
||||
@@ -245,21 +260,6 @@ await act(async () => {
|
||||
check(nestedWheelAccepted && arbiter?.modeRef.current === "manual", "a nested edge hands wheel ownership to the transcript");
|
||||
nestedScroller.remove();
|
||||
|
||||
// If measurement/clamping reaches the physical bottom between scroll events,
|
||||
// a fresh downward gesture must still claim tail-follow even though the
|
||||
// browser has no remaining pixels to deliver.
|
||||
scrollElement.scrollTop = 400;
|
||||
let bottomWheelAccepted = false;
|
||||
await act(async () => {
|
||||
bottomWheelAccepted = arbiter?.onWheelIntent({
|
||||
ctrlKey: false,
|
||||
deltaX: 0,
|
||||
deltaY: 40,
|
||||
target: scrollElement,
|
||||
} as React.WheelEvent<HTMLElement>) ?? false;
|
||||
});
|
||||
check(bottomWheelAccepted && arbiter?.modeRef.current === "tail-follow", "a downward gesture claims an already-clamped physical bottom");
|
||||
|
||||
// A queued confirmation belongs to the surface that requested it. Resetting
|
||||
// before its frame runs must prevent the old request from writing the new one.
|
||||
scrollToCalls = 0;
|
||||
@@ -270,6 +270,15 @@ await act(async () => arbiter?.reset());
|
||||
await flushFrames();
|
||||
check(scrollToCalls === 1, "a reset invalidates the previous surface's queued tail confirmation");
|
||||
|
||||
// A jump-bottom transaction suppresses the blank watchdog while WebView2 and
|
||||
// Virtuoso are still exchanging scroll/measurement frames. The diagnostic
|
||||
// packages showed the old watchdog rebuilding inside this exact window.
|
||||
const keyBeforeJumpBlank = integrity?.resetKey;
|
||||
await act(async () => arbiter?.scrollToBottom());
|
||||
await triggerWatchdogRebuild();
|
||||
check(integrity?.resetKey === keyBeforeJumpBlank, "jump-bottom transients cannot trigger a blank size-tree rebuild");
|
||||
await advanceClock(350);
|
||||
|
||||
// Tail-follow is a persistent mode, not a six-frame retry window. As long as
|
||||
// growth keeps arriving (streaming), each height notification re-arms another
|
||||
// coalesced convergence and the view keeps landing on the current bottom.
|
||||
@@ -277,6 +286,7 @@ scrollToCalls = 0;
|
||||
await act(async () => arbiter?.scrollToBottom());
|
||||
for (let i = 0; i < 14; i += 1) {
|
||||
scrollExtent += 200;
|
||||
await advanceClock(40);
|
||||
await act(async () => arbiter?.followGrowingTail());
|
||||
await flushFrames();
|
||||
}
|
||||
@@ -309,6 +319,28 @@ check(
|
||||
);
|
||||
check(scrollToCalls >= 1 && scrollToCalls <= 2, `post-churn convergence costs at most two writes (${scrollToCalls})`);
|
||||
|
||||
// Replay the returned Windows trace: several different extents become visible
|
||||
// while one explicit jump owns the viewport. The writer may respond immediately
|
||||
// and perform one final correction, but must not write once per intermediate
|
||||
// height.
|
||||
scrollToCalls = 0;
|
||||
scrollExtent = 5_154;
|
||||
scrollElement.scrollTop = 0;
|
||||
await act(async () => arbiter?.scrollToBottom());
|
||||
for (const extent of [3_467, 6_785, 7_728, 5_525, 4_869]) {
|
||||
scrollExtent = extent;
|
||||
scrollElement.scrollTop = Math.min(scrollElement.scrollTop, scrollExtent - scrollElement.clientHeight);
|
||||
await act(async () => arbiter?.followGrowingTail());
|
||||
await flushFrames();
|
||||
}
|
||||
await advanceClock(240);
|
||||
for (let i = 0; i < 6; i += 1) await flushFrames();
|
||||
check(scrollToCalls <= 2, `one jump-bottom transaction emits at most two effective writes (${scrollToCalls})`);
|
||||
check(
|
||||
scrollElement.scrollTop === scrollExtent - scrollElement.clientHeight,
|
||||
`the bounded jump-bottom transaction still converges on the final native bottom (${scrollElement.scrollTop}/${scrollExtent - scrollElement.clientHeight})`,
|
||||
);
|
||||
|
||||
scrollExtent = 500;
|
||||
scrollElement.scrollTop = 400;
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
@@ -346,11 +378,26 @@ check(scrollByCalls === 0, "invalidated anchor stops the restore correction loop
|
||||
check(scrollToIndexCalls === 0, "invalidated anchor never re-aims at the stale row");
|
||||
check(scrollToBottomCalls === 1, "a reset without an anchor settles at the bottom");
|
||||
|
||||
// ── Blank-recovery cooldown: immediate re-blank is blocked, a persistent
|
||||
// blank past the cooldown earns another rebuild
|
||||
// ── Blank-recovery generation: the same geometry may hard-reset only once.
|
||||
// A real row-set or width change opens one new bounded recovery opportunity.
|
||||
await advanceClock(2_100);
|
||||
await triggerWatchdogRebuild();
|
||||
check(integrity?.resetKey === "surface-c:3", "a later blank rebuilds the size tree again");
|
||||
check(integrity?.resetKey === "surface-c:2", "the same broken layout generation cannot enter a reset loop");
|
||||
check(integrity?.safeMode === true, "a repeatedly blank generation enters one bounded measurement probe instead of another remount");
|
||||
const safeModeResetKey = integrity?.resetKey;
|
||||
rowElement.getBoundingClientRect = () => rectAt(0);
|
||||
await flushBlankCheck();
|
||||
check(integrity?.safeMode === false && integrity?.resetKey === safeModeResetKey,
|
||||
"a healthy measured viewport exits the bounded probe without remounting");
|
||||
rowElement.getBoundingClientRect = () => rectAt(200);
|
||||
await triggerWatchdogRebuild();
|
||||
check(integrity?.safeMode === false && integrity?.resetKey === safeModeResetKey,
|
||||
"an exhausted generation cannot re-enter its measurement probe");
|
||||
let recoveryRows = [...baseRows, { kind: "answer", key: "generation-1", item: { ...item, id: "generation-1" } } satisfies TranscriptRow];
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-c" rows={recoveryRows} />));
|
||||
check(integrity?.safeMode === false, "a real layout generation change exits the automatic measurement fallback");
|
||||
await triggerWatchdogRebuild();
|
||||
check(integrity?.resetKey === "surface-c:3", "a changed row generation earns one new rebuild");
|
||||
await act(async () => integrity?.handleItemsRendered(1));
|
||||
// Let the in-flight restore converge: place the anchor row at its target
|
||||
// offset so the correction loop settles within two stable frames (real DOMs
|
||||
@@ -367,7 +414,11 @@ check(integrity?.resetKey === "surface-c:3", "blank recovery within the cooldown
|
||||
check(scrollByCalls === 0, "cooldown-blocked blank check performs no correction");
|
||||
await advanceClock(2_100);
|
||||
await triggerWatchdogRebuild();
|
||||
check(integrity?.resetKey === "surface-c:4", "a blank that persists past the cooldown earns another rebuild");
|
||||
check(integrity?.resetKey === "surface-c:3", "the revised generation also refuses a second hard reset");
|
||||
recoveryRows = [...recoveryRows, { kind: "answer", key: "generation-2", item: { ...item, id: "generation-2" } } satisfies TranscriptRow];
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-c" rows={recoveryRows} />));
|
||||
await triggerWatchdogRebuild();
|
||||
check(integrity?.resetKey === "surface-c:4", "the next real layout generation can recover once");
|
||||
await act(async () => integrity?.handleItemsRendered(1));
|
||||
rowElement.getBoundingClientRect = () => rectAt(0);
|
||||
for (let i = 0; i < 10; i += 1) await flushFrames();
|
||||
@@ -508,6 +559,7 @@ strayRow.className = "transcript__row";
|
||||
strayRow.dataset.rowKey = "row-stray";
|
||||
strayRow.getBoundingClientRect = () => rectAt(600);
|
||||
scrollElement.appendChild(strayRow);
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-f" layoutWidth={960} />));
|
||||
await triggerWatchdogRebuild();
|
||||
const watchdogLocation = integrity?.restoreLocation;
|
||||
check(
|
||||
@@ -671,6 +723,37 @@ await flushFrames();
|
||||
check(scrollToBottomCalls === scrollToBottomBeforeSnapshot + 2, "a disjoint snapshot-less first mount settles at the bottom");
|
||||
stubSnapshot = null;
|
||||
|
||||
// A 10,000-row generation gets only one keyed reset and one bounded probe.
|
||||
const longRows: TranscriptRow[] = Array.from({ length: 10_000 }, (_, index) => ({
|
||||
kind: "answer", key: `long-${index}`,
|
||||
item: { ...item, id: `long-${index}` },
|
||||
}));
|
||||
await switchSurface("surface-long", longRows);
|
||||
await act(async () => arbiter?.releaseTailFollow());
|
||||
await advanceClock(2_100);
|
||||
const longResetBefore = integrity?.resetKey;
|
||||
await triggerWatchdogRebuild();
|
||||
const longResetAfter = integrity?.resetKey;
|
||||
check(longResetAfter !== longResetBefore, "a 10,000-row generation spends its single hard-reset budget");
|
||||
await act(async () => integrity?.invalidateAnchors());
|
||||
await act(async () => integrity?.handleItemsRendered(1));
|
||||
await triggerWatchdogRebuild();
|
||||
check(integrity?.safeMode === true, "long-history recovery keeps the probe independent of total row count");
|
||||
for (let frame = 0; frame < 3; frame += 1) await flushFrames();
|
||||
check(integrity?.safeMode === false,
|
||||
"an unsuccessful long-history probe exits without another range or scroll event");
|
||||
for (let cycle = 0; cycle < 3; cycle += 1) await triggerWatchdogRebuild();
|
||||
check(integrity?.resetKey === longResetAfter && integrity?.safeMode === false,
|
||||
"repeated blank cycles cannot remount or re-enter the probe after the generation budget is exhausted");
|
||||
const nextLongRows = [...longRows.slice(0, -1), { kind: "answer" as const, key: "long-next-generation",
|
||||
item: { ...item, id: "long-next-generation" } }];
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-long" rows={nextLongRows} />));
|
||||
check(integrity?.safeMode === false, "a changed 10,000-row generation resets the probe state");
|
||||
await advanceClock(2_100);
|
||||
const nextGenerationResetBefore = integrity?.resetKey;
|
||||
await triggerWatchdogRebuild();
|
||||
check(integrity?.resetKey !== nextGenerationResetBefore, "a changed 10,000-row generation receives a fresh hard-reset budget");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
Date.now = originalDateNow;
|
||||
dom.window.setTimeout = originalSetTimeout;
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// Run: node --import tsx src/__tests__/transcript-scroll-diagnostics.test.ts
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
createTranscriptScrollDiagnostics,
|
||||
isTranscriptScrollDiagnosticsBuild,
|
||||
} from "../lib/transcriptScrollDiagnostics";
|
||||
|
||||
let now = 1_000;
|
||||
const diagnostics = createTranscriptScrollDiagnostics({
|
||||
maxEvents: 4,
|
||||
now: () => now,
|
||||
randomID: () => "0123456789abcdef0123456789abcdef",
|
||||
environment: () => ({
|
||||
buildCommit: "test-commit",
|
||||
buildChannel: "test",
|
||||
platform: "windows",
|
||||
userAgent: "Reasonix diagnostic test",
|
||||
devicePixelRatio: 1.25,
|
||||
viewportWidth: 1440,
|
||||
viewportHeight: 900,
|
||||
reducedMotion: false,
|
||||
transcriptWidth: 1180,
|
||||
contentWidth: 960,
|
||||
fontSize: 14,
|
||||
lineHeight: 23.52,
|
||||
processFoldPreference: "auto",
|
||||
reasoningDisplayMode: "summary",
|
||||
}),
|
||||
});
|
||||
|
||||
diagnostics.record("scroll", { scrollTop: 10 });
|
||||
assert.equal(diagnostics.getSnapshot().eventCount, 0, "idle recorder ignores events");
|
||||
|
||||
diagnostics.start();
|
||||
now += 10;
|
||||
diagnostics.record("wheel", { deltaY: -120 });
|
||||
now += 10;
|
||||
diagnostics.record("scroll", {
|
||||
scrollTop: 120,
|
||||
scrollHeight: 2_400,
|
||||
clientHeight: 800,
|
||||
bottomDistance: 1_480,
|
||||
transcriptText: "PRIVATE_TRANSCRIPT_CANARY",
|
||||
rowKey: "raw-session-row-key",
|
||||
path: "C:\\Users\\private-user\\secret",
|
||||
token: "TOKEN_CANARY",
|
||||
} as never);
|
||||
now += 10;
|
||||
diagnostics.mark();
|
||||
now += 10;
|
||||
diagnostics.record("items-rendered", { mountedRows: 12, totalRows: 100, firstVisibleIndex: 44 });
|
||||
now += 10;
|
||||
diagnostics.record("scroll-write", { owner: "jump", writeKind: "scrollToIndex", targetIndex: 45 });
|
||||
|
||||
const active = diagnostics.getSnapshot();
|
||||
assert.equal(active.status, "recording");
|
||||
assert.equal(active.eventCount, 4, "ring buffer remains bounded");
|
||||
assert.equal(active.droppedEventCount, 2, "ring buffer reports dropped events");
|
||||
assert.equal(active.markerCount, 1, "issue marker is retained in summary");
|
||||
|
||||
const payload = diagnostics.stop();
|
||||
assert.equal(payload.schemaVersion, 2);
|
||||
assert.equal(payload.summary.eventCount, 4);
|
||||
assert.equal(payload.summary.droppedEventCount, 3);
|
||||
assert.equal(payload.summary.markerCount, 1);
|
||||
assert.equal(payload.manifest.platform, "windows");
|
||||
assert.equal(payload.events[payload.events.length - 1]?.type, "stop");
|
||||
|
||||
const serialized = JSON.stringify(payload);
|
||||
for (const forbidden of [
|
||||
"PRIVATE_TRANSCRIPT_CANARY",
|
||||
"raw-session-row-key",
|
||||
"private-user",
|
||||
"TOKEN_CANARY",
|
||||
"transcriptText",
|
||||
"rowKey",
|
||||
"path",
|
||||
"token",
|
||||
]) {
|
||||
assert.equal(serialized.includes(forbidden), false, `payload excludes ${forbidden}`);
|
||||
}
|
||||
|
||||
const frozenCount = diagnostics.getSnapshot().eventCount;
|
||||
now += 10;
|
||||
diagnostics.record("scroll", { scrollTop: 999 });
|
||||
assert.equal(diagnostics.getSnapshot().eventCount, frozenCount, "stopped recorder remains frozen");
|
||||
|
||||
diagnostics.start();
|
||||
assert.equal(diagnostics.getSnapshot().eventCount, 1, "new recording clears the old trace");
|
||||
diagnostics.reset();
|
||||
assert.equal(diagnostics.getSnapshot().status, "idle");
|
||||
assert.equal(diagnostics.getSnapshot().eventCount, 0);
|
||||
|
||||
const markerOverflow = createTranscriptScrollDiagnostics({
|
||||
maxEvents: 4,
|
||||
now: () => now,
|
||||
randomID: () => "fedcba9876543210fedcba9876543210",
|
||||
environment: () => ({
|
||||
buildCommit: "test-commit",
|
||||
buildChannel: "test",
|
||||
platform: "windows",
|
||||
userAgent: "Reasonix diagnostic test",
|
||||
devicePixelRatio: 1,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
reducedMotion: false,
|
||||
transcriptWidth: 1040,
|
||||
contentWidth: 960,
|
||||
fontSize: 14,
|
||||
lineHeight: 23.52,
|
||||
processFoldPreference: "expanded",
|
||||
reasoningDisplayMode: "expanded",
|
||||
}),
|
||||
});
|
||||
markerOverflow.start();
|
||||
markerOverflow.mark();
|
||||
for (let i = 0; i < 4; i += 1) markerOverflow.record("scroll", { scrollTop: i });
|
||||
const overflowPayload = markerOverflow.stop();
|
||||
assert.equal(overflowPayload.summary.markerCount, 0, "summary only counts markers retained in the bounded trace");
|
||||
assert.equal(overflowPayload.events.some((event) => event.type === "mark"), false, "evicted markers do not survive serialization");
|
||||
|
||||
const detailDiagnostics = createTranscriptScrollDiagnostics({
|
||||
now: () => now,
|
||||
randomID: () => "abcdef0123456789abcdef0123456789",
|
||||
environment: () => ({
|
||||
buildCommit: "detail-test",
|
||||
buildChannel: "test",
|
||||
platform: "windows",
|
||||
userAgent: "Reasonix detail diagnostic test",
|
||||
devicePixelRatio: 1.38,
|
||||
viewportWidth: 1920,
|
||||
viewportHeight: 1080,
|
||||
reducedMotion: true,
|
||||
transcriptWidth: 1600,
|
||||
contentWidth: 960,
|
||||
fontSize: 15,
|
||||
lineHeight: 25.2,
|
||||
processFoldPreference: "auto",
|
||||
reasoningDisplayMode: "legacy-collapsed",
|
||||
}),
|
||||
});
|
||||
detailDiagnostics.start();
|
||||
detailDiagnostics.record("row-measure", {
|
||||
rowIndex: 44,
|
||||
rowKind: "answer",
|
||||
estimatedSize: 1_800,
|
||||
previousSize: 1_800,
|
||||
measuredSize: 420,
|
||||
sizeDelta: -1_380,
|
||||
contentRevision: 3,
|
||||
foldState: "closed",
|
||||
disclosureCount: 1,
|
||||
rowKey: "PRIVATE_ROW_KEY",
|
||||
transcriptText: "PRIVATE_TRANSCRIPT_CANARY",
|
||||
});
|
||||
detailDiagnostics.record("scroll-state", {
|
||||
source: "jump-bottom",
|
||||
previousMode: "manual",
|
||||
mode: "tail-follow",
|
||||
atBottom: true,
|
||||
scrollable: true,
|
||||
readerIntent: false,
|
||||
canClaimTail: false,
|
||||
tailCommand: true,
|
||||
scrollTop: 4_000,
|
||||
scrollHeight: 5_000,
|
||||
clientHeight: 800,
|
||||
bottomDistance: 200,
|
||||
});
|
||||
detailDiagnostics.record("scroll-write", {
|
||||
owner: "tail-follow",
|
||||
writeKind: "scrollTo",
|
||||
source: "jump-bottom",
|
||||
phase: "settle",
|
||||
targetTop: 5_000,
|
||||
settleFrame: 2,
|
||||
offBottomFrames: 2,
|
||||
stagnantFrames: 0,
|
||||
});
|
||||
const detailPayload = detailDiagnostics.stop();
|
||||
const rowMeasurement = detailPayload.events.find((event) => event.type === "row-measure");
|
||||
assert.deepEqual(rowMeasurement, {
|
||||
t: 0,
|
||||
type: "row-measure",
|
||||
rowIndex: 44,
|
||||
estimatedSize: 1_800,
|
||||
previousSize: 1_800,
|
||||
measuredSize: 420,
|
||||
sizeDelta: -1_380,
|
||||
contentRevision: 3,
|
||||
disclosureCount: 1,
|
||||
rowKind: "answer",
|
||||
foldState: "closed",
|
||||
});
|
||||
const detailSerialized = JSON.stringify(detailPayload);
|
||||
assert.equal(detailSerialized.includes("PRIVATE_ROW_KEY"), false, "row measurements exclude stable row keys");
|
||||
assert.equal(detailSerialized.includes("PRIVATE_TRANSCRIPT_CANARY"), false, "row measurements exclude transcript text");
|
||||
|
||||
assert.equal(isTranscriptScrollDiagnosticsBuild("test", false), true, "test builds expose diagnostics");
|
||||
assert.equal(isTranscriptScrollDiagnosticsBuild("stable", true), true, "development server exposes diagnostics");
|
||||
assert.equal(isTranscriptScrollDiagnosticsBuild("stable", false), false, "stable production hides diagnostics");
|
||||
|
||||
console.log("transcript scroll diagnostics tests passed");
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type TranscriptScrollEvent,
|
||||
type TranscriptScrollState,
|
||||
} from "../lib/transcriptScrollArbiter";
|
||||
import { pinTranscriptScrollerToNativeTail, pinTranscriptTailAfterViewportShrink } from "../lib/transcriptScrollGeometry";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -71,9 +72,56 @@ const returned = run([
|
||||
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
|
||||
{ type: "USER_SCROLL_INTENT", canClaimTail: true },
|
||||
{ type: "SCROLL_DELIVERED", atBottom: false, scrollable: true },
|
||||
// The bottom must be held: two consecutive at-bottom deliveries inside the
|
||||
// same reader-intent window re-engage tail-follow (#8709/#9099).
|
||||
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
|
||||
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
|
||||
]);
|
||||
check(returned.state.mode === "tail-follow", "reaching the real bottom re-engages tail-follow");
|
||||
check(returned.state.mode === "tail-follow", "holding the real bottom across two deliveries re-engages tail-follow");
|
||||
|
||||
const touchDownOnce = run([
|
||||
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
|
||||
{ type: "USER_SCROLL_INTENT", canClaimTail: true },
|
||||
{ type: "SCROLL_DELIVERED", atBottom: false, scrollable: true },
|
||||
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
|
||||
]);
|
||||
check(touchDownOnce.state.mode === "manual", "a single touch-down at the bottom stays manual");
|
||||
|
||||
const holdBrokenByUpwardGesture = run([
|
||||
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
|
||||
{ type: "USER_SCROLL_INTENT", canClaimTail: true },
|
||||
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
|
||||
// An upward gesture inside the streak resets the hold; the next downward
|
||||
// gesture starts again from zero.
|
||||
{ type: "USER_SCROLL_INTENT", canClaimTail: false },
|
||||
{ type: "USER_SCROLL_INTENT", canClaimTail: true },
|
||||
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
|
||||
]);
|
||||
check(holdBrokenByUpwardGesture.state.mode === "manual", "an upward gesture breaks the bottom-hold streak");
|
||||
|
||||
const holdEndsWithIntentWindow = run([
|
||||
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
|
||||
{ type: "USER_SCROLL_INTENT", canClaimTail: true },
|
||||
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
|
||||
{ type: "READER_INTENT_ENDED" },
|
||||
{ type: "USER_SCROLL_INTENT", canClaimTail: true },
|
||||
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
|
||||
]);
|
||||
check(holdEndsWithIntentWindow.state.mode === "manual", "a closed intent window ends the bottom-hold streak");
|
||||
|
||||
const steadyStateOffsetKeepsManual = run([
|
||||
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
|
||||
{ type: "USER_SCROLL_INTENT", canClaimTail: false },
|
||||
{ type: "SCROLL_DELIVERED", atBottom: false, scrollable: true },
|
||||
{ type: "READER_INTENT_ENDED" },
|
||||
{ type: "SCROLL_TO_OFFSET", owner: "anchor-compensation", top: 640 },
|
||||
{ type: "SCROLL_TO_OFFSET", owner: "block-window-prepend", top: 680 },
|
||||
]);
|
||||
check(steadyStateOffsetKeepsManual.state.mode === "manual", "steady-state offset corrections keep manual ownership");
|
||||
check(
|
||||
steadyStateOffsetKeepsManual.commands.join(",") === "SCROLL_TO_OFFSET,SCROLL_TO_OFFSET",
|
||||
"steady-state offset corrections emit only their own commands",
|
||||
);
|
||||
|
||||
const browserClamp = run([
|
||||
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
|
||||
@@ -207,5 +255,21 @@ check(
|
||||
"substantial displacements keep reconverging after a misread shrink",
|
||||
);
|
||||
|
||||
const wrapScroller = { scrollHeight: 500, scrollTop: 400, clientHeight: 80 };
|
||||
check(pinTranscriptScrollerToNativeTail(wrapScroller) === true, "a composer-wrap viewport shrink is off-bottom and gets pinned");
|
||||
check(wrapScroller.scrollTop === 420, "pin writes the native tail top");
|
||||
check(pinTranscriptScrollerToNativeTail(wrapScroller) === false, "an already-pinned scroller is left alone");
|
||||
|
||||
const foldScroller = { scrollHeight: 500, scrollTop: 400, clientHeight: 80 };
|
||||
check(
|
||||
pinTranscriptTailAfterViewportShrink(foldScroller, { contentExtent: 540, viewportExtent: 100 }, true) === null,
|
||||
"content collapse suppresses a coincident viewport-shrink pin",
|
||||
);
|
||||
check(foldScroller.scrollTop === 400, "content collapse leaves the browser-owned offset unchanged");
|
||||
check(
|
||||
pinTranscriptTailAfterViewportShrink(foldScroller, { contentExtent: 500, viewportExtent: 100 }, false) === null,
|
||||
"manual reading suppresses viewport-shrink pinning",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,7 +1,7 @@
|
||||
// Run: tsx src/__tests__/transcript-state-snapshot.test.ts
|
||||
|
||||
import type { StateSnapshot } from "react-virtuoso";
|
||||
import { resolveTranscriptStateSnapshot } from "../lib/transcriptStateSnapshot";
|
||||
import type { StateSnapshot, VirtuosoHandle } from "react-virtuoso";
|
||||
import { captureTranscriptVirtuosoState, resolveTranscriptStateSnapshot } from "../lib/transcriptStateSnapshot";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -18,6 +18,12 @@ function check(condition: unknown, label: string) {
|
||||
|
||||
console.log("\ntranscript state snapshot");
|
||||
|
||||
const liveSnapshot = { ranges: [{ startIndex: 0, endIndex: 1, size: 100 }], scrollTop: 42 } as StateSnapshot;
|
||||
const fakeHandle = { getState: (callback: (snapshot: StateSnapshot) => void) => callback(liveSnapshot) } as VirtuosoHandle;
|
||||
check(captureTranscriptVirtuosoState(null) === null, "a missing Virtuoso handle has no snapshot");
|
||||
check(captureTranscriptVirtuosoState(fakeHandle) === liveSnapshot,
|
||||
"snapshot capture returns Virtuoso's synchronous measured state");
|
||||
|
||||
const base: StateSnapshot = {
|
||||
scrollTop: 420,
|
||||
ranges: [
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// refs, and the markdown cache budget.
|
||||
|
||||
import { TranscriptStore } from "../lib/transcriptStore";
|
||||
import { historyPageRequestBudget } from "../lib/historyPaging";
|
||||
import { historyMessagesToItems, type Item } from "../lib/useController";
|
||||
import type {
|
||||
HistoryContentChunk,
|
||||
@@ -104,7 +105,8 @@ class FakeBackend {
|
||||
};
|
||||
}
|
||||
|
||||
// Turn-budgeted windowing, mirroring the Go slice semantics.
|
||||
// Turn- and entry-budgeted windowing, mirroring the Go slice semantics for
|
||||
// the compact stress fixtures used below.
|
||||
async HistorySliceForTab(_tabID: string, req: HistorySliceRequest): Promise<HistorySlice> {
|
||||
this.sliceCalls.push(req);
|
||||
if (this.sliceGate) {
|
||||
@@ -135,6 +137,8 @@ class FakeBackend {
|
||||
if (turnsOf[i] >= oldestTurn) { lo = i; break; }
|
||||
}
|
||||
}
|
||||
const entries = Math.max(1, Math.floor(req.entries || 120));
|
||||
lo = Math.max(lo, before - entries);
|
||||
return this.slice(lo, before);
|
||||
}
|
||||
|
||||
@@ -181,6 +185,34 @@ function bigTranscript(turns: number): HistoryMessage[] {
|
||||
return messages;
|
||||
}
|
||||
|
||||
function longArchivedTranscript(turns: number): HistoryMessage[] {
|
||||
const messages: HistoryMessage[] = [];
|
||||
for (let turn = 1; turn <= turns; turn += 1) {
|
||||
const callId = `archived-${turn}`;
|
||||
messages.push({ role: "user", content: `prompt ${turn}` });
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: `answer ${turn}`,
|
||||
toolCalls: [{
|
||||
id: callId,
|
||||
name: "bash",
|
||||
arguments: "",
|
||||
argumentsArchived: true,
|
||||
subject: `command ${turn}`,
|
||||
summary: "1 line",
|
||||
}],
|
||||
});
|
||||
messages.push({
|
||||
role: "tool",
|
||||
toolCallId: callId,
|
||||
toolName: "bash",
|
||||
content: "",
|
||||
toolResultArchived: true,
|
||||
});
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Canonical shape for cross-scheme equality (ids are scheme-dependent and
|
||||
// verified separately).
|
||||
function canon(items: Item[]): unknown[] {
|
||||
@@ -219,6 +251,11 @@ console.log("\ntranscript store");
|
||||
const first = await store.loadLatest("tab-1", "/s/one.jsonl", { turns: 12 });
|
||||
ok(!!first && first.items.length > 0, "latest page projects items");
|
||||
eq(first?.hasOlder, true, "latest page reports older history");
|
||||
const projectedTurns = (first?.items ?? [])
|
||||
.filter((item): item is Extract<Item, { kind: "user" }> => item.kind === "user")
|
||||
.map((item) => item.historyTurn);
|
||||
eq(projectedTurns[projectedTurns.length - 1], 46, "history user items retain their absolute turn for complete-session navigation");
|
||||
ok(projectedTurns.every((turn) => Number.isInteger(turn) && (turn ?? 0) > 0), "every paged user item carries an absolute history turn");
|
||||
const firstIds = (first?.items ?? []).map((item) => item.id);
|
||||
await drainOlder(store, "tab-1", "/s/one.jsonl", 12);
|
||||
const full = store.peek("tab-1", "/s/one.jsonl");
|
||||
@@ -230,6 +267,36 @@ console.log("\ntranscript store");
|
||||
eq(unique.size, fullIds.length, "item ids are unique across the full projection");
|
||||
}
|
||||
|
||||
// ── 10,000-turn targeted paging stays bounded ──────────────────────────────
|
||||
{
|
||||
const messages = longArchivedTranscript(10_000);
|
||||
const backend = new FakeBackend(messages, new Map(), "stress");
|
||||
const store = new TranscriptStore(backend);
|
||||
const startedAt = performance.now();
|
||||
let projection = await store.loadLatest("tab-stress", "/s/stress.jsonl", { turns: 60 });
|
||||
let pages = 1;
|
||||
while (projection?.hasOlder && pages <= 40) {
|
||||
const budget = historyPageRequestBudget(projection.startTurn, projection.totalTurns, 1);
|
||||
const older = await store.loadOlder("tab-stress", "/s/stress.jsonl", budget);
|
||||
if (!older) break;
|
||||
projection = older;
|
||||
pages += 1;
|
||||
}
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
const users = (projection?.items ?? []).filter((item): item is Extract<Item, { kind: "user" }> => item.kind === "user");
|
||||
eq(projection?.hasOlder, false, "10,000-turn target paging reaches the first page");
|
||||
eq(pages, 32, "10,000-turn target paging respects both turn and production entry bounds");
|
||||
eq(backend.sliceCalls.length, 32, "10,000-turn target paging performs the expected bounded backend calls");
|
||||
ok(backend.sliceCalls.slice(1).every((request) => request.entries === 1000), "targeted pages use the backend's bounded 1000-entry capacity");
|
||||
eq(users.length, 10_000, "10,000-turn target paging preserves every question");
|
||||
eq(users[0]?.historyTurn, 1, "10,000-turn target paging lands on absolute turn one");
|
||||
eq(users[users.length - 1]?.historyTurn, 10_000, "10,000-turn target paging keeps the tail coordinate");
|
||||
ok(new Set(projection?.items.map((item) => item.id)).size === projection?.items.length, "10,000-turn target paging keeps item ids unique");
|
||||
const stats = store.stats();
|
||||
ok(stats.bodyBytes <= stats.bodyBudgetBytes, "10,000-turn transcript stays within the production history body budget");
|
||||
ok(elapsedMs < 10_000, `10,000-turn targeted paging completes within 10s (${elapsedMs.toFixed(1)}ms)`);
|
||||
}
|
||||
|
||||
// ── cross-page tool call/result merge ───────────────────────────────────────
|
||||
{
|
||||
const messages: HistoryMessage[] = [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Run: tsx src/__tests__/use-controller-meta.test.ts
|
||||
|
||||
import { currentTurnWaitMs, effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, historyMessagesToItems, initialState, localizedBackendNoticeText, localizedNoticeText, metaFromTab, modelSwitchNoticeText, reducer, sameMeta, type Item } from "../lib/useController";
|
||||
import { historyPageRequestBudget, historyTurnsToLoad } from "../lib/historyPaging";
|
||||
import { shouldReconcileStaleTurn } from "../lib/useStaleTurnWatchdog";
|
||||
import { parseTodos } from "../lib/tools";
|
||||
import { resolveTodoPanelTodos } from "../lib/todoVisibility";
|
||||
@@ -273,6 +274,12 @@ console.log("\nuse controller meta");
|
||||
}
|
||||
|
||||
{
|
||||
eq(historyTurnsToLoad(941, 1_000, 1), 500, "a distant question jump uses the bounded 500-turn history window");
|
||||
eq(historyTurnsToLoad(441, 1_000, 1), 440, "the follow-up jump page reaches the requested turn without overfetching");
|
||||
eq(historyTurnsToLoad(2, 61), 60, "ordinary automatic history loading keeps the standard page size");
|
||||
eq(JSON.stringify(historyPageRequestBudget(941, 1_000, 1)), JSON.stringify({ turns: 500, entries: 1000 }), "a distant jump uses the backend's bounded entry capacity");
|
||||
eq(JSON.stringify(historyPageRequestBudget(2, 61)), JSON.stringify({ turns: 60 }), "ordinary history loading keeps the default entry and byte budgets");
|
||||
|
||||
let s = reducer(initialState, {
|
||||
type: "event",
|
||||
e: { kind: "notice", level: "warn", code: "session_recovery_depth_cap", text: "reworded recovery maintenance" },
|
||||
@@ -720,12 +727,17 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
|
||||
},
|
||||
});
|
||||
eq(s.items.some((item) => item.kind === "user" && item.text === "recent prompt"), true, "history page replace renders the latest window");
|
||||
eq(s.historyStartTurn, 60, "history page stores the older cursor");
|
||||
eq(s.historyStartTurn, 61, "legacy history page converts its zero-based cursor to the first absolute turn");
|
||||
eq(s.historyHasOlder, true, "history page records older availability");
|
||||
const recentUser = s.items.find((item) => item.kind === "user" && item.text === "recent prompt");
|
||||
eq(recentUser?.kind === "user" && recentUser.checkpointTurn, 1060, "paged history hydrates its authoritative checkpoint turn");
|
||||
eq(recentUser?.kind === "user" && recentUser.historyTurn, 61, "legacy history page preserves the absolute question turn");
|
||||
s = reducer(s, { type: "history_older_start" });
|
||||
eq(s.historyOlderLoading, true, "older history request marks loading");
|
||||
s = reducer(s, { type: "history_older_error", error: "read failed" });
|
||||
eq(s.historyOlderError, "read failed", "older history failures remain available to the retry UI");
|
||||
s = reducer(s, { type: "history_older_start" });
|
||||
eq(s.historyOlderError, undefined, "retrying older history clears the previous failure");
|
||||
s = reducer(s, {
|
||||
type: "history_page",
|
||||
mode: "prepend",
|
||||
@@ -743,6 +755,8 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
|
||||
const users = s.items.filter((item) => item.kind === "user");
|
||||
eq(users[0]?.kind === "user" && users[0].text, "older prompt", "older history prepends before the current window");
|
||||
eq(users[1]?.kind === "user" && users[1].text, "recent prompt", "older history keeps the current window");
|
||||
eq(users[0]?.kind === "user" && users[0].historyTurn, 1, "legacy prepend starts at absolute turn one");
|
||||
eq(users[1]?.kind === "user" && users[1].historyTurn, 61, "legacy prepend keeps the recent page's absolute turn");
|
||||
eq(s.historyHasOlder, false, "older history clears hasOlder when all pages are loaded");
|
||||
eq(s.historyOlderLoading, false, "older history clears loading");
|
||||
}
|
||||
|
||||
@@ -52,9 +52,14 @@ function ev(s: typeof initialState, e: WireEvent) {
|
||||
review: "passed",
|
||||
gap_kinds: [],
|
||||
constraint_degraded: false,
|
||||
floor: "standard",
|
||||
attention: false,
|
||||
},
|
||||
});
|
||||
eq(complete.items, before.items, "ordinary completion summary stays off the transcript");
|
||||
eq(complete.items.length, before.items.length + 1, "workspace mutations add a neutral change notice");
|
||||
const changeNotice = complete.items[complete.items.length - 1];
|
||||
eq(changeNotice?.kind === "notice" ? changeNotice.level : "", "info", "workspace mutations use an info notice, not a warning");
|
||||
eq(changeNotice?.kind === "notice" ? changeNotice.completionSummary : undefined, complete.completionSummary, "neutral change notice retains its own normalized summary");
|
||||
eq(complete.completionSummary?.preset, "balanced", "ordinary completion summary remains available to the change panel");
|
||||
|
||||
const after = ev(complete, {
|
||||
@@ -69,15 +74,48 @@ function ev(s: typeof initialState, e: WireEvent) {
|
||||
review: "passed",
|
||||
gap_kinds: ["stale_check"],
|
||||
constraint_degraded: true,
|
||||
floor: "delivery",
|
||||
attention: true,
|
||||
},
|
||||
});
|
||||
eq(after.items.length, before.items.length + 1, "actionable completion summary adds one compact transcript notice");
|
||||
eq(after.items.length, complete.items.length + 1, "actionable completion summary adds one compact transcript notice");
|
||||
const notice = after.items[after.items.length - 1];
|
||||
eq(notice?.kind === "notice" ? notice.variant : "", "completion", "quality gap uses the completion notice variant");
|
||||
eq(notice?.kind === "notice" ? notice.action : "", "open_changes", "quality gap links to the change panel");
|
||||
eq(notice?.kind === "notice" ? notice.completionSummary : undefined, after.completionSummary, "completion notice retains its own normalized summary");
|
||||
eq(notice?.kind === "notice" ? notice.text.includes("balanced") : true, false, "compact notice does not expose internal preset values");
|
||||
eq(after.completionSummary?.checks_failed, 1, "actionable completion summary is retained for details");
|
||||
|
||||
const switchedFloor = ev({
|
||||
...complete,
|
||||
meta: { label: "test", ready: true, eventChannel: "", cwd: "/repo", qualityFloor: "delivery" },
|
||||
}, {
|
||||
kind: "completion_summary",
|
||||
completion: {
|
||||
...complete.completionSummary!,
|
||||
verdict: "partial",
|
||||
gap_kinds: ["unverified_change"],
|
||||
floor: "standard",
|
||||
attention: false,
|
||||
},
|
||||
});
|
||||
const switchedNotice = switchedFloor.items[switchedFloor.items.length - 1];
|
||||
eq(switchedNotice?.kind === "notice" ? switchedNotice.level : "", "info", "turn-time standard summary stays neutral after switching to delivery");
|
||||
|
||||
const suppressed = ev(complete, {
|
||||
kind: "completion_summary",
|
||||
completion: {
|
||||
...complete.completionSummary!,
|
||||
mutations: 0,
|
||||
checks_suppressed: 1,
|
||||
gap_kinds: ["suppressed_requirement"],
|
||||
floor: "delivery",
|
||||
attention: true,
|
||||
},
|
||||
});
|
||||
const suppressedNotice = suppressed.items[suppressed.items.length - 1];
|
||||
eq(suppressedNotice?.kind === "notice" ? suppressedNotice.title : "", "This turn still needs attention", "required suppression uses a generic attention notice");
|
||||
|
||||
const restarted = ev(after, { kind: "turn_started" });
|
||||
eq(restarted.completionSummary, undefined, "a new turn clears the previous turn's quality details");
|
||||
}
|
||||
|
||||
@@ -1,28 +1,9 @@
|
||||
// Run: tsx src/__tests__/workspace-changes-errors.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import { registerHooks } from "node:module";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { workspaceFileIcon } from "../components/WorkspaceFileIcon";
|
||||
import { WorkspacePanel } from "../components/WorkspacePanel";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { DirEntry, GitCommitView, WireCompletionSummary, WorkspaceChangeDetailView, WorkspaceChangesView } from "../lib/types";
|
||||
|
||||
// Markdown previews lazy-load MarkdownRenderer, whose KaTeX stylesheet belongs
|
||||
// to the same production chunk. Node's tsx loader has no CSS module support,
|
||||
// so map stylesheet imports to the existing empty asset stub in this DOM test.
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.endsWith(".css")) {
|
||||
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
import type { DirEntry, FilePreview, WorkspaceChangeDetailView, WorkspaceChangesView } from "../lib/types";
|
||||
import { flushPromises, renderFilesWorkspace, renderWorkspace, waitFor } from "./workspace-panel-test-harness";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -37,169 +18,6 @@ function ok(value: boolean, label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
dom.window.ResizeObserver = TestResizeObserver;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
(dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void }).attachEvent = () => {};
|
||||
(dom.window.HTMLElement.prototype as unknown as { detachEvent: () => void }).detachEvent = () => {};
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", {
|
||||
configurable: true,
|
||||
get: () => 320,
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get: function offsetHeight(this: HTMLElement) {
|
||||
return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: function getBoundingClientRect(this: HTMLElement) {
|
||||
const width = 320;
|
||||
const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: width,
|
||||
bottom: height,
|
||||
width,
|
||||
height,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
},
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
async function renderWorkspace(
|
||||
changes: WorkspaceChangesView,
|
||||
options: { creationMode?: boolean; history?: GitCommitView[]; detail?: WorkspaceChangeDetailView; completionSummary?: WireCompletionSummary } = {},
|
||||
) {
|
||||
resetWorkspaceTreeMemoryForTests();
|
||||
const dom = installDom();
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListDirForTab: async () => [],
|
||||
WorkspaceGitHistory: async () => options.history ?? [],
|
||||
WorkspaceChanges: async () => changes,
|
||||
WorkspaceChangeDetail: async () => options.detail ?? {},
|
||||
ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<WorkspacePanel
|
||||
open
|
||||
tabId="tab-a"
|
||||
cwd="/repo"
|
||||
maximized={false}
|
||||
initialViewMode="changed"
|
||||
creationMode={options.creationMode}
|
||||
completionSummary={options.completionSummary}
|
||||
onClose={() => {}}
|
||||
onToggleMaximized={() => {}}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("workspace changes", () => Boolean(document.querySelector(".workspace-preview__body")));
|
||||
return { dom, root };
|
||||
}
|
||||
|
||||
async function renderFilesWorkspace(methods: Partial<AppBindings>, props: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) {
|
||||
resetWorkspaceTreeMemoryForTests();
|
||||
const dom = installDom();
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListDirForTab: async () => [],
|
||||
SearchFileRefsForTab: async () => [],
|
||||
WorkspaceGitHistory: async () => [],
|
||||
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
|
||||
WorkspaceChangeDetail: async () => ({}),
|
||||
ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
|
||||
...methods,
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
let currentProps: Parameters<typeof WorkspacePanel>[0] = {
|
||||
open: true,
|
||||
tabId: "tab-a",
|
||||
cwd: "/repo",
|
||||
maximized: false,
|
||||
initialViewMode: "files",
|
||||
onClose: () => {},
|
||||
onToggleMaximized: () => {},
|
||||
...props,
|
||||
};
|
||||
const rerender = async (nextProps: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) => {
|
||||
currentProps = { ...currentProps, ...nextProps };
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<WorkspacePanel {...currentProps} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
};
|
||||
await rerender();
|
||||
return { dom, root, rerender };
|
||||
}
|
||||
|
||||
console.log("\nworkspace changes git errors");
|
||||
|
||||
{
|
||||
@@ -833,6 +651,8 @@ console.log("\nworkspace changes git errors");
|
||||
}
|
||||
|
||||
{
|
||||
let resolveCodePreview!: (preview: FilePreview) => void;
|
||||
const codePreview = new Promise<FilePreview>((resolve) => { resolveCodePreview = resolve; });
|
||||
const { dom, root } = await renderFilesWorkspace({
|
||||
ListDirForTab: async (_tabId, dir) => dir === ""
|
||||
? [
|
||||
@@ -840,13 +660,9 @@ console.log("\nworkspace changes git errors");
|
||||
{ name: "README.md", isDir: false },
|
||||
]
|
||||
: [],
|
||||
ReadFileForTab: async (_tabId, path) => ({
|
||||
path,
|
||||
body: path === "README.md" ? "# Documentation" : "const value = 42;",
|
||||
size: 17,
|
||||
truncated: false,
|
||||
binary: false,
|
||||
}),
|
||||
ReadFileForTab: async (_tabId, path) => path === "README.md"
|
||||
? { path, body: "# Documentation", size: 15, truncated: false, binary: false }
|
||||
: codePreview,
|
||||
});
|
||||
|
||||
await waitFor("searchable code file", () => document.querySelector('[data-workspace-path="code.ts"]') != null);
|
||||
@@ -856,11 +672,15 @@ console.log("\nworkspace changes git errors");
|
||||
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("pending code preview layout", () => document.querySelector(".workspace-preview__body--code") != null);
|
||||
ok(document.querySelector(".workspace-preview__body--code") != null, "code preview uses its final layout while loading");
|
||||
await act(async () => {
|
||||
resolveCodePreview({ path: "code.ts", body: "const value = 42;", size: 17, truncated: false, binary: false });
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("code preview search action", () => document.querySelector('button[aria-label="Find"]') != null);
|
||||
ok(
|
||||
document.querySelector('button[aria-label="Find"]') != null,
|
||||
"searchable code previews expose a visible search action",
|
||||
);
|
||||
ok(document.querySelector(".workspace-preview__body--code") != null, "resolved code preview keeps the loading layout");
|
||||
ok(document.querySelector('button[aria-label="Find"]') != null, "searchable code previews expose a visible search action");
|
||||
|
||||
const filterInput = document.querySelector<HTMLInputElement>('input[placeholder="Filter files…"]');
|
||||
await act(async () => {
|
||||
@@ -874,10 +694,7 @@ console.log("\nworkspace changes git errors");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("panel-scoped code search", () => document.querySelector(".code-search__input") != null);
|
||||
ok(
|
||||
document.activeElement === document.querySelector(".code-search__input"),
|
||||
"workspace find shortcut opens and focuses code search from the file filter",
|
||||
);
|
||||
ok(document.activeElement === document.querySelector(".code-search__input"), "workspace find shortcut opens and focuses code search from the file filter");
|
||||
|
||||
await act(async () => {
|
||||
document
|
||||
@@ -893,10 +710,7 @@ console.log("\nworkspace changes git errors");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("button-opened code search", () => document.querySelector(".code-search__input") != null);
|
||||
ok(
|
||||
document.activeElement === document.querySelector(".code-search__input"),
|
||||
"visible search action opens and focuses the same search UI",
|
||||
);
|
||||
ok(document.activeElement === document.querySelector(".code-search__input"), "visible search action opens and focuses the same search UI");
|
||||
|
||||
await act(async () => {
|
||||
document
|
||||
@@ -905,10 +719,7 @@ console.log("\nworkspace changes git errors");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("markdown preview", () => document.body.textContent?.includes("Documentation") === true);
|
||||
ok(
|
||||
document.querySelector('button[aria-label="Find"]') == null,
|
||||
"Markdown previews do not expose the code-search action",
|
||||
);
|
||||
ok(document.querySelector('button[aria-label="Find"]') == null, "Markdown previews do not expose the code-search action");
|
||||
const markdownFindEvent = new window.KeyboardEvent("keydown", {
|
||||
key: "f",
|
||||
ctrlKey: true,
|
||||
|
||||
@@ -19,6 +19,10 @@ let failed = 0;
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
|
||||
const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8");
|
||||
const moreMenuSource = [
|
||||
readFileSync(resolve(testDir, "../components/TopicbarMoreMenu.tsx"), "utf8"),
|
||||
readFileSync(resolve(testDir, "../components/TopicbarMoreMenuContent.tsx"), "utf8"),
|
||||
].join("\n");
|
||||
const terminalPanelSource = readFileSync(resolve(testDir, "../components/TerminalPanel.tsx"), "utf8");
|
||||
const terminalViewSource = readFileSync(resolve(testDir, "../components/TerminalView.tsx"), "utf8");
|
||||
const terminalRailSource = readFileSync(resolve(testDir, "../components/TerminalSessionRail.tsx"), "utf8");
|
||||
@@ -160,7 +164,7 @@ eq(terminalMaxHeight(180), 120, "terminal maximum never falls below the accessib
|
||||
eq(clampTerminalHeight(680, 480), 240, "restored terminal height clamps after the window shrinks");
|
||||
eq(clampTerminalHeight(80, 720), 120, "terminal height clamps to its minimum");
|
||||
eq(
|
||||
/const closeWorkspacePanel = useCallback\(\(\) => \{[\s\S]*?setLiveWorkspacePanelRenderWidth\(null\);[\s\S]*?setWorkspacePanelOpen\(false\);[\s\S]*?saveWorkspacePanelOpen\(false\);/.test(appSource),
|
||||
/const closeWorkspacePanel = useCallback\(\(\) => \{[\s\S]*?setLiveWorkspacePanelRenderWidth\(null\);[\s\S]*?setWorkspacePanelOpen\(false\);[\s\S]*?saveWorkspacePanelOpen\(false, activeWorkspaceRoot\);/.test(appSource),
|
||||
true,
|
||||
"closing the dock clears the transient render width, hides the panel, and persists the collapsed preference",
|
||||
);
|
||||
@@ -177,7 +181,7 @@ eq(
|
||||
"workspace resize has one guarded finish path for capture loss, blur, cancellation, and unmount",
|
||||
);
|
||||
eq(
|
||||
/setWorkspacePanelOpen\(true\);[\s\S]*?saveWorkspacePanelOpen\(true\);/.test(appSource),
|
||||
/setWorkspacePanelOpen\(true\);[\s\S]*?saveWorkspacePanelOpen\(true, activeWorkspaceRoot\);/.test(appSource),
|
||||
true,
|
||||
"opening the dock persists the expanded preference for the next launch",
|
||||
);
|
||||
@@ -253,7 +257,8 @@ const workspaceDockTabsSource = appSource.match(/<div className="workbench-dock_
|
||||
eq(
|
||||
workspaceDockTabsSource.length > 0
|
||||
&& !/rightDock\.terminal|terminalPanelOpen|toggleTerminalPanel/.test(workspaceDockTabsSource)
|
||||
&& /className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"[\s\S]*?aria-label=\{t\("rightDock\.terminal"\)\}[\s\S]*?onClick=\{toggleTerminalPanel\}/.test(appSource),
|
||||
&& /<TopicbarMoreMenu[\s\S]*?toggleTerminal=\{toggleTerminalPanel\}/.test(appSource)
|
||||
&& /className="topicbar__menu-item"[\s\S]*?closeAndRun\(toggleTerminal\)[\s\S]*?t\("rightDock\.terminal"\)/.test(moreMenuSource),
|
||||
true,
|
||||
"workspace dock omits the terminal view while the topic bar keeps the terminal drawer action",
|
||||
);
|
||||
@@ -324,11 +329,11 @@ eq(
|
||||
"terminal and xterm remain in a lazy chunk",
|
||||
);
|
||||
eq(
|
||||
/onPointerEnter=\{prefetchTerminalPanel\}/.test(appSource)
|
||||
&& /onFocus=\{prefetchTerminalPanel\}/.test(appSource)
|
||||
/onPointerEnter=\{prefetchTerminal\}/.test(moreMenuSource)
|
||||
&& /onFocus=\{prefetchTerminal\}/.test(moreMenuSource)
|
||||
&& /void import\("\.\.\/components\/TerminalPanel"\)/.test(terminalLifecycleSource),
|
||||
true,
|
||||
"pointer and keyboard intent prefetch the terminal chunk before opening",
|
||||
"pointer and keyboard intent prefetch the terminal chunk before opening (More menu)",
|
||||
);
|
||||
eq(
|
||||
/useWarmTerminalPanel\(terminalPanelOpen, terminalResizing\)/.test(appSource)
|
||||
@@ -370,5 +375,45 @@ eq(
|
||||
"closing a warm terminal removes portaled selection controls",
|
||||
);
|
||||
|
||||
// C1: the chat pane keeps its 400px floor no matter how wide the dock is
|
||||
// dragged — the dock's available width is viewport minus sidebar minus the
|
||||
// 400px chat minimum minus the resizer, so chat can never be squeezed below it.
|
||||
const chatFloorDock = availableWorkspacePanelWidth({
|
||||
viewportWidth: 1000,
|
||||
sidebarCollapsed: false,
|
||||
sidebarWidth: SIDEBAR_WIDTH,
|
||||
chatMinWidth: CHAT_MIN_WIDTH,
|
||||
resizerWidth: RESIZER_WIDTH,
|
||||
});
|
||||
eq(
|
||||
chatFloorDock + SIDEBAR_WIDTH + CHAT_MIN_WIDTH + RESIZER_WIDTH <= 1000,
|
||||
true,
|
||||
"C1: dock width never consumes the chat 400px floor (chat stays readable)",
|
||||
);
|
||||
// Sanity: with a wide viewport the dock gets more room, but the chat floor is
|
||||
// still reserved — chat is never the thing that shrinks.
|
||||
const wideDock = availableWorkspacePanelWidth({
|
||||
viewportWidth: 1600,
|
||||
sidebarCollapsed: false,
|
||||
sidebarWidth: SIDEBAR_WIDTH,
|
||||
chatMinWidth: CHAT_MIN_WIDTH,
|
||||
resizerWidth: RESIZER_WIDTH,
|
||||
});
|
||||
eq(wideDock > chatFloorDock, true, "C1: wider viewport gives the dock more room, chat floor untouched");
|
||||
|
||||
// C3: switching dock tabs (context/files/changed) must never resize the dock —
|
||||
// the preferred width is a single source (rightDockTreeWidth), not a
|
||||
// detail-dependent ternary that would jump the sidebar per tab.
|
||||
eq(
|
||||
/const preferredWorkspacePanelWidth = rightDockTreeWidth;/.test(appSource),
|
||||
true,
|
||||
"C3: preferredWorkspacePanelWidth is the single tree width (no detail ternary)",
|
||||
);
|
||||
eq(
|
||||
/const preferredWorkspacePanelWidth = rightDockDetailActive \? rightDockPreviewWidth : rightDockTreeWidth;/.test(appSource),
|
||||
false,
|
||||
"C3: no preview-width dual system that would resize the sidebar on tab switch",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,65 @@
|
||||
// Run: tsx src/__tests__/workspace-panel-open-migration.test.ts
|
||||
// zk-ge C14 兼容性:项目级 dock 开合 key 缺失时回退旧全局 key,升级不翻转用户选择
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function installLocalStorage(seed: Record<string, string>) {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost/" });
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
for (const [key, value] of Object.entries(seed)) {
|
||||
dom.window.localStorage.setItem(key, value);
|
||||
}
|
||||
return dom;
|
||||
}
|
||||
|
||||
// 动态 import(依赖 window/localStorage 已就绪)
|
||||
async function loadModule() {
|
||||
return await import("../store/layout");
|
||||
}
|
||||
|
||||
console.log("\nworkspace dock-open legacy key migration (CLAIM.PERSIST.014)");
|
||||
|
||||
{
|
||||
// 场景 1:项目 key 缺失 + 旧全局 key = 关闭("0") → 首次打开项目应保持关闭
|
||||
installLocalStorage({ "reasonix.workspacePanel.open": "0" });
|
||||
const { loadWorkspacePanelOpen } = await loadModule();
|
||||
ok(loadWorkspacePanelOpen("project-a") === false, "project key missing + legacy global closed => stays closed");
|
||||
}
|
||||
|
||||
{
|
||||
// 场景 2:项目 key 缺失 + 旧全局 key = 打开("1") → 首次打开项目应打开
|
||||
installLocalStorage({ "reasonix.workspacePanel.open": "1" });
|
||||
const { loadWorkspacePanelOpen } = await loadModule();
|
||||
ok(loadWorkspacePanelOpen("project-b") === true, "project key missing + legacy global open => stays open");
|
||||
}
|
||||
|
||||
{
|
||||
// 场景 3:项目 key 存在(优先于旧全局 key)
|
||||
installLocalStorage({ "reasonix.workspacePanel.open": "0", "reasonix.workspacePanel.open.project-c": "1" });
|
||||
const { loadWorkspacePanelOpen } = await loadModule();
|
||||
ok(loadWorkspacePanelOpen("project-c") === true, "project key present wins over legacy global");
|
||||
}
|
||||
|
||||
{
|
||||
// 场景 4:项目 key 和旧全局 key 都缺失 → 默认打开
|
||||
installLocalStorage({});
|
||||
const { loadWorkspacePanelOpen } = await loadModule();
|
||||
ok(loadWorkspacePanelOpen("project-d") === true, "neither key present => default open");
|
||||
}
|
||||
|
||||
console.log(`\nworkspace dock-open migration: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,181 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import { registerHooks } from "node:module";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { WorkspacePanel } from "../components/WorkspacePanel";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { GitCommitView, WireCompletionSummary, WorkspaceChangeDetailView, WorkspaceChangesView } from "../lib/types";
|
||||
import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
|
||||
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.endsWith(".css")) {
|
||||
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
export function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
export async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
dom.window.ResizeObserver = TestResizeObserver;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
(dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void }).attachEvent = () => {};
|
||||
(dom.window.HTMLElement.prototype as unknown as { detachEvent: () => void }).detachEvent = () => {};
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", {
|
||||
configurable: true,
|
||||
get: () => 320,
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get: function offsetHeight(this: HTMLElement) {
|
||||
return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: function getBoundingClientRect(this: HTMLElement) {
|
||||
const width = 320;
|
||||
const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: width,
|
||||
bottom: height,
|
||||
width,
|
||||
height,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
},
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
export async function renderWorkspace(
|
||||
changes: WorkspaceChangesView,
|
||||
options: { creationMode?: boolean; history?: GitCommitView[]; detail?: WorkspaceChangeDetailView; completionSummary?: WireCompletionSummary } = {},
|
||||
) {
|
||||
resetWorkspaceTreeMemoryForTests();
|
||||
const dom = installDom();
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListDirForTab: async () => [],
|
||||
WorkspaceGitHistory: async () => options.history ?? [],
|
||||
WorkspaceChanges: async () => changes,
|
||||
WorkspaceChangeDetail: async () => options.detail ?? {},
|
||||
ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<WorkspacePanel
|
||||
open
|
||||
tabId="tab-a"
|
||||
cwd="/repo"
|
||||
maximized={false}
|
||||
initialViewMode="changed"
|
||||
creationMode={options.creationMode}
|
||||
completionSummary={options.completionSummary}
|
||||
onClose={() => {}}
|
||||
onToggleMaximized={() => {}}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("workspace changes", () => Boolean(document.querySelector(".workspace-preview__body")));
|
||||
return { dom, root };
|
||||
}
|
||||
|
||||
export async function renderFilesWorkspace(methods: Partial<AppBindings>, props: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) {
|
||||
resetWorkspaceTreeMemoryForTests();
|
||||
const dom = installDom();
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListDirForTab: async () => [],
|
||||
SearchFileRefsForTab: async () => [],
|
||||
WorkspaceGitHistory: async () => [],
|
||||
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
|
||||
WorkspaceChangeDetail: async () => ({}),
|
||||
ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
|
||||
...methods,
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
let currentProps: Parameters<typeof WorkspacePanel>[0] = {
|
||||
open: true,
|
||||
tabId: "tab-a",
|
||||
cwd: "/repo",
|
||||
maximized: false,
|
||||
initialViewMode: "files",
|
||||
onClose: () => {},
|
||||
onToggleMaximized: () => {},
|
||||
...props,
|
||||
};
|
||||
const rerender = async (nextProps: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) => {
|
||||
currentProps = { ...currentProps, ...nextProps };
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<WorkspacePanel {...currentProps} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
};
|
||||
await rerender();
|
||||
return { dom, root, rerender };
|
||||
}
|
||||
@@ -96,7 +96,17 @@ eq(finalDeclaration(".code-search__actions", "display"), "flex", "search actions
|
||||
eq(finalDeclaration(".code-search__actions", "margin-left"), "auto", "wrapped search actions stay aligned to the toolbar edge");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code", "overflow"), "auto", "code viewport owns horizontal and vertical scrolling");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code", "min-height"), "0", "code viewport can shrink inside the preview pane");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code", "max-height"), "none", "workspace code clears the shared scroll-y height cap");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code", "margin"), "0", "code viewport scrollbar sits at the visible pane bottom");
|
||||
eq(
|
||||
computedDeclaration(
|
||||
`<html><head></head><body><div class="workspace-preview__body workspace-preview__body--code"><pre class="code code--scroll-y"></pre></div></body></html>`,
|
||||
".code--scroll-y",
|
||||
"max-height",
|
||||
),
|
||||
"none",
|
||||
"workspace selector wins over the shared virtual-scroll cap",
|
||||
);
|
||||
eq(finalDeclaration(".code-search__input", "min-width"), "60px", "search input reserves a minimum readable width");
|
||||
eq(
|
||||
computedDeclaration(
|
||||
@@ -107,24 +117,25 @@ eq(
|
||||
"0px",
|
||||
"themed workspace code keeps the line-number gutter flush",
|
||||
);
|
||||
const splitDom = `<html><head></head><body><aside class="workspace-panel"><section class="workspace-files"></section><section class="workspace-preview"></section><button class="workspace-tree-resizer"></button></aside></body></html>`;
|
||||
eq(
|
||||
finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden)", "grid-template-columns"),
|
||||
"var(--workspace-tree-rail-width) var(--workspace-tree-width) minmax(var(--workspace-preview-min-width), 1fr)",
|
||||
"split mode keeps a narrow tree toggle rail beside the file tree",
|
||||
computedDeclaration(splitDom, ".workspace-panel", "grid-template-columns"),
|
||||
"minmax(var(--workspace-preview-min-width), 1fr) var(--workspace-tree-width)",
|
||||
"split mode is a two-column layout: preview on the left, file tree on the right (the tree toggle rail is gone)",
|
||||
);
|
||||
eq(finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden) .workspace-files", "grid-column"), "2", "file tree sits beside the rail");
|
||||
eq(finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden) .workspace-preview", "grid-column"), "3", "preview sits after rail and tree");
|
||||
eq(computedDeclaration(splitDom, ".workspace-files", "grid-column"), "2", "file tree sits at the far right after the preview");
|
||||
eq(computedDeclaration(splitDom, ".workspace-preview", "grid-column"), "1", "preview sits to the left of the tree");
|
||||
eq(
|
||||
finalDeclaration(".workspace-panel--with-tree-rail .workspace-tree-resizer", "left"),
|
||||
"calc(var(--workspace-tree-rail-width) + var(--workspace-tree-width) - 4px)",
|
||||
"tree resizer accounts for the persistent rail",
|
||||
computedDeclaration(splitDom, ".workspace-tree-resizer", "left"),
|
||||
"calc(100% - var(--workspace-tree-width) - 4px)",
|
||||
"tree resizer sits at the tree's left edge (tree is on the right)",
|
||||
);
|
||||
eq(
|
||||
finalDeclaration(".workspace-panel--tree-hidden", "grid-template-columns"),
|
||||
"var(--workspace-tree-rail-width) minmax(0, 1fr)",
|
||||
"preview-only mode keeps a narrow tree toggle rail",
|
||||
"minmax(0, 1fr)",
|
||||
"preview-only mode drops the file tree",
|
||||
);
|
||||
eq(finalDeclaration(".workspace-panel--tree-hidden .workspace-preview", "grid-column"), "2", "preview sits beside the rail");
|
||||
eq(finalDeclaration(".workspace-panel--tree-hidden .workspace-preview", "grid-column"), "1", "preview fills the panel when the tree is hidden");
|
||||
eq(
|
||||
/const filePreviewLimit = 2 \* 1024 \* 1024/.test(appGo),
|
||||
true,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// Run: tsx src/__tests__/workspace-resize-interaction.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { WorkspacePanel } from "../components/WorkspacePanel";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq<T>(actual: T, expected: T, label: string) {
|
||||
if (Object.is(actual, expected)) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${String(expected)}, got ${String(actual)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: "en-US" });
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
dom.window.ResizeObserver = TestResizeObserver;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get: () => 800 });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get: function offsetHeight(this: HTMLElement) {
|
||||
return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: function getBoundingClientRect(this: HTMLElement) {
|
||||
const width = 800;
|
||||
const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
|
||||
return { x: 100, y: 0, top: 0, left: 100, right: 900, bottom: height, width, height, toJSON: () => ({}) } as DOMRect;
|
||||
},
|
||||
});
|
||||
|
||||
console.log("\nworkspace right-side tree resize interaction");
|
||||
|
||||
resetWorkspaceTreeMemoryForTests();
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListDirForTab: async (_tabId, dir) => dir === "" ? [{ name: "app.ts", isDir: false }] : [],
|
||||
SearchFileRefsForTab: async () => [],
|
||||
WorkspaceGitHistory: async () => [],
|
||||
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
|
||||
WorkspaceChangeDetail: async () => ({}),
|
||||
ReadFileForTab: async (_tabId, path) => ({ path, body: "const value = 1;", size: 16, truncated: false, binary: false }),
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("missing root");
|
||||
const root = createRoot(rootElement);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<WorkspacePanel
|
||||
open
|
||||
tabId="resize-tab"
|
||||
cwd="/repo"
|
||||
maximized={false}
|
||||
panelWidth={800}
|
||||
initialViewMode="files"
|
||||
onClose={() => {}}
|
||||
onToggleMaximized={() => {}}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
await waitFor("workspace file", () => document.querySelector('[data-workspace-path="app.ts"]') !== null);
|
||||
await act(async () => {
|
||||
document.querySelector<HTMLButtonElement>('[data-workspace-path="app.ts"]')?.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("tree separator", () => document.querySelector(".workspace-tree-resizer") !== null);
|
||||
|
||||
const separator = document.querySelector<HTMLButtonElement>(".workspace-tree-resizer");
|
||||
if (!separator) throw new Error("missing tree separator");
|
||||
const initialWidth = Number(separator.getAttribute("aria-valuenow"));
|
||||
|
||||
await act(async () => {
|
||||
separator.dispatchEvent(new window.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowRight" }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(Number(separator.getAttribute("aria-valuenow")), initialWidth - 16, "ArrowRight moves the separator right and narrows the right-side tree");
|
||||
|
||||
await act(async () => {
|
||||
separator.dispatchEvent(new window.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowLeft" }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(Number(separator.getAttribute("aria-valuenow")), initialWidth, "ArrowLeft moves the separator left and widens the right-side tree");
|
||||
|
||||
await act(async () => {
|
||||
separator.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, cancelable: true, clientX: 600 }));
|
||||
window.dispatchEvent(new window.MouseEvent("pointermove", { bubbles: true, clientX: 650 }));
|
||||
window.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, clientX: 650 }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(Number(separator.getAttribute("aria-valuenow")), 250, "pointer position measures tree width from the panel's right edge");
|
||||
|
||||
await act(async () => {
|
||||
separator.dispatchEvent(new window.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "End" }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(Number(separator.getAttribute("aria-valuenow")), 660, "End widens the tree to keep only the preview minimum");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,91 @@
|
||||
// Run: tsx src/__tests__/workspace-selection-isolation.test.tsx
|
||||
|
||||
import { act } from "react";
|
||||
import { flushPromises, renderFilesWorkspace, waitFor } from "./workspace-panel-test-harness";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nworkspace selection isolation");
|
||||
|
||||
const changeDetailPaths: string[] = [];
|
||||
const { dom, root, rerender } = await renderFilesWorkspace({
|
||||
ListDirForTab: async (_tabId, dir) => dir === "" ? [{ name: "notes.txt", isDir: false }] : [],
|
||||
WorkspaceChanges: async () => ({
|
||||
files: [{ path: "src/changed.ts", sources: ["git"], gitStatus: "M" }],
|
||||
gitAvailable: true,
|
||||
}),
|
||||
WorkspaceChangeDetail: async (_tabId, path) => {
|
||||
changeDetailPaths.push(path);
|
||||
return { source: "git", diff: "changed diff" };
|
||||
},
|
||||
ReadFileForTab: async (_tabId, path) => ({ path, body: "file preview", size: 12, truncated: false, binary: false }),
|
||||
});
|
||||
|
||||
await waitFor("files view entry", () => document.body.textContent?.includes("notes.txt") === true);
|
||||
await act(async () => {
|
||||
document.querySelector<HTMLButtonElement>('[data-workspace-path="notes.txt"]')
|
||||
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("selected file preview", () => document.body.textContent?.includes("file preview") === true);
|
||||
|
||||
await rerender({ initialViewMode: "changed" });
|
||||
await waitFor("changes overview", () => document.body.textContent?.includes("changed.ts") === true);
|
||||
ok(changeDetailPaths.length === 0, "entering Changes does not request diff detail for the Files selection");
|
||||
ok(document.querySelector(".workspace-panel--changed-overview") !== null, "entering Changes opens its overview instead of leaking the Files selection");
|
||||
|
||||
await act(async () => {
|
||||
document.querySelector<HTMLButtonElement>(".workspace-change")
|
||||
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("selected change detail", () => changeDetailPaths.includes("src/changed.ts"));
|
||||
|
||||
await rerender({ initialViewMode: "files" });
|
||||
await waitFor("restored file preview", () => document.body.textContent?.includes("file preview") === true);
|
||||
ok(changeDetailPaths.every((path) => path === "src/changed.ts"), "Changes requests only its own selected path");
|
||||
ok(document.body.textContent?.includes("notes.txt") === true, "returning to Files restores the prior file selection after viewing a change");
|
||||
|
||||
await rerender({
|
||||
initialViewMode: "changed",
|
||||
changeListRequest: {
|
||||
id: 1,
|
||||
changes: [{ key: "scoped-change", path: "src/changed.ts", meta: "M", time: "", detail: "changed" }],
|
||||
},
|
||||
});
|
||||
await waitFor("scoped Changes list", () => document.body.textContent?.includes("Session changes") === true);
|
||||
await rerender({ initialViewMode: "files", changeListRequest: null });
|
||||
await waitFor("file preview after scoped Changes", () => document.body.textContent?.includes("file preview") === true);
|
||||
ok(document.body.textContent?.includes("notes.txt") === true, "scoped Changes requests preserve the Files preview state");
|
||||
|
||||
await rerender({ initialViewMode: "changed" });
|
||||
await waitFor("change before scope switch", () => document.body.textContent?.includes("changed.ts") === true);
|
||||
await act(async () => {
|
||||
document.querySelector<HTMLButtonElement>(".workspace-change")
|
||||
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("change selected before scope switch", () => changeDetailPaths.includes("src/changed.ts"));
|
||||
const callsBeforeScopeSwitch = changeDetailPaths.length;
|
||||
await rerender({ workspaceScopeKey: "other-session" });
|
||||
await waitFor("new scope Changes overview", () => document.querySelector(".workspace-panel--changed-overview") !== null);
|
||||
ok(changeDetailPaths.length === callsBeforeScopeSwitch, "workspace scope changes do not request the previous scope's selected change");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -28,30 +28,27 @@ function eq(a: unknown, b: unknown, label: string) {
|
||||
console.log("\nworkspace file split");
|
||||
|
||||
const TREE_MIN_WIDTH = 140;
|
||||
const TREE_RAIL_WIDTH = 44;
|
||||
const PREVIEW_MIN_WIDTH = 140;
|
||||
|
||||
eq(
|
||||
initialWorkspaceSplitTreeWidth({
|
||||
panelWidth: 660,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
savedTreeWidth: null,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
308,
|
||||
"first split divides the file area evenly after reserving the tree rail",
|
||||
330,
|
||||
"first split divides the file area evenly (no tree rail to reserve)",
|
||||
);
|
||||
|
||||
eq(
|
||||
initialWorkspaceSplitTreeWidth({
|
||||
panelWidth: 660,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
savedTreeWidth: 620,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
476,
|
||||
520,
|
||||
"tree width is clamped so the preview keeps its minimum width",
|
||||
);
|
||||
|
||||
@@ -60,12 +57,11 @@ eq(
|
||||
clientX: 400,
|
||||
panelLeft: 100,
|
||||
panelWidth: 660,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
256,
|
||||
"tree resize pointer coordinates start after the tree rail",
|
||||
300,
|
||||
"tree resize pointer coordinates start at the panel's left edge",
|
||||
);
|
||||
|
||||
eq(
|
||||
@@ -73,12 +69,37 @@ eq(
|
||||
clientX: 700,
|
||||
panelLeft: 100,
|
||||
panelWidth: 660,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
476,
|
||||
"tree resize pointer clamps against the preview minimum after reserving the rail",
|
||||
520,
|
||||
"tree resize pointer clamps against the preview minimum",
|
||||
);
|
||||
|
||||
eq(
|
||||
workspaceSplitTreeWidthFromPointer({
|
||||
clientX: 650,
|
||||
panelLeft: 100,
|
||||
panelWidth: 800,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
treeOnRight: true,
|
||||
}),
|
||||
250,
|
||||
"right-side tree width is measured from the pointer to the panel's right edge",
|
||||
);
|
||||
|
||||
eq(
|
||||
workspaceSplitTreeWidthFromPointer({
|
||||
clientX: 100,
|
||||
panelLeft: 100,
|
||||
panelWidth: 800,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
treeOnRight: true,
|
||||
}),
|
||||
660,
|
||||
"right-side tree maximum keeps space for the preview",
|
||||
);
|
||||
|
||||
eq(
|
||||
@@ -86,11 +107,10 @@ eq(
|
||||
mode: "even",
|
||||
currentTreeWidth: 140,
|
||||
panelWidth: 660,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
308,
|
||||
330,
|
||||
"default split recomputes evenly after the parent preview width applies",
|
||||
);
|
||||
|
||||
@@ -99,7 +119,6 @@ eq(
|
||||
mode: "manual",
|
||||
currentTreeWidth: 256,
|
||||
panelWidth: 660,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
@@ -121,19 +140,17 @@ eq(
|
||||
|
||||
eq(
|
||||
workspaceSplitCanFit({
|
||||
panelWidth: 323,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
panelWidth: 279,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
false,
|
||||
"file tree collapses before rail tree and preview would overflow a narrow panel",
|
||||
"file tree collapses before tree and preview would overflow a narrow panel",
|
||||
);
|
||||
|
||||
eq(
|
||||
workspaceSplitCanFit({
|
||||
panelWidth: 324,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
panelWidth: 280,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
@@ -144,7 +161,6 @@ eq(
|
||||
eq(
|
||||
workspaceSplitCanFit({
|
||||
panelWidth: undefined,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
@@ -178,7 +194,6 @@ const effectiveEvenTreeWidth = resolveWorkspaceSplitTreeWidth({
|
||||
mode: "even",
|
||||
currentTreeWidth: TREE_MIN_WIDTH,
|
||||
panelWidth: 660,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
});
|
||||
@@ -188,7 +203,6 @@ eq(
|
||||
mode: "manual",
|
||||
currentTreeWidth: effectiveEvenTreeWidth,
|
||||
panelWidth: 660,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// Run: tsx src/__tests__/workspace-tree-scroll-restore.test.tsx
|
||||
// zk-ge CLAIM.TREE.008: 切换 dock 标签(组件 unmount/remount)后文件树滚动位置恢复
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { WorkspacePanel } from "../components/WorkspacePanel";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: "en-US" });
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
dom.window.ResizeObserver = TestResizeObserver;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get: () => 320 });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", {
|
||||
configurable: true,
|
||||
get: function clientHeight(this: HTMLElement) {
|
||||
return this.classList.contains("workspace-tree") ? 300 : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: function scrollHeight(this: HTMLElement) {
|
||||
return this.classList.contains("workspace-tree") ? 40 * 24 : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get: function offsetHeight(this: HTMLElement) {
|
||||
return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: function getBoundingClientRect(this: HTMLElement) {
|
||||
const width = 320;
|
||||
const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
|
||||
return { x: 0, y: 0, top: 0, left: 0, right: width, bottom: height, width, height, toJSON: () => ({}) } as DOMRect;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollTo", {
|
||||
configurable: true,
|
||||
value: function scrollTo(this: HTMLElement, options?: ScrollToOptions | number) {
|
||||
const requested = typeof options === "number" ? options : options?.top ?? 0;
|
||||
const maximum = Math.max(0, this.scrollHeight - this.clientHeight);
|
||||
this.scrollTop = Math.max(0, Math.min(maximum, requested));
|
||||
},
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
const MEMORY_KEY = "scroll-test\u0000/repo";
|
||||
|
||||
function renderPanel(root: Root) {
|
||||
return act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<WorkspacePanel
|
||||
open
|
||||
tabId="scroll-tab"
|
||||
cwd="/repo"
|
||||
workspaceMemoryKey={MEMORY_KEY}
|
||||
maximized={false}
|
||||
initialViewMode="files"
|
||||
onClose={() => {}}
|
||||
onToggleMaximized={() => {}}
|
||||
onOpenInTerminal={() => {}}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
}
|
||||
|
||||
console.log("\nworkspace tree scroll restore (CLAIM.TREE.008)");
|
||||
|
||||
resetWorkspaceTreeMemoryForTests();
|
||||
const dom = installDom();
|
||||
dom.window.localStorage.clear();
|
||||
const RESTORED_SCROLL_TOP = 200;
|
||||
dom.window.localStorage.setItem("reasonix.workspaceState.v2", JSON.stringify({
|
||||
version: 2,
|
||||
projects: [{
|
||||
key: MEMORY_KEY,
|
||||
state: {
|
||||
openDirs: [""],
|
||||
scrollTop: RESTORED_SCROLL_TOP,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}],
|
||||
}));
|
||||
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListDirForTab: async (_tabId, dir) => {
|
||||
if (dir === "") {
|
||||
return Array.from({ length: 40 }, (_, i) => ({ name: `file-${i}.ts`, isDir: false }));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
SearchFileRefsForTab: async () => [],
|
||||
WorkspaceGitHistory: async () => [],
|
||||
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
|
||||
WorkspaceChangeDetail: async () => ({}),
|
||||
ReadFileForTab: async (_tabId, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
|
||||
ResolveWorkspacePathForTab: async (_tabId, path) => `/repo/${path}`,
|
||||
RevealWorkspacePathForTab: async () => {},
|
||||
OpenWorkspacePathForTab: async () => {},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("missing root");
|
||||
const root = createRoot(rootElement);
|
||||
|
||||
// Phase 1: mount from a persisted deep offset and verify the virtualizer writes
|
||||
// that offset into a genuinely scrollable DOM fixture.
|
||||
await renderPanel(root);
|
||||
await waitFor("workspace rows render", () => document.querySelectorAll(".workspace-tree__row").length > 0);
|
||||
|
||||
const treeEl = document.querySelector<HTMLElement>(".workspace-tree");
|
||||
if (!treeEl) throw new Error("missing workspace-tree element");
|
||||
ok(treeEl.scrollHeight > treeEl.clientHeight, "fixture has a real scrollable extent");
|
||||
await waitFor("persisted tree offset restore", () => treeEl.scrollTop === RESTORED_SCROLL_TOP);
|
||||
ok(treeEl.scrollTop === RESTORED_SCROLL_TOP, "initial mount restores the persisted DOM scroll offset");
|
||||
|
||||
// Scroll position should have been persisted to the upstream per-project
|
||||
// workspace state envelope (reasonix.workspaceState.v2).
|
||||
function persistedScrollTop(): number | null {
|
||||
const raw = dom.window.localStorage.getItem("reasonix.workspaceState.v2");
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { projects?: Array<{ key: string; state: { scrollTop?: number } }> };
|
||||
const project = parsed.projects?.find((entry) => entry.key === MEMORY_KEY);
|
||||
return typeof project?.state?.scrollTop === "number" ? project.state.scrollTop : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
ok(persistedScrollTop() === RESTORED_SCROLL_TOP, "restoration preserves the persisted localStorage offset");
|
||||
|
||||
// Phase 2: unmount (simulates switching from 文件 to 概览 dock tab)
|
||||
await act(async () => {
|
||||
root.render(<></>);
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
// Phase 3: remount (simulates switching back to 文件)
|
||||
await renderPanel(root);
|
||||
await waitFor("workspace rows re-render", () => document.querySelectorAll(".workspace-tree__row").length > 0);
|
||||
|
||||
const treeEl2 = document.querySelector<HTMLElement>(".workspace-tree");
|
||||
if (!treeEl2) throw new Error("missing workspace-tree element after remount");
|
||||
|
||||
// The persisted value must survive the remount — verify the workspace memory
|
||||
// still holds it (this isolates persistence loss from virtualizer restore).
|
||||
ok(persistedScrollTop() === RESTORED_SCROLL_TOP, "persisted scroll offset survives remount in localStorage");
|
||||
|
||||
// Remount must re-render the tree rows (otherwise the restore effect's
|
||||
// treeRows.length guard never passes).
|
||||
const rowsAfterRemount = document.querySelectorAll(".workspace-tree__row").length;
|
||||
ok(rowsAfterRemount > 0, `tree rows re-render after remount (got ${rowsAfterRemount})`);
|
||||
|
||||
await waitFor("remounted tree DOM restore", () => treeEl2.scrollTop === RESTORED_SCROLL_TOP);
|
||||
ok(treeEl2.scrollTop === RESTORED_SCROLL_TOP, "dock-tab remount restores the actual DOM scroll offset");
|
||||
|
||||
// Phase 4: switch to the "changed" view via the tab button, then back to
|
||||
// "files". The pending scroll restore must be reset on viewMode change — the
|
||||
// saved offset belongs to the previous mode's tree and re-applying it to a
|
||||
// shorter list (files -> changed) would scroll past the end and leave a blank
|
||||
// band at the top. We assert the panel still renders rows after the round
|
||||
// trip; the reset itself is what makes that render coherent.
|
||||
const tabs = document.querySelectorAll(".workspace-files__tab");
|
||||
ok(tabs.length >= 2, "files and changed view tabs render in the fixture");
|
||||
if (tabs.length >= 2) {
|
||||
await act(async () => {
|
||||
(tabs[1] as HTMLElement).click();
|
||||
await flushTimers();
|
||||
});
|
||||
await act(async () => {
|
||||
(tabs[0] as HTMLElement).click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("files rows re-render after view-mode round trip", () => document.querySelectorAll(".workspace-tree__row").length > 0);
|
||||
ok(document.querySelectorAll(".workspace-tree__row").length > 0, "tree rows re-render after files<->changed view switch");
|
||||
}
|
||||
|
||||
console.log(`\nworkspace tree scroll restore: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,218 @@
|
||||
// Run: tsx src/__tests__/workspace-turn-verification.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import { registerHooks } from "node:module";
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { WORKSPACE_TURN_VERIFICATION_ID, WorkspacePanel } from "../components/WorkspacePanel";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { WireCompletionSummary } from "../lib/types";
|
||||
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.endsWith(".css")) {
|
||||
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: unknown, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
dom.window.ResizeObserver = TestResizeObserver;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
(dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void }).attachEvent = () => {};
|
||||
(dom.window.HTMLElement.prototype as unknown as { detachEvent: () => void }).detachEvent = () => {};
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get: () => 320 });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", { configurable: true, get: () => 300 });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => ({ x: 0, y: 0, top: 0, left: 0, right: 320, bottom: 300, width: 320, height: 300, toJSON: () => ({}) }) as DOMRect,
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
type WorkspaceProps = Parameters<typeof WorkspacePanel>[0];
|
||||
|
||||
async function createHarness(props: Partial<WorkspaceProps>) {
|
||||
const dom = installDom();
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListDirForTab: async () => [],
|
||||
SearchFileRefsForTab: async () => [],
|
||||
WorkspaceGitHistory: async () => [],
|
||||
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
|
||||
WorkspaceChangeDetail: async () => ({}),
|
||||
ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
let currentProps: WorkspaceProps = {
|
||||
open: true,
|
||||
tabId: "tab-a",
|
||||
cwd: "/repo",
|
||||
maximized: false,
|
||||
onClose: () => {},
|
||||
onToggleMaximized: () => {},
|
||||
...props,
|
||||
};
|
||||
const rerender = async (next: Partial<WorkspaceProps> = {}) => {
|
||||
currentProps = { ...currentProps, ...next };
|
||||
await act(async () => {
|
||||
root.render(<LocaleProvider><WorkspacePanel {...currentProps} /></LocaleProvider>);
|
||||
await flushPromises();
|
||||
});
|
||||
};
|
||||
await rerender();
|
||||
return { dom, root, rerender };
|
||||
}
|
||||
|
||||
async function closeHarness(dom: JSDOM, root: Root) {
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
function summary(mutations: number, checksFailed = 1): WireCompletionSummary {
|
||||
return {
|
||||
preset: "balanced",
|
||||
verdict: "partial",
|
||||
mutations,
|
||||
checks_passed: 2,
|
||||
checks_failed: checksFailed,
|
||||
checks_suppressed: 1,
|
||||
review: "passed",
|
||||
gap_kinds: ["stale_check", "future_internal_value"],
|
||||
constraint_degraded: true,
|
||||
};
|
||||
}
|
||||
|
||||
console.log("\nworkspace turn verification");
|
||||
|
||||
{
|
||||
const current = summary(3);
|
||||
const { dom, root } = await createHarness({ initialViewMode: "changed", completionSummary: current });
|
||||
await waitFor("turn verification summary", () => document.getElementById(WORKSPACE_TURN_VERIFICATION_ID) !== null);
|
||||
const text = document.querySelector(".workspace-completion-summary")?.textContent ?? "";
|
||||
ok(text.includes("Partially complete") && text.includes("3 changes"), "summary renders localized verdict and metrics");
|
||||
ok(text.includes("stale checks") && text.includes("Other"), "summary safely labels known and unknown gaps");
|
||||
ok(text.includes("Turn verification limited"), "summary explains constrained verification");
|
||||
ok(!text.includes("balanced") && !text.includes("partial") && !text.includes("stale_check"), "summary exposes no raw enum values");
|
||||
const title = document.getElementById(`${WORKSPACE_TURN_VERIFICATION_ID}-title`);
|
||||
ok(title?.tagName === "H3", "turn verification title is a heading, not a button");
|
||||
ok(document.querySelector(`#${WORKSPACE_TURN_VERIFICATION_ID} button`) === null, "summary does not expose a clickable control");
|
||||
await closeHarness(dom, root);
|
||||
}
|
||||
|
||||
{
|
||||
const legacyDeliverySummary: WireCompletionSummary = {
|
||||
preset: "balanced",
|
||||
verdict: "partial",
|
||||
mutations: 1,
|
||||
checks_passed: 0,
|
||||
checks_failed: 0,
|
||||
checks_suppressed: 0,
|
||||
review: "passed",
|
||||
gap_kinds: ["unverified_change"],
|
||||
constraint_degraded: false,
|
||||
};
|
||||
const { dom, root, rerender } = await createHarness({
|
||||
initialViewMode: "changed",
|
||||
completionSummary: legacyDeliverySummary,
|
||||
qualityFloor: "delivery",
|
||||
});
|
||||
await waitFor("delivery attention styling", () => document.querySelector(".workspace-completion-summary") !== null);
|
||||
ok(document.querySelector(".workspace-completion-summary")?.classList.contains("workspace-completion-summary--attention"), "legacy delivery summary uses delivery-floor attention styling");
|
||||
await rerender({ qualityFloor: "standard" });
|
||||
ok(!document.querySelector(".workspace-completion-summary")?.classList.contains("workspace-completion-summary--attention"), "legacy standard summary remains neutral");
|
||||
await closeHarness(dom, root);
|
||||
}
|
||||
|
||||
{
|
||||
const current = summary(2);
|
||||
const historical = summary(7);
|
||||
const { dom, root, rerender } = await createHarness({ initialViewMode: "changed", completionSummary: current });
|
||||
await waitFor("current summary", () => document.body.textContent?.includes("2 changes") === true);
|
||||
let scrolled = 0;
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => { scrolled += 1; } });
|
||||
const request = { id: 1, summary: historical, tabId: "tab-a", turnStartAt: 100, currentSummary: current };
|
||||
await rerender({ verificationRevealRequest: request, turnStartAt: 100 } as Partial<WorkspaceProps>);
|
||||
await waitFor("same-view scroll", () => scrolled > 0);
|
||||
ok(document.body.textContent?.includes("7 changes"), "same-view reveal displays the requested historical summary");
|
||||
ok(scrolled === 1, "same-view reveal scrolls exactly once");
|
||||
|
||||
await rerender({ completionSummary: undefined, turnStartAt: 200 } as Partial<WorkspaceProps>);
|
||||
await waitFor("stale summary cleared", () => document.getElementById(WORKSPACE_TURN_VERIFICATION_ID) === null);
|
||||
ok(!document.body.textContent?.includes("7 changes"), "a new turn clears the historical reveal");
|
||||
await closeHarness(dom, root);
|
||||
}
|
||||
|
||||
{
|
||||
const current = summary(4);
|
||||
const historical = summary(9);
|
||||
const { dom, root, rerender } = await createHarness({ initialViewMode: "files", completionSummary: current });
|
||||
let scrolled = 0;
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => { scrolled += 1; } });
|
||||
const request = { id: 2, summary: historical, tabId: "tab-a", turnStartAt: 300, currentSummary: current };
|
||||
await rerender({ initialViewMode: "changed", verificationRevealRequest: request, turnStartAt: 300 } as Partial<WorkspaceProps>);
|
||||
await waitFor("navigation reveal", () => document.body.textContent?.includes("9 changes") === true && scrolled > 0);
|
||||
ok(document.getElementById(WORKSPACE_TURN_VERIFICATION_ID) !== null, "reveal navigates from Files to the change overview");
|
||||
ok(scrolled === 1, "navigation reveal scrolls after the overview mounts");
|
||||
await closeHarness(dom, root);
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -19,7 +19,6 @@ interface AppChromeProps {
|
||||
sidebarToggleTitle: string;
|
||||
workspacePanelMaximized: boolean;
|
||||
workspacePanelRenderable: boolean;
|
||||
workspaceTogglePressed: boolean;
|
||||
workspacePanelLabel: string;
|
||||
onToggleSidebar: () => void;
|
||||
onToggleWorkspacePanel: () => void;
|
||||
@@ -45,7 +44,6 @@ export function AppChrome({
|
||||
sidebarToggleTitle,
|
||||
workspacePanelMaximized,
|
||||
workspacePanelRenderable,
|
||||
workspaceTogglePressed,
|
||||
workspacePanelLabel,
|
||||
onToggleSidebar,
|
||||
onToggleWorkspacePanel,
|
||||
@@ -129,7 +127,6 @@ export function AppChrome({
|
||||
className={[
|
||||
"app-chrome__tools",
|
||||
"app-chrome__tools--fixed",
|
||||
workspaceTogglePressed ? "app-chrome__tools--workspace-pressed" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
aria-label={t("tabBar.commandSearch")}
|
||||
>
|
||||
@@ -156,7 +153,6 @@ export function AppChrome({
|
||||
<div
|
||||
className={[
|
||||
"app-chrome__tools",
|
||||
workspaceTogglePressed ? "app-chrome__tools--workspace-pressed" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
aria-label={t("tabBar.commandSearch")}
|
||||
>
|
||||
@@ -183,7 +179,6 @@ export function AppChrome({
|
||||
"app-chrome__panel-toggle",
|
||||
"app-chrome__panel-toggle--right",
|
||||
workspacePanelRenderable ? "app-chrome__panel-toggle--active" : "",
|
||||
workspaceTogglePressed ? "app-chrome__panel-toggle--pressed" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
type="button"
|
||||
onClick={onToggleWorkspacePanel}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { enqueueInboxGuidance } from "../lib/inboxSubmit";
|
||||
import { formatInboxError, isInboxItemMissing } from "../lib/inboxError";
|
||||
import { inboxScopeKey } from "../lib/composerInboxQueue";
|
||||
import { useComposerInboxRefresh } from "../lib/useComposerInboxRefresh";
|
||||
import { useComposerImeGuard } from "../lib/useComposerImeGuard";
|
||||
import { guidanceIsInFlight, guidanceNeedsRetry, guidanceTextMatches, kickIdleGuidance, markGuidanceQueued } from "../lib/composerGuidance";
|
||||
import { canUsePromptHistory, composerEnterAction, composerEscapeAction, composerMenuKeyAction, insertComposerNewline, isFnKeyEvent, isImeKeyEvent, promptHistoryDirectionFromEvent } from "../lib/composerKeyboard";
|
||||
import { cacheGeneration, loadOlder } from "../lib/composerHistory";
|
||||
@@ -33,6 +34,7 @@ import type { ControllerLiveStore } from "../lib/useController";
|
||||
import { clearLayoutSize, loadOptionalLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
|
||||
import { createRafResizeUpdater } from "../lib/resizeDrag";
|
||||
import { observeComposerMenuViewport } from "../lib/composerMenuViewport";
|
||||
import { resolveComposerContentSizing } from "../lib/composerSizing";
|
||||
import { useToast } from "../lib/toast";
|
||||
import { type CollaborationMode, type CommandInfo, type ComposerInsertRequest, type ContextInfo, type DirEntry, type EffortInfo, type GoalRuntime, type HistoryMessage, type Mode, type PromptHistoryEntry, type QualityFloor, type SessionMeta, type SessionReference, type SlashArgItem, type SlashArgsResult, type ToolApprovalMode, type BalanceInfo } from "../lib/types";
|
||||
import {
|
||||
@@ -382,6 +384,14 @@ function composerMaxHeight(): number {
|
||||
return Math.max(COMPOSER_MIN_HEIGHT, Math.min(COMPOSER_MAX_HEIGHT, Math.floor(window.innerHeight * COMPOSER_MAX_VIEWPORT_RATIO)));
|
||||
}
|
||||
|
||||
// Hero (creation) input cap: the old 96px hard cap clipped longer drafts
|
||||
// before the card autosize took over; give the hero min(30vh, 160px) so a
|
||||
// visible scrollbar takes over instead (#8494/#8742/#9019).
|
||||
function composerHeroInputMaxHeight(): number {
|
||||
if (typeof window === "undefined") return 160;
|
||||
return Math.min(Math.floor(window.innerHeight * 0.3), 160);
|
||||
}
|
||||
|
||||
// The rendered card includes the run strip while a turn runs; subtract it to
|
||||
// recover the user's logical height when measuring from the DOM.
|
||||
function composerLogicalHeight(card: HTMLElement): number {
|
||||
@@ -394,10 +404,6 @@ function clampComposerHeight(height: number): number {
|
||||
return Math.min(Math.max(Math.round(height), COMPOSER_MIN_HEIGHT), composerMaxHeight());
|
||||
}
|
||||
|
||||
function composerAutoInputMaxHeight(extraReservedHeight = 0): number {
|
||||
return Math.max(32, composerMaxHeight() - COMPOSER_AUTO_RESERVED_HEIGHT - extraReservedHeight);
|
||||
}
|
||||
|
||||
function loadComposerHeight(): number | null {
|
||||
return loadOptionalLayoutSize("composerHeight", clampComposerHeight);
|
||||
}
|
||||
@@ -733,6 +739,8 @@ export function Composer({
|
||||
const [active, setActive] = useState(0);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
// A saved manual height is a floor, not a hard cap: longer drafts may grow
|
||||
// above it and return to it when their content shrinks.
|
||||
const [composerHeight, setComposerHeight] = useState<number | null>(loadComposerHeight);
|
||||
const [composerResizing, setComposerResizing] = useState(false);
|
||||
const [textareaAutoHeight, setTextareaAutoHeight] = useState<number | null>(null);
|
||||
@@ -789,8 +797,6 @@ export function Composer({
|
||||
const intentHoverTimerRef = useRef<number | null>(null);
|
||||
const creationChrome = showContextWindowRing;
|
||||
const wasRunningByDraftRef = useRef<Record<string, boolean>>({ [draftKey]: running });
|
||||
const composingRef = useRef(false);
|
||||
const lastCompositionEndAt = useRef(0);
|
||||
const pastChatSearchComposingRef = useRef(false);
|
||||
const pastChatSearchLastCompositionEndAt = useRef(0);
|
||||
const lastSelectionRef = useRef({ start: 0, end: 0 });
|
||||
@@ -822,6 +828,18 @@ export function Composer({
|
||||
const openPastedLabelsRef = useRef(openPastedLabels);
|
||||
const sessionRefsRef = useRef(sessionRefs);
|
||||
const selectedTextRefsRef = useRef(selectedTextRefs);
|
||||
// Plain-textarea IME freeze: while a composition is active the textarea
|
||||
// renders uncontrolled so no re-render can cancel it (#8593/#8409); the
|
||||
// hook owns the composition lifecycle, resync, and force-sync semantics.
|
||||
const { composingRef, lastCompositionEndAt, trackImeInputChange } = useComposerImeGuard({
|
||||
taRef,
|
||||
text,
|
||||
invocationCount: invocations.length,
|
||||
textRef,
|
||||
lastSelectionRef,
|
||||
setText,
|
||||
setPlainSelection,
|
||||
});
|
||||
textRef.current = text;
|
||||
invocationsRef.current = invocations;
|
||||
attachmentsRef.current = attachments;
|
||||
@@ -2927,11 +2945,6 @@ export function Composer({
|
||||
}, []);
|
||||
|
||||
const measureTextareaAutoHeight = useCallback(() => {
|
||||
if (composerHeight !== null) {
|
||||
setTextareaAutoHeight(null);
|
||||
setTextareaAutoOverflow(false);
|
||||
return;
|
||||
}
|
||||
// Creation empty hero starts single-line but must grow so multi-line drafts
|
||||
// stay readable before send (review: fixed 20px + overflow:hidden clipped).
|
||||
if (heroMode) {
|
||||
@@ -2944,7 +2957,7 @@ export function Composer({
|
||||
const previousHeight = node.style.height;
|
||||
node.style.height = "auto";
|
||||
const scrollHeight = node.scrollHeight || 20;
|
||||
const maxHeight = 96;
|
||||
const maxHeight = composerHeroInputMaxHeight();
|
||||
const nextHeight = Math.min(Math.max(scrollHeight, 20), maxHeight);
|
||||
const nextOverflow = scrollHeight > maxHeight + 1;
|
||||
node.style.height = previousHeight;
|
||||
@@ -2958,12 +2971,15 @@ export function Composer({
|
||||
const previousHeight = node?.style.height;
|
||||
if (node) node.style.height = "auto";
|
||||
const scrollHeight = richHeight || node?.scrollHeight || 0;
|
||||
const maxHeight = composerAutoInputMaxHeight();
|
||||
const nextHeight = Math.min(scrollHeight, maxHeight);
|
||||
const nextOverflow = scrollHeight > maxHeight + 1;
|
||||
const sizing = resolveComposerContentSizing({
|
||||
contentHeight: scrollHeight,
|
||||
manualLogicalHeight: composerHeight,
|
||||
maxLogicalHeight: composerMaxHeight(),
|
||||
reservedHeight: COMPOSER_AUTO_RESERVED_HEIGHT,
|
||||
});
|
||||
if (node && previousHeight !== undefined) node.style.height = previousHeight;
|
||||
setTextareaAutoHeight((current) => (current === nextHeight ? current : nextHeight));
|
||||
setTextareaAutoOverflow((current) => (current === nextOverflow ? current : nextOverflow));
|
||||
setTextareaAutoHeight((current) => (current === sizing.inputHeight ? current : sizing.inputHeight));
|
||||
setTextareaAutoOverflow((current) => (current === sizing.overflow ? current : sizing.overflow));
|
||||
}, [composerHeight, heroMode, invocations.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -2971,7 +2987,6 @@ export function Composer({
|
||||
}, [text, measureTextareaAutoHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (composerHeight !== null) return;
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
if (frame) window.cancelAnimationFrame(frame);
|
||||
@@ -3009,7 +3024,7 @@ export function Composer({
|
||||
|
||||
e.preventDefault();
|
||||
const startY = e.clientY;
|
||||
const startHeight = composerHeight ?? composerLogicalHeight(card);
|
||||
const startHeight = Math.max(composerHeight ?? COMPOSER_MIN_HEIGHT, composerLogicalHeight(card));
|
||||
let nextHeight = clampComposerHeight(startHeight);
|
||||
let moved = false;
|
||||
card.style.setProperty("--composer-height", `${nextHeight}px`);
|
||||
@@ -3047,7 +3062,10 @@ export function Composer({
|
||||
|
||||
const onComposerResizeKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const card = composerCardRef.current;
|
||||
const current = composerHeight ?? (card ? composerLogicalHeight(card) : COMPOSER_MIN_HEIGHT);
|
||||
const current = Math.max(
|
||||
composerHeight ?? COMPOSER_MIN_HEIGHT,
|
||||
card ? composerLogicalHeight(card) : COMPOSER_MIN_HEIGHT,
|
||||
);
|
||||
const step = e.shiftKey ? 32 : 16;
|
||||
let next: number | null = null;
|
||||
if (e.key === "ArrowUp" || e.key === "PageUp") next = current + step;
|
||||
@@ -3597,21 +3615,33 @@ export function Composer({
|
||||
|
||||
// When the run strip is visible inside a user-resized card, the card grows
|
||||
// by the strip's reserved height so the meta row stays fully visible.
|
||||
// --composer-height carries only the user's logical height; the reservation
|
||||
// is a separate variable consumed by the CSS calc, so the live resize drag
|
||||
// (which writes raw logical heights) stays consistent with this render path.
|
||||
// --composer-height stays in logical card-height space. It may be the saved
|
||||
// manual floor or a larger content-derived height; the run-strip reservation
|
||||
// remains separate so the live resize writer uses the same coordinate space.
|
||||
const showRunStrip = Boolean(retry || running);
|
||||
const composerCardStyle = composerHeight === null
|
||||
const effectiveComposerHeight = composerHeight === null
|
||||
? null
|
||||
: resolveComposerContentSizing({
|
||||
contentHeight: textareaAutoHeight ?? 0,
|
||||
manualLogicalHeight: composerHeight,
|
||||
maxLogicalHeight: composerMaxHeight(),
|
||||
reservedHeight: COMPOSER_AUTO_RESERVED_HEIGHT,
|
||||
}).logicalHeight;
|
||||
const composerCardStyle = effectiveComposerHeight === null
|
||||
? undefined
|
||||
: ({
|
||||
"--composer-height": `${composerHeight}px`,
|
||||
"--composer-height": `${effectiveComposerHeight}px`,
|
||||
"--composer-run-strip-reserved": `${showRunStrip ? COMPOSER_RUN_STRIP_RESERVED : 0}px`,
|
||||
} as CSSProperties);
|
||||
const textareaStyle = composerHeight === null && textareaAutoHeight !== null
|
||||
const textareaStyle = !composerResizing && textareaAutoHeight !== null
|
||||
? ({ height: `${textareaAutoHeight}px`, overflowY: textareaAutoOverflow ? "auto" : "hidden" } as CSSProperties)
|
||||
: undefined;
|
||||
const composerAutoExpanded = composerHeight === null && textareaAutoHeight !== null && textareaAutoHeight > 40;
|
||||
const composerResizeValue = composerHeight ?? clampComposerHeight((textareaAutoHeight ?? 0) + COMPOSER_AUTO_RESERVED_HEIGHT);
|
||||
// Autosize mode flips overflow-y to auto once content exceeds the max
|
||||
// height; the card modifier restores a thin scrollbar for exactly that
|
||||
// state so long drafts expose their scrollability (#8494/#8742/#9019).
|
||||
const composerAutoOverflow = composerHeight === null && textareaAutoOverflow;
|
||||
const composerResizeValue = effectiveComposerHeight ?? clampComposerHeight((textareaAutoHeight ?? 0) + COMPOSER_AUTO_RESERVED_HEIGHT);
|
||||
void onSetMode;
|
||||
const chooseApprovalMode = (nextMode: ToolApprovalMode) => {
|
||||
onSetToolApprovalMode(nextMode);
|
||||
@@ -4399,7 +4429,7 @@ export function Composer({
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`composer-card${composerHeight !== null || composerResizing ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerResizing ? " composer-card--resizing" : ""}${running ? (waitingPrompt ? " composer-card--waiting" : " composer-card--running") : ""}`}
|
||||
className={`composer-card${composerHeight !== null || composerResizing ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerAutoOverflow ? " composer-card--auto-overflow" : ""}${composerResizing ? " composer-card--resizing" : ""}${running ? (waitingPrompt ? " composer-card--waiting" : " composer-card--running") : ""}`}
|
||||
ref={composerCardRef}
|
||||
style={composerCardStyle}
|
||||
>
|
||||
@@ -4511,7 +4541,7 @@ export function Composer({
|
||||
ref={taRef}
|
||||
className="composer__input"
|
||||
aria-label={t("composer.placeholder")} spellCheck={false} autoCorrect="off" autoCapitalize="off"
|
||||
value={text}
|
||||
value={composingRef.current ? undefined : text}
|
||||
onInputCapture={(e) => {
|
||||
pendingNativeInputTypeRef.current = (e.nativeEvent as InputEvent).inputType;
|
||||
}}
|
||||
@@ -4520,6 +4550,7 @@ export function Composer({
|
||||
const inputType = (e.nativeEvent as InputEvent).inputType
|
||||
|| pendingNativeInputTypeRef.current;
|
||||
pendingNativeInputTypeRef.current = undefined;
|
||||
trackImeInputChange(e.nativeEvent as InputEvent, inputType, e.target.value);
|
||||
resetPromptHistoryNavigation();
|
||||
textRef.current = e.target.value;
|
||||
setText(e.target.value);
|
||||
@@ -4539,13 +4570,6 @@ export function Composer({
|
||||
onContextMenu={openInputMenu}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={onKeyDown}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true;
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
composingRef.current = false;
|
||||
lastCompositionEndAt.current = Date.now();
|
||||
}}
|
||||
style={textareaStyle}
|
||||
placeholder={composerPlaceholder}
|
||||
rows={1}
|
||||
|
||||
@@ -145,9 +145,13 @@ function isStreamingListItemLine(line: string): boolean {
|
||||
|
||||
// Live parse prefix: last completed block. A later list marker commits prior
|
||||
// items only — the new item stays in the tail so indented continuations can join.
|
||||
// An open code fence commits only up to the fence line: the tail renders the
|
||||
// growing code with code styling (splitStreamingTailFence), so streaming a
|
||||
// large block no longer re-parses the whole document on every commit.
|
||||
export function streamingCommitTarget(text: string): string {
|
||||
let lineStart = 0;
|
||||
let fence: { marker: string; length: number } | null = null;
|
||||
let fenceStart = 0;
|
||||
let displayMath = false;
|
||||
let boundary = 0;
|
||||
while (lineStart < text.length) {
|
||||
@@ -167,8 +171,10 @@ export function streamingCommitTarget(text: string): string {
|
||||
}
|
||||
} else {
|
||||
const fenceMatch = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
||||
if (fenceMatch) fence = { marker: fenceMatch[1][0], length: fenceMatch[1].length };
|
||||
else if (line.trim() === "$$") displayMath = true;
|
||||
if (fenceMatch) {
|
||||
fence = { marker: fenceMatch[1][0], length: fenceMatch[1].length };
|
||||
fenceStart = lineStart;
|
||||
} else if (line.trim() === "$$") displayMath = true;
|
||||
else if (terminated && line.trim() === "") boundary = lineEnd;
|
||||
// A heading interrupts a paragraph, so a partial heading line already
|
||||
// completes everything before it; a terminated one is itself complete.
|
||||
@@ -177,7 +183,41 @@ export function streamingCommitTarget(text: string): string {
|
||||
}
|
||||
lineStart = lineEnd;
|
||||
}
|
||||
return fence || displayMath ? text : text.slice(0, boundary);
|
||||
return fence ? text.slice(0, fenceStart) : displayMath ? text : text.slice(0, boundary);
|
||||
}
|
||||
|
||||
type StreamingTailFence = { head: string; lang: string; code: string };
|
||||
|
||||
// Split a streaming tail around an unclosed code fence so the fence body can
|
||||
// render with code styling before the closing fence arrives. Bail out cheaply
|
||||
// when no fence marker exists; otherwise mirror the fence state machine from
|
||||
// streamingCommitTarget in one forward pass over the tail.
|
||||
export function splitStreamingTailFence(text: string): StreamingTailFence | null {
|
||||
if (!text.includes("```") && !text.includes("~~~")) return null;
|
||||
let lineStart = 0;
|
||||
let fence: { marker: string; length: number } | null = null;
|
||||
let fenceStart = 0;
|
||||
let fenceBodyStart = 0;
|
||||
let lang = "";
|
||||
while (lineStart < text.length) {
|
||||
const newline = text.indexOf("\n", lineStart);
|
||||
const lineEnd = newline === -1 ? text.length : newline + 1;
|
||||
const line = text.slice(lineStart, newline === -1 ? text.length : newline).replace(/\r$/, "");
|
||||
if (fence) {
|
||||
if (new RegExp(`^ {0,3}${fence.marker}{${fence.length},}[ \\t]*$`).test(line)) fence = null;
|
||||
} else {
|
||||
const fenceMatch = /^ {0,3}(`{3,}|~{3,})([^\n]*)$/.exec(line);
|
||||
if (fenceMatch) {
|
||||
fence = { marker: fenceMatch[1][0], length: fenceMatch[1].length };
|
||||
fenceStart = lineStart;
|
||||
fenceBodyStart = lineEnd;
|
||||
lang = fenceMatch[2].trim();
|
||||
}
|
||||
}
|
||||
lineStart = lineEnd;
|
||||
}
|
||||
if (!fence) return null;
|
||||
return { head: text.slice(0, fenceStart), lang, code: text.slice(fenceBodyStart) };
|
||||
}
|
||||
|
||||
export function useRenderedMarkdownText(text: string, streaming: boolean, holdIdleFinalization = false): string {
|
||||
@@ -302,9 +342,35 @@ const StreamingMarkdownTail = memo(function StreamingMarkdownTail({ text }: { te
|
||||
const element = elementRef.current;
|
||||
if (!element) return;
|
||||
const previousText = previousTextRef.current;
|
||||
const textNode = element.firstChild;
|
||||
if (text.startsWith(previousText) && textNode?.nodeType === Node.TEXT_NODE) {
|
||||
(textNode as Text).appendData(text.slice(previousText.length));
|
||||
const previous = splitStreamingTailFence(previousText);
|
||||
const next = splitStreamingTailFence(text);
|
||||
// Append-only fast paths keep per-frame updates to one text-node append.
|
||||
if (next && previous && next.head === previous.head && next.lang === previous.lang && next.code.startsWith(previous.code)) {
|
||||
const textNode = element.querySelector("code")?.firstChild;
|
||||
if (textNode?.nodeType === Node.TEXT_NODE) {
|
||||
(textNode as Text).appendData(next.code.slice(previous.code.length));
|
||||
previousTextRef.current = text;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!next && !previous && text.startsWith(previousText)) {
|
||||
const textNode = element.firstChild;
|
||||
if (element.childNodes.length === 1 && textNode?.nodeType === Node.TEXT_NODE) {
|
||||
(textNode as Text).appendData(text.slice(previousText.length));
|
||||
previousTextRef.current = text;
|
||||
return;
|
||||
}
|
||||
}
|
||||
element.textContent = "";
|
||||
if (next) {
|
||||
if (next.head) element.appendChild(document.createTextNode(next.head));
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = "code md--stream-tail-code";
|
||||
if (next.lang) pre.setAttribute("data-lang", next.lang);
|
||||
const code = document.createElement("code");
|
||||
code.textContent = next.code;
|
||||
pre.appendChild(code);
|
||||
element.appendChild(pre);
|
||||
} else {
|
||||
element.textContent = text;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getMarkdownWorkerClient } from "../lib/markdownWorkerClient";
|
||||
import { getTranscriptStore } from "../lib/transcriptStore";
|
||||
import { createComponents } from "./markdownComponents";
|
||||
import { VirtualMarkdownSourceTable } from "./MarkdownTable";
|
||||
import { useTranscriptScrollOffsetWrite } from "./TranscriptLayoutIntentContext";
|
||||
|
||||
// A history surface opens at the newest transcript content. Keep the same
|
||||
// ownership inside a giant Markdown row: mount a small tail, then move a
|
||||
@@ -180,6 +181,7 @@ export const MarkdownHistory = memo(function MarkdownHistory({
|
||||
const [blockWindow, moveBlockWindow] = useProgressiveBlockWindow(totalBlocks, blocks);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const pendingAnchorRef = useRef<PendingScrollAnchor | null>(null);
|
||||
const writeTranscriptOffset = useTranscriptScrollOffsetWrite();
|
||||
const moveFromBoundary = useCallback((direction: "older" | "newer", sentinel: HTMLSpanElement) => {
|
||||
if (!blocks) return;
|
||||
if (pendingAnchorRef.current?.identity === blocks) return;
|
||||
@@ -212,8 +214,11 @@ export const MarkdownHistory = memo(function MarkdownHistory({
|
||||
const anchor = rootRef.current?.querySelector<HTMLElement>(`[data-markdown-scroll-anchor="${pending.index}"]`);
|
||||
pendingAnchorRef.current = null;
|
||||
if (!anchor || !pending.scroller?.isConnected) return;
|
||||
pending.scroller.scrollTop += anchor.getBoundingClientRect().top - pending.top;
|
||||
}, [blockWindow.end, blockWindow.start, blocks]);
|
||||
const delta = anchor.getBoundingClientRect().top - pending.top;
|
||||
if (delta !== 0 && writeTranscriptOffset) {
|
||||
writeTranscriptOffset("block-window-prepend", pending.scroller.scrollTop + delta);
|
||||
}
|
||||
}, [blockWindow.end, blockWindow.start, blocks, writeTranscriptOffset]);
|
||||
|
||||
// JSX per block depends only on the block and the components map; build it
|
||||
// lazily so viewport-window growth never re-converts settled blocks.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, type RefObject, useCallback, useEffect, useId, useLayoutEffect, u
|
||||
import { createPortal } from "react-dom";
|
||||
import { AlertCircle, Code2, Maximize2, Minimize2, Play, RotateCcw, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { createMermaidPanZoom, type MermaidPanZoomInstance, type MermaidPanZoomOptions } from "./mermaidPanZoom";
|
||||
import { openExternal } from "../lib/bridge";
|
||||
import { markdownImageSource } from "../lib/markdownImage";
|
||||
|
||||
@@ -25,17 +26,9 @@ type MermaidRenderAdapter = (
|
||||
signal: AbortSignal,
|
||||
) => Promise<string>;
|
||||
|
||||
type PanZoomInstance = {
|
||||
destroy(): void;
|
||||
resize(): unknown;
|
||||
fit(): unknown;
|
||||
center(): unknown;
|
||||
zoomIn(): unknown;
|
||||
zoomOut(): unknown;
|
||||
reset(): unknown;
|
||||
};
|
||||
type PanZoomInstance = MermaidPanZoomInstance;
|
||||
|
||||
type PanZoomFactory = (svg: SVGSVGElement, options?: Record<string, unknown>) => PanZoomInstance;
|
||||
type PanZoomFactory = (svg: SVGSVGElement, options?: MermaidPanZoomOptions) => PanZoomInstance;
|
||||
|
||||
const MAX_TEXT_SIZE = 100000;
|
||||
const MIN_ZOOM = 0.3;
|
||||
@@ -46,8 +39,6 @@ const XLINK_NS = "http://www.w3.org/1999/xlink";
|
||||
let mermaidApi: MermaidApi | null = null;
|
||||
let initPromise: Promise<MermaidApi> | null = null;
|
||||
let renderQueue: Promise<void> = Promise.resolve();
|
||||
let panZoomFactory: PanZoomFactory | null = null;
|
||||
let panZoomPromise: Promise<PanZoomFactory | null> | null = null;
|
||||
let renderAdapterForTest: MermaidRenderAdapter | null = null;
|
||||
let panZoomFactoryForTest: PanZoomFactory | null | undefined;
|
||||
|
||||
@@ -210,15 +201,7 @@ async function renderMermaid(
|
||||
|
||||
async function ensurePanZoomFactory(): Promise<PanZoomFactory | null> {
|
||||
if (panZoomFactoryForTest !== undefined) return panZoomFactoryForTest;
|
||||
if (panZoomFactory) return panZoomFactory;
|
||||
if (!panZoomPromise) {
|
||||
panZoomPromise = import("svg-pan-zoom").then((mod) => {
|
||||
const factory = (("default" in mod ? mod.default : mod) as unknown) as PanZoomFactory;
|
||||
panZoomFactory = factory;
|
||||
return factory;
|
||||
}).catch(() => null);
|
||||
}
|
||||
return panZoomPromise;
|
||||
return createMermaidPanZoom;
|
||||
}
|
||||
|
||||
export function isSafeMermaidHref(href: string | null | undefined): boolean {
|
||||
@@ -317,7 +300,7 @@ function destroyPanZoom(instance: PanZoomInstance | null): void {
|
||||
try {
|
||||
instance.destroy();
|
||||
} catch {
|
||||
/* svg-pan-zoom cleanup is best-effort across browser and test DOMs. */
|
||||
/* pan/zoom listener cleanup is best-effort across browser and test DOMs. */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,9 +315,8 @@ export function safelyRunPanZoom(instance: PanZoomInstance | null, action: () =>
|
||||
action();
|
||||
return true;
|
||||
} catch {
|
||||
// svg-pan-zoom asks SVGMatrix.inverse() to invert the current transform.
|
||||
// Hidden/zero-sized layouts can produce a singular matrix in WebKit and
|
||||
// Chromium; keep that library detail out of the global crash surface.
|
||||
// Pan/zoom math reads live layout (getBoundingClientRect); a detached or
|
||||
// zero-sized surface must never reach the global crash surface (#8068).
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -429,14 +411,6 @@ const MermaidDiagram = memo(function MermaidDiagram({ definition }: MermaidDiagr
|
||||
destroyPanZoom(panZoomRef.current);
|
||||
try {
|
||||
instance = factory(svg, {
|
||||
zoomEnabled: true,
|
||||
panEnabled: true,
|
||||
controlIconsEnabled: false,
|
||||
dblClickZoomEnabled: true,
|
||||
// Fitting inside the constructor can run before the portal/layout has
|
||||
// dimensions, which is the matrix-inverse crash seen in diagnostics.
|
||||
fit: false,
|
||||
center: false,
|
||||
minZoom: MIN_ZOOM,
|
||||
maxZoom: MAX_ZOOM,
|
||||
zoomScaleSensitivity: 0.3,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { asArray } from "../lib/array";
|
||||
import { useToast } from "../lib/toast";
|
||||
import { app } from "../lib/bridge";
|
||||
import { onProjectTreeChangedV2 } from "../lib/sessionCatalogBridge";
|
||||
import { isRuntimeSessionNode, isTopicNode, loadWorkbenchOrganizeMode, loadWorkbenchSortMode, mergeIncompleteProjectTopicPage, mergeProjectTopicPage, projectTreeDedupedExactTime, projectTreeEventAffectsFolder, projectTreeFolderDisclosure, projectTreeReadActivityKey, projectTreeRevisionIsFresh, projectTreeShellChildren, projectTreeShellSignature, projectTreeShouldApplyShellSnapshot, projectTreeShouldRenderTopicActions, projectTreeShouldSuppressOpenForRename, projectTreeTopicArchiveBlocked, projectTreeTopicHasUnreadActivity, projectTreeTopicHoverCardModel, projectTreeTopicMenuOffersPin, projectTreeTopicMetaLine, projectTreeTopicOpenRequest, projectTreeTopicPageIsFresh, projectTreeTopicPageSignature, projectTreeWithoutTopic, topicActivityAt, topicActivityDateLabel, topicActivityLabel, topicIsActive, topicStatus, topicStatusLabel, topicUnknownTimeLabel, WORKBENCH_ORGANIZE_KEY, WORKBENCH_SORT_KEY, type ProjectTreePendingTopicOpen, type ProjectTreeReadActivity, type ProjectTreeTopicHoverCard, type ProjectTreeVariant, type WorkbenchOrganizeMode, type WorkbenchSortMode } from "../lib/projectTreeTopic";
|
||||
import { isRuntimeSessionNode, isTopicNode, loadWorkbenchOrganizeMode, loadWorkbenchSortMode, mergeIncompleteProjectTopicPage, mergeProjectTopicPage, projectTreeDedupedExactTime, projectTreeEventAffectsFolder, projectTreeFolderDisclosure, projectTreeReadActivityKey, projectTreeRevisionIsFresh, projectTreeShellChildren, projectTreeShellSignature, projectTreeShouldApplyShellSnapshot, projectTreeShouldRenderTopicActions, projectTreeShouldSuppressOpenForRename, projectTreeTopicArchiveBlocked, projectTreeTopicHasUnreadActivity, projectTreeTopicHoverCardModel, projectTreeTopicMenuOffersPin, projectTreeTopicMetaLine, projectTreeTopicOpenRequest, projectTreeTopicPageIsFresh, projectTreeTopicPageSignature, projectTreeTopicRecoveryCopyCount, projectTreeWithoutTopic, projectTreeWithTopicTitle, topicActivityAt, topicActivityDateLabel, topicActivityLabel, topicIsActive, topicStatus, topicStatusLabel, topicUnknownTimeLabel, WORKBENCH_ORGANIZE_KEY, WORKBENCH_SORT_KEY, type ProjectTreePendingTopicOpen, type ProjectTreeReadActivity, type ProjectTreeTopicHoverCard, type ProjectTreeVariant, type WorkbenchOrganizeMode, type WorkbenchSortMode } from "../lib/projectTreeTopic";
|
||||
export * from "../lib/projectTreeTopic";
|
||||
import { arrangeClassicProjectTree, arrangeWorkbenchTree, classicTopicWindow, CLASSIC_TOPIC_PREVIEW_LIMIT, splitPinnedProjectTree, type PinnedTreeSections } from "../lib/projectTreePresentation";
|
||||
export * from "../lib/projectTreePresentation";
|
||||
@@ -257,6 +257,7 @@ export function ProjectTree({
|
||||
const [catalogStatus, setCatalogStatus] = useState<SessionCatalogStatus>({
|
||||
state: "opening", revision: 0, indexed: 0, total: 0, repairPending: 0,
|
||||
});
|
||||
const rebuildingCatalogRef = useRef(false);
|
||||
const [topicPageState, setTopicPageState] = useState<Record<string, { nextCursor?: string; loading: boolean }>>({});
|
||||
const topicPageStateRef = useRef(topicPageState);
|
||||
const updateTopicPageState = useCallback((key: string, next: { nextCursor?: string; loading: boolean }) => {
|
||||
@@ -459,12 +460,27 @@ export function ProjectTree({
|
||||
}
|
||||
}, [applyRuntimeProjection]);
|
||||
refreshRef.current = refresh;
|
||||
|
||||
const { addingProject, handleAddProject, openBlankProjectFlow, blankProjectFlow } = useProjectCreation({
|
||||
onAddProject,
|
||||
onRefresh: refresh,
|
||||
showToast,
|
||||
});
|
||||
|
||||
const rebuildSessionCatalog = useCallback(async () => {
|
||||
if (rebuildingCatalogRef.current || catalogStatus.canRebuild === false) return;
|
||||
rebuildingCatalogRef.current = true;
|
||||
try {
|
||||
await app.RebuildSessionCatalog();
|
||||
showToast(t("projectTree.rebuildCatalog"), "info");
|
||||
void refresh();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : String(error), "error", { durationMs: 6000 });
|
||||
} finally {
|
||||
rebuildingCatalogRef.current = false;
|
||||
}
|
||||
}, [catalogStatus.canRebuild, refresh, showToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
treeRef.current = tree;
|
||||
}, [tree]);
|
||||
@@ -485,7 +501,12 @@ export function ProjectTree({
|
||||
}, [refresh, refreshSignal]);
|
||||
|
||||
useEffect(() => onProjectTreeChangedV2((event) => {
|
||||
if (!projectTreeRevisionIsFresh(latestRevisionRef.current, event.revision)) return;
|
||||
// A stale or missed revision means the tree may have drifted from the
|
||||
// catalog; refetch the full snapshot instead of dropping the event.
|
||||
if (!projectTreeRevisionIsFresh(latestRevisionRef.current, event.revision)) {
|
||||
void refresh();
|
||||
return;
|
||||
}
|
||||
latestRevisionRef.current = Math.max(latestRevisionRef.current, event.revision);
|
||||
if (event.reason === "metadata") setOrganizationRevision((current) => Math.max(current, event.revision));
|
||||
void app.GetSessionCatalogStatus().then(setCatalogStatus).catch(() => {});
|
||||
@@ -769,6 +790,8 @@ export function ProjectTree({
|
||||
try {
|
||||
if (onRenameTopic) await onRenameTopic(topicId, title);
|
||||
else await app.RenameTopic(topicId, title);
|
||||
// Paint the new label immediately; the catalog event round-trip can lag.
|
||||
setTree((current) => applyRuntimeProjection(projectTreeWithTopicTitle(current, topicId, title)));
|
||||
await refresh();
|
||||
if (!onRenameTopic) await onTopicsChanged?.();
|
||||
} catch (err) {
|
||||
@@ -1050,7 +1073,9 @@ export function ProjectTree({
|
||||
const accentStyle = projectAccentStyle(node.projectColor, scope === "global" ? "var(--project-tree-global-accent)" : undefined);
|
||||
const active = topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
|
||||
const label = (node.label || node.topicId || "Untitled").replace(/^●\s*/, "");
|
||||
// Ordinary list never advertises recovery copies; History owns that view.
|
||||
// Recovery copies stay folded behind the canonical row; the row only
|
||||
// advertises how many converged, and History owns the full list (#8525).
|
||||
const recoveryCopyCount = projectTreeTopicRecoveryCopyCount(node);
|
||||
const activityAt = node.lastActivityAt || node.createdAt || 0;
|
||||
// Every variant is a single-line row with the activity time on the right;
|
||||
// classic moved there too so turns and the exact date live in the hover
|
||||
@@ -1076,7 +1101,12 @@ export function ProjectTree({
|
||||
const imSourceLabel = imSource?.label || "";
|
||||
const imSourceTitle = imSourceLabel ? t("msg.fromIm", { source: imSourceLabel }) : "";
|
||||
const imSourcePlatform = (imSource?.platform || "im").replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "im";
|
||||
const title = [node.preview || "", label, imSourceTitle, statusLabel, metaFull, projectTreeDedupedExactTime(metaFull, exactTimeLabel)].filter(Boolean).join(" · ");
|
||||
const recoveryLabel = node.recoveryState === "recovery_only"
|
||||
? t("projectTree.recoveryOnly")
|
||||
: node.recovered
|
||||
? t("projectTree.recovered")
|
||||
: "";
|
||||
const title = [node.preview || "", label, recoveryLabel, imSourceTitle, statusLabel, metaFull, projectTreeDedupedExactTime(metaFull, exactTimeLabel)].filter(Boolean).join(" · ");
|
||||
const topicMenuOpen = !isSessionNode && menuTopic === topicId;
|
||||
const pinned = Boolean(node.pinned);
|
||||
const pinLabel = t(pinned ? "projectTree.unpinTopic" : "projectTree.pinTopic");
|
||||
@@ -1211,6 +1241,7 @@ export function ProjectTree({
|
||||
<span className="project-tree__topic-copy">
|
||||
<span className="project-tree__topic-heading">
|
||||
<span className="project-tree__topic-label">{label}</span>
|
||||
{recoveryLabel && <span className="project-tree__topic-recovery" title={recoveryLabel}>{recoveryLabel}</span>}
|
||||
{imSource && (
|
||||
<span
|
||||
className={`project-tree__topic-im project-tree__topic-im--${imSourcePlatform}`}
|
||||
@@ -1224,6 +1255,14 @@ export function ProjectTree({
|
||||
{!compactTopics && statusLabel && (!classicTopics || status === "paused" || status === "awaiting_delivery" || status === "error") && (
|
||||
<span className={`project-tree__topic-status project-tree__topic-status--${status}`}>{statusLabel}</span>
|
||||
)}
|
||||
{recoveryCopyCount > 0 && (
|
||||
<span
|
||||
className="project-tree__topic-recovery-copies"
|
||||
title={t("projectTree.recoveryCopies", { count: recoveryCopyCount })}
|
||||
>
|
||||
{t("projectTree.recoveryCopies", { count: recoveryCopyCount })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
{sideTimeVisible && (
|
||||
@@ -1551,6 +1590,23 @@ export function ProjectTree({
|
||||
const backendPage = topicPageState[key];
|
||||
const renderFolderChildren = () => {
|
||||
if (!hasChildren) {
|
||||
// While the first topic page is still loading (cold start, catalog
|
||||
// reconcile in flight), show a skeleton instead of a blank folder or
|
||||
// a premature "no topics" placeholder.
|
||||
if (backendPage?.loading) {
|
||||
return (
|
||||
<div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
|
||||
<div className="project-tree__children-inner">
|
||||
<div className="project-tree__skeleton" style={{ paddingLeft: 14 + (depth + 1) * 16 }} aria-hidden="true">
|
||||
<span className="project-tree__skeleton-bar" />
|
||||
<span className="project-tree__skeleton-bar project-tree__skeleton-bar--short" />
|
||||
<span className="project-tree__skeleton-bar" />
|
||||
<span className="project-tree__skeleton-bar project-tree__skeleton-bar--short" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!classicTopics) return null;
|
||||
return (
|
||||
<div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
|
||||
@@ -2092,9 +2148,16 @@ export function ProjectTree({
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{(catalogStatus.state === "opening" || catalogStatus.state === "rebuilding" || catalogStatus.repairPending > 0) && (
|
||||
{(catalogStatus.state === "opening" || catalogStatus.state === "rebuilding" || catalogStatus.repairPending > 0 || (catalogStatus.unindexedTargetCount ?? 0) > 0 || Boolean(catalogStatus.lastError)) && (
|
||||
<div className="project-tree__catalog-progress" role="status">
|
||||
{t("projectTree.indexingProgress", { done: catalogStatus.indexed, total: catalogStatus.total || "?" })}
|
||||
<span>{catalogStatus.lastError
|
||||
? t("projectTree.rebuildCatalog")
|
||||
: t("projectTree.indexingProgress", { done: catalogStatus.indexed, total: catalogStatus.total || "?" })}</span>
|
||||
{catalogStatus.canRebuild !== false && catalogStatus.state !== "rebuilding" && (
|
||||
<button type="button" className="project-tree__catalog-rebuild" onClick={() => void rebuildSessionCatalog()}>
|
||||
{t("projectTree.rebuildCatalog")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{compactTopics ? (
|
||||
@@ -2107,7 +2170,10 @@ export function ProjectTree({
|
||||
<>
|
||||
{pinnedTreeSections.pinned.length > 0 && (
|
||||
<div className="project-tree__section project-tree__section--pinned">
|
||||
<div className="project-tree__section-title">{t("projectTree.pinnedTitle")}</div>
|
||||
<div className="project-tree__section-title project-tree__section-title--pinned">
|
||||
<Pin size={14} className="project-tree__section-title-icon" aria-hidden="true" />
|
||||
<span>{t("projectTree.pinnedTitle")}</span>
|
||||
</div>
|
||||
{pinnedTreeSections.pinned.map((node) => renderNode(node, 0, "pinned"))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,9 @@ export function PromptShelf({
|
||||
role = "dialog",
|
||||
decision = false,
|
||||
actionsRole = "listbox",
|
||||
cardCollapsible = false,
|
||||
collapsed = false,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
className?: string;
|
||||
cardClassName?: string;
|
||||
@@ -45,6 +48,11 @@ export function PromptShelf({
|
||||
// Select-then-confirm surfaces use listbox. Immediate actions are a group of
|
||||
// buttons so assistive technology does not announce them as pending choices.
|
||||
actionsRole?: "listbox" | "group";
|
||||
// Whole-card click-to-collapse (used by the todo shelf): clicking anywhere
|
||||
// on the card toggles collapsed; interactive children stop propagation.
|
||||
cardCollapsible?: boolean;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -59,22 +67,40 @@ export function PromptShelf({
|
||||
>
|
||||
<div
|
||||
ref={barRef}
|
||||
className={["prompt-shelf__card", cardClassName ?? ""].filter(Boolean).join(" ")}
|
||||
className={[
|
||||
"prompt-shelf__card",
|
||||
cardCollapsible ? "prompt-shelf__card--collapsible" : "",
|
||||
cardCollapsible && collapsed ? "prompt-shelf__card--collapsed" : "",
|
||||
cardClassName ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
role={role}
|
||||
aria-modal={role === "dialog" ? "false" : undefined}
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
tabIndex={cardCollapsible ? 0 : -1}
|
||||
onClick={cardCollapsible ? onToggleCollapse : undefined}
|
||||
onKeyDown={cardCollapsible && onToggleCollapse ? (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onToggleCollapse();
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
<div className="prompt-shelf__content">
|
||||
<div className="prompt-shelf__header">
|
||||
<div className="prompt-shelf__copy">
|
||||
<div id={titleId} className="prompt-shelf__title">
|
||||
<span className="prompt-shelf__heading">{title}</span>
|
||||
{meta && <span className="prompt-shelf__meta">{meta}</span>}
|
||||
{badges && <span className="prompt-shelf__badges">{badges}</span>}
|
||||
</div>
|
||||
{meta && <div className="prompt-shelf__meta">{meta}</div>}
|
||||
</div>
|
||||
{headerActions && <div className="prompt-shelf__header-actions">{headerActions}</div>}
|
||||
{headerActions && (
|
||||
<div className="prompt-shelf__header-actions" onClick={(event) => event.stopPropagation()}>
|
||||
{headerActions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{crumbs}
|
||||
{children && <div className="prompt-shelf__body">{children}</div>}
|
||||
|
||||
@@ -1,86 +1,146 @@
|
||||
// QuestionJumpBar: the question navigator rail along the transcript edge.
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import { useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { QuestionAnchor } from "../lib/transcriptGrouping";
|
||||
|
||||
export function QuestionJumpBar({ questions, onJump }: { questions: QuestionAnchor[]; onJump: (question: QuestionAnchor) => void }) {
|
||||
export const QUESTION_JUMP_MAX_MARKERS = 120;
|
||||
|
||||
export function sampledQuestionTurns(totalQuestions: number, activeTurn: number | null, limit = QUESTION_JUMP_MAX_MARKERS): number[] {
|
||||
const total = Math.max(0, Math.floor(totalQuestions));
|
||||
const maxMarkers = Math.max(2, Math.floor(limit));
|
||||
if (total <= maxMarkers) return Array.from({ length: total }, (_, turn) => turn);
|
||||
|
||||
const turns = Array.from({ length: maxMarkers }, (_, index) => (
|
||||
Math.round(index * (total - 1) / (maxMarkers - 1))
|
||||
));
|
||||
if (maxMarkers <= 2 || activeTurn == null || activeTurn <= 0 || activeTurn >= total - 1 || turns.includes(activeTurn)) return turns;
|
||||
|
||||
let replaceIndex = 1;
|
||||
let closestDistance = Number.POSITIVE_INFINITY;
|
||||
for (let index = 1; index < turns.length - 1; index += 1) {
|
||||
const distance = Math.abs(turns[index] - activeTurn);
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
replaceIndex = index;
|
||||
}
|
||||
}
|
||||
turns[replaceIndex] = activeTurn;
|
||||
turns.sort((left, right) => left - right);
|
||||
return turns;
|
||||
}
|
||||
|
||||
export function questionTurnFromRailY(clientY: number, top: number, height: number, totalQuestions: number): number | null {
|
||||
const total = Math.max(0, Math.floor(totalQuestions));
|
||||
if (total === 0 || !Number.isFinite(height) || height <= 0) return null;
|
||||
const ratio = Math.max(0, Math.min(1, (clientY - top) / height));
|
||||
return Math.min(total - 1, Math.floor(ratio * total));
|
||||
}
|
||||
|
||||
export function QuestionJumpBar({
|
||||
loadedQuestions,
|
||||
totalQuestions,
|
||||
activeTurn,
|
||||
onJump,
|
||||
}: {
|
||||
loadedQuestions: QuestionAnchor[];
|
||||
totalQuestions: number;
|
||||
activeTurn: number | null;
|
||||
onJump: (question: QuestionAnchor) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const total = Math.max(0, Math.floor(totalQuestions));
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
const [active, setActive] = useState<number | null>(null);
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const barRef = useRef<HTMLElement>(null);
|
||||
const railRef = useRef<HTMLDivElement>(null);
|
||||
const previewTop = useRef(0);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (questions.length === 0) return;
|
||||
setActive(questions[questions.length - 1]?.turn ?? null);
|
||||
}, [questions]);
|
||||
const loadedByTurn = useMemo(() => {
|
||||
const loaded = new Map<number, QuestionAnchor>();
|
||||
for (const question of loadedQuestions) loaded.set(question.turn, question);
|
||||
return loaded;
|
||||
}, [loadedQuestions]);
|
||||
const active = activeTurn != null && activeTurn >= 0 && activeTurn < total
|
||||
? activeTurn
|
||||
: total > 0 ? total - 1 : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (active === null) return;
|
||||
const el = barRef.current?.querySelector(`[data-turn="${active}"]`);
|
||||
el?.scrollIntoView({ block: "nearest" });
|
||||
}, [active]);
|
||||
const markerTurns = useMemo(() => sampledQuestionTurns(total, active), [active, total]);
|
||||
const hoverIdx = hovered === null
|
||||
? -1
|
||||
: markerTurns.reduce((closest, turn, index) => (
|
||||
closest < 0 || Math.abs(turn - hovered) < Math.abs(markerTurns[closest] - hovered) ? index : closest
|
||||
), -1);
|
||||
|
||||
const hoverIdx = hovered !== null ? questions.findIndex((question) => question.turn === hovered) : -1;
|
||||
const hoveredQuestion = hovered !== null ? questions.find((question) => question.turn === hovered) : undefined;
|
||||
|
||||
const closestQuestionFromY = (clientY: number): { question: QuestionAnchor; previewY: number } | null => {
|
||||
const el = barRef.current;
|
||||
if (!el) return null;
|
||||
const markers = el.querySelectorAll<HTMLElement>(".jump-item");
|
||||
const barRect = el.getBoundingClientRect();
|
||||
let closest = -1;
|
||||
let closestDist = Infinity;
|
||||
let closestY = 0;
|
||||
markers.forEach((item, index) => {
|
||||
const rect = item.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
const dist = Math.abs(clientY - midY);
|
||||
if (dist < closestDist) {
|
||||
closestDist = dist;
|
||||
closest = index;
|
||||
closestY = midY - barRect.top;
|
||||
}
|
||||
});
|
||||
const question = questions[closest];
|
||||
if (!question) return null;
|
||||
return { question, previewY: closestY };
|
||||
const questionAt = (turn: number): QuestionAnchor => loadedByTurn.get(turn) ?? {
|
||||
id: `history-question-${turn + 1}`,
|
||||
text: t("questionNav.notLoaded", { n: turn + 1 }),
|
||||
turn,
|
||||
loaded: false,
|
||||
};
|
||||
const hoveredQuestion = hovered === null ? undefined : questionAt(hovered);
|
||||
|
||||
const onMove = (e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
const closest = closestQuestionFromY(e.clientY);
|
||||
if (!closest) return;
|
||||
previewTop.current = closest.previewY;
|
||||
setHovered(closest.question.turn);
|
||||
setShowPreview(true);
|
||||
const questionFromY = (clientY: number): { question: QuestionAnchor; previewY: number } | null => {
|
||||
const rail = railRef.current;
|
||||
const bar = barRef.current;
|
||||
if (!rail || !bar) return null;
|
||||
const railRect = rail.getBoundingClientRect();
|
||||
const turn = questionTurnFromRailY(clientY, railRect.top, railRect.height, total);
|
||||
if (turn === null) return null;
|
||||
const barRect = bar.getBoundingClientRect();
|
||||
const turnCenter = railRect.top - barRect.top + ((turn + 0.5) / total) * railRect.height;
|
||||
return {
|
||||
question: questionAt(turn),
|
||||
previewY: Math.max(0, Math.min(barRect.height, turnCenter)),
|
||||
};
|
||||
};
|
||||
|
||||
const scrollTo = (question: QuestionAnchor) => {
|
||||
setActive(question.turn);
|
||||
onJump(question);
|
||||
};
|
||||
|
||||
const onRailMouseDown = (e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
const closest = closestQuestionFromY(e.clientY);
|
||||
if (!closest) return;
|
||||
e.preventDefault();
|
||||
previewTop.current = closest.previewY;
|
||||
setHovered(closest.question.turn);
|
||||
const onMove = (event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
const target = questionFromY(event.clientY);
|
||||
if (!target) return;
|
||||
previewTop.current = target.previewY;
|
||||
setHovered(target.question.turn);
|
||||
setShowPreview(true);
|
||||
scrollTo(closest.question);
|
||||
};
|
||||
|
||||
const onItemMouseDown = (e: ReactMouseEvent<HTMLButtonElement>, question: QuestionAnchor) => {
|
||||
e.preventDefault();
|
||||
scrollTo(question);
|
||||
const onRailMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
const target = questionFromY(event.clientY);
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
previewTop.current = target.previewY;
|
||||
setHovered(target.question.turn);
|
||||
setShowPreview(true);
|
||||
scrollTo(target.question);
|
||||
};
|
||||
|
||||
const dotProps = (
|
||||
idx: number,
|
||||
turn: number,
|
||||
): { style: CSSProperties; "data-d"?: string } => {
|
||||
const onRailKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (total === 0) return;
|
||||
const current = active ?? Math.max(0, total - 1);
|
||||
const page = Math.max(1, Math.round(total / 10));
|
||||
let next: number | null = null;
|
||||
switch (event.key) {
|
||||
case "ArrowUp":
|
||||
case "ArrowLeft": next = current - 1; break;
|
||||
case "ArrowDown":
|
||||
case "ArrowRight": next = current + 1; break;
|
||||
case "PageUp": next = current - page; break;
|
||||
case "PageDown": next = current + page; break;
|
||||
case "Home": next = 0; break;
|
||||
case "End": next = total - 1; break;
|
||||
case "Enter":
|
||||
case " ": next = current; break;
|
||||
default: return;
|
||||
}
|
||||
event.preventDefault();
|
||||
scrollTo(questionAt(Math.max(0, Math.min(total - 1, next))));
|
||||
};
|
||||
|
||||
const dotProps = (idx: number, turn: number): { style: CSSProperties; "data-d"?: string } => {
|
||||
const isActive = active === turn;
|
||||
if (hoverIdx < 0) {
|
||||
return { style: { width: isActive ? 18 : 12, background: isActive ? "var(--accent)" : undefined } };
|
||||
@@ -89,40 +149,51 @@ export function QuestionJumpBar({ questions, onJump }: { questions: QuestionAnch
|
||||
const width = d === 0 ? 32 : d === 1 ? 20 : d === 2 ? 14 : isActive ? 18 : 12;
|
||||
const background = d <= 2 ? undefined : isActive ? "var(--accent)" : undefined;
|
||||
return {
|
||||
style: { width, transitionDelay: `${d * 20}ms`, background },
|
||||
style: { width, transitionDelay: `${Math.min(d, 3) * 20}ms`, background },
|
||||
"data-d": d <= 2 ? String(d) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const density = markerTurns.length > 80 ? "packed" : markerTurns.length > 40 ? "compact" : "normal";
|
||||
const activeValue = active ?? Math.max(0, total - 1);
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="jump-bar"
|
||||
ref={barRef}
|
||||
aria-label={t("questionNav.label")}
|
||||
onMouseMove={onMove}
|
||||
onMouseLeave={() => {
|
||||
setHovered(null);
|
||||
setShowPreview(false);
|
||||
}}
|
||||
>
|
||||
{/* Pointer activation is owned by mousedown. Handling the following click
|
||||
as well can issue a second jump after the active marker has moved. */}
|
||||
<div className="jump-scroll" onMouseDown={onRailMouseDown}>
|
||||
{questions.map((question, index) => (
|
||||
<button
|
||||
<div
|
||||
className="jump-scroll"
|
||||
ref={railRef}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={t("questionNav.label")}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={Math.max(1, total)}
|
||||
aria-valuenow={activeValue + 1}
|
||||
aria-valuetext={t("questionNav.progress", { current: activeValue + 1, total })}
|
||||
data-density={density}
|
||||
onMouseMove={onMove}
|
||||
onMouseDown={onRailMouseDown}
|
||||
onKeyDown={onRailKeyDown}
|
||||
>
|
||||
{markerTurns.map((turn, index) => (
|
||||
<span
|
||||
className="jump-item"
|
||||
key={question.id}
|
||||
type="button"
|
||||
data-turn={question.turn}
|
||||
aria-label={t("questionNav.jump", { n: question.turn + 1 })}
|
||||
onMouseDown={(e) => onItemMouseDown(e, question)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.detail === 0) scrollTo(question);
|
||||
}}
|
||||
key={turn}
|
||||
data-turn={turn}
|
||||
data-loaded={loadedByTurn.has(turn) ? "true" : "false"}
|
||||
aria-hidden="true"
|
||||
style={{ top: `${((turn + 0.5) / total) * 100}%` }}
|
||||
>
|
||||
<span className="jump-dot" {...dotProps(index, question.turn)} />
|
||||
</button>
|
||||
<span className="jump-dot" {...dotProps(index, turn)} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{showPreview && hoveredQuestion && (
|
||||
@@ -133,3 +204,5 @@ export function QuestionJumpBar({ questions, onJump }: { questions: QuestionAnch
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuestionJumpBar;
|
||||
@@ -0,0 +1,108 @@
|
||||
.scroll-diagnostics {
|
||||
--wails-draggable: no-drag;
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
bottom: 14px;
|
||||
z-index: var(--z-inline-sticky);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-height: 32px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--bg-elev) 94%, transparent);
|
||||
box-shadow: var(--shadow-2);
|
||||
color: var(--fg-dim);
|
||||
font-size: var(--font-caption);
|
||||
pointer-events: auto;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.scroll-diagnostics button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-height: 26px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scroll-diagnostics button:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.scroll-diagnostics button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.scroll-diagnostics button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.scroll-diagnostics__start,
|
||||
.scroll-diagnostics__export {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scroll-diagnostics__export {
|
||||
background: var(--accent-soft) !important;
|
||||
color: var(--accent) !important;
|
||||
}
|
||||
|
||||
.scroll-diagnostics__icon-button {
|
||||
width: 28px;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.scroll-diagnostics__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 200px;
|
||||
padding: 0 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scroll-diagnostics__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: 0 0 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.scroll-diagnostics--stopped .scroll-diagnostics__dot {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.scroll-diagnostics {
|
||||
left: 8px;
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
.scroll-diagnostics__status {
|
||||
max-width: 132px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.scroll-diagnostics__export span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scroll-diagnostics__export {
|
||||
width: 28px;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Activity, CircleStop, Download, Flag, X } from "lucide-react";
|
||||
import { app } from "../lib/bridge";
|
||||
import { useI18n, type Locale } from "../lib/i18n";
|
||||
import { useToast } from "../lib/toast";
|
||||
import "./ScrollDiagnosticPanel.css";
|
||||
import {
|
||||
isTranscriptScrollDiagnosticsBuild,
|
||||
transcriptScrollDiagnostics,
|
||||
type TranscriptScrollDiagnosticSnapshot,
|
||||
} from "../lib/transcriptScrollDiagnostics";
|
||||
|
||||
const diagnosticsAvailable = isTranscriptScrollDiagnosticsBuild(
|
||||
typeof __BUILD_CHANNEL__ === "string" ? __BUILD_CHANNEL__ : "stable",
|
||||
import.meta.env.DEV,
|
||||
);
|
||||
|
||||
type DiagnosticCopy = {
|
||||
start: string;
|
||||
startHint: string;
|
||||
recording: (seconds: number, events: number) => string;
|
||||
ready: (events: number) => string;
|
||||
mark: string;
|
||||
markHint: string;
|
||||
stopExport: string;
|
||||
exportLabel: string;
|
||||
exported: string;
|
||||
exportFailed: (error: string) => string;
|
||||
};
|
||||
|
||||
// Keep diagnostic-only copy in this lazy chunk so stable builds do not carry
|
||||
// strings for a panel that is compiled out of the product surface.
|
||||
const COPY: Record<Locale, DiagnosticCopy> = {
|
||||
en: {
|
||||
start: "Scroll diagnostics",
|
||||
startHint: "Start a local 90-second scroll trace",
|
||||
recording: (seconds, events) => `Recording - ${seconds}s - ${events} events`,
|
||||
ready: (events) => `Trace ready - ${events} events`,
|
||||
mark: "Mark issue",
|
||||
markHint: "Mark the moment the issue appears",
|
||||
stopExport: "Stop & export",
|
||||
exportLabel: "Export",
|
||||
exported: "Scroll diagnostics exported",
|
||||
exportFailed: (error) => `Could not export diagnostics: ${error}`,
|
||||
},
|
||||
zh: {
|
||||
start: "滚动诊断",
|
||||
startHint: "开始一段仅保存在本地的 90 秒滚动记录",
|
||||
recording: (seconds, events) => `记录中 - ${seconds} 秒 - ${events} 条事件`,
|
||||
ready: (events) => `记录已停止 - ${events} 条事件`,
|
||||
mark: "标记问题",
|
||||
markHint: "问题出现时点击标记",
|
||||
stopExport: "停止并导出",
|
||||
exportLabel: "导出",
|
||||
exported: "滚动诊断文件已导出",
|
||||
exportFailed: (error) => `无法导出诊断文件:${error}`,
|
||||
},
|
||||
"zh-TW": {
|
||||
start: "捲動診斷",
|
||||
startHint: "開始一段僅儲存在本機的 90 秒捲動記錄",
|
||||
recording: (seconds, events) => `記錄中 - ${seconds} 秒 - ${events} 筆事件`,
|
||||
ready: (events) => `記錄已停止 - ${events} 筆事件`,
|
||||
mark: "標記問題",
|
||||
markHint: "問題出現時按一下標記",
|
||||
stopExport: "停止並匯出",
|
||||
exportLabel: "匯出",
|
||||
exported: "捲動診斷檔案已匯出",
|
||||
exportFailed: (error) => `無法匯出診斷檔案:${error}`,
|
||||
},
|
||||
};
|
||||
|
||||
function diagnosticSample(element: HTMLElement | null, totalRows: number): Record<string, unknown> | null {
|
||||
if (!element) return null;
|
||||
const viewport = element.getBoundingClientRect();
|
||||
let firstVisibleIndex: number | undefined;
|
||||
let firstVisibleTop: number | undefined;
|
||||
const mountedRows = element.querySelectorAll<HTMLElement>(".transcript__row[data-logical-index]");
|
||||
for (const row of mountedRows) {
|
||||
const rect = row.getBoundingClientRect();
|
||||
if (rect.bottom <= viewport.top || rect.top >= viewport.bottom) continue;
|
||||
const index = Number(row.dataset.logicalIndex);
|
||||
if (Number.isFinite(index)) firstVisibleIndex = index;
|
||||
firstVisibleTop = rect.top - viewport.top;
|
||||
break;
|
||||
}
|
||||
const bottomDistance = element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
return {
|
||||
scrollTop: element.scrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight,
|
||||
bottomDistance,
|
||||
mountedRows: mountedRows.length,
|
||||
totalRows,
|
||||
firstVisibleIndex,
|
||||
firstVisibleTop,
|
||||
mode: element.dataset.scrollMode,
|
||||
atBottom: bottomDistance <= 4,
|
||||
scrollable: element.scrollHeight - element.clientHeight > 4,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ScrollDiagnosticPanel({
|
||||
scrollElement,
|
||||
totalRows,
|
||||
}: {
|
||||
scrollElement: HTMLElement | null;
|
||||
totalRows: number;
|
||||
}) {
|
||||
const { locale, t } = useI18n();
|
||||
const copy = COPY[locale];
|
||||
const { showToast } = useToast();
|
||||
const scrollElementRef = useRef(scrollElement);
|
||||
const totalRowsRef = useRef(totalRows);
|
||||
scrollElementRef.current = scrollElement;
|
||||
totalRowsRef.current = totalRows;
|
||||
const [snapshot, setSnapshot] = useState<TranscriptScrollDiagnosticSnapshot>(() => transcriptScrollDiagnostics.getSnapshot());
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const refresh = useCallback(() => setSnapshot(transcriptScrollDiagnostics.getSnapshot()), []);
|
||||
useEffect(() => transcriptScrollDiagnostics.subscribe(refresh), [refresh]);
|
||||
useEffect(() => {
|
||||
if (snapshot.status !== "recording") return;
|
||||
const timer = window.setInterval(refresh, 500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refresh, snapshot.status]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
transcriptScrollDiagnostics.start(() => diagnosticSample(scrollElementRef.current, totalRowsRef.current));
|
||||
}, []);
|
||||
|
||||
const exportTrace = useCallback(async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const payload = transcriptScrollDiagnostics.stop();
|
||||
const serialized = JSON.stringify(payload);
|
||||
const path = app.ExportScrollDiagnostics
|
||||
? await app.ExportScrollDiagnostics(serialized)
|
||||
: await app.SaveExportFile("reasonix-scroll-diagnostics-browser.json", serialized, false)
|
||||
.then(() => "reasonix-scroll-diagnostics-browser.json");
|
||||
if (path) {
|
||||
showToast(copy.exported, "info");
|
||||
transcriptScrollDiagnostics.reset();
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
showToast(copy.exportFailed(detail), "error");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [copy, showToast]);
|
||||
|
||||
if (!diagnosticsAvailable) return null;
|
||||
|
||||
if (snapshot.status === "idle") {
|
||||
return (
|
||||
<div className="scroll-diagnostics" data-testid="scroll-diagnostics">
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-diagnostics__start"
|
||||
onClick={start}
|
||||
title={copy.startHint}
|
||||
>
|
||||
<Activity size={15} aria-hidden="true" />
|
||||
<span>{copy.start}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.min(90, Math.floor(snapshot.durationMs / 1_000));
|
||||
const stopped = snapshot.status === "stopped";
|
||||
return (
|
||||
<div className={`scroll-diagnostics scroll-diagnostics--${snapshot.status}`} data-testid="scroll-diagnostics">
|
||||
<span className="scroll-diagnostics__status" role="status">
|
||||
<span className="scroll-diagnostics__dot" aria-hidden="true" />
|
||||
{stopped
|
||||
? copy.ready(snapshot.eventCount)
|
||||
: copy.recording(elapsedSeconds, snapshot.eventCount)}
|
||||
</span>
|
||||
{!stopped && (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-diagnostics__icon-button"
|
||||
onClick={() => transcriptScrollDiagnostics.mark()}
|
||||
aria-label={copy.mark}
|
||||
title={copy.markHint}
|
||||
>
|
||||
<Flag size={15} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-diagnostics__export"
|
||||
onClick={() => void exportTrace()}
|
||||
disabled={exporting}
|
||||
title={stopped ? copy.exportLabel : copy.stopExport}
|
||||
>
|
||||
{stopped ? <Download size={15} aria-hidden="true" /> : <CircleStop size={15} aria-hidden="true" />}
|
||||
<span>{stopped ? copy.exportLabel : copy.stopExport}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-diagnostics__icon-button"
|
||||
onClick={() => transcriptScrollDiagnostics.reset()}
|
||||
aria-label={t("common.cancel")}
|
||||
title={t("common.cancel")}
|
||||
>
|
||||
<X size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { lazy, memo, Suspense, startTransition, useCallback, useDeferredValue, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
|
||||
import { ArrowRight, Bot as BotIcon, BrainCircuit, Check, CheckCircle2, ChevronDown, ChevronUp, CircleDollarSign, Clipboard, ExternalLink, KeyRound, Languages, ListChecks, Loader2, MessageCircle, Monitor, MoreHorizontal, PanelBottom, Play, Power, QrCode, RefreshCw, Send, ShieldCheck, SlidersHorizontal, Trash2, Volume2 } from "lucide-react";
|
||||
import { ArrowRight, BrainCircuit, Check, CheckCircle2, ChevronDown, ChevronUp, CircleDollarSign, Clipboard, ExternalLink, KeyRound, Languages, ListChecks, Loader2, Monitor, MoreHorizontal, PanelBottom, Play, Power, QrCode, RefreshCw, Send, ShieldCheck, SlidersHorizontal, Trash2, Volume2 } from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { CHANNEL_ICONS } from "./channelIcons";
|
||||
import { botAccessEntryCount, botAccessReady, botConnectionCredentialSummary, botConnectionLabel, botConnectionScopeLabel, botConnectionSecretEnv, botConnectionSecretPatch, botInstallTargetForConnection, botInstallTargetMatchesConnection, botTargetHint, botTargetLabel, diagnosticMessage, diagnosticReportDetail, firstConnectionRemote, formatInstallTimeLeft, formatInstallUserCode, qqBotAdded, type BotInstallTarget, type BotOfficialInstallTarget } from "./botConnectionSettings";
|
||||
import { useDeferredClose } from "../lib/useMountTransition";
|
||||
import { app, openExternal } from "../lib/bridge";
|
||||
import { normalizeLangPref, useI18n, useT, type DictKey, type LangPref } from "../lib/i18n";
|
||||
@@ -1113,6 +1115,7 @@ function defaultBotSettings(): BotSettingsView {
|
||||
qq: [],
|
||||
feishu: [],
|
||||
weixin: [],
|
||||
dingtalk: [],
|
||||
},
|
||||
control: {
|
||||
enabled: false,
|
||||
@@ -1140,6 +1143,10 @@ function defaultBotSettings(): BotSettingsView {
|
||||
qqGroups: [],
|
||||
feishuGroups: [],
|
||||
weixinGroups: [],
|
||||
dingtalkUsers: [],
|
||||
dingtalkApprovers: [],
|
||||
dingtalkAdmins: [],
|
||||
dingtalkGroups: [],
|
||||
},
|
||||
qq: { enabled: false, appId: "", appSecretEnv: "QQ_BOT_APP_SECRET", secretSet: false, sandbox: false, model: "", toolApprovalMode: "ask", workspaceRoot: "", access: defaultBotAccess() },
|
||||
feishu: {
|
||||
@@ -1160,6 +1167,18 @@ function defaultBotSettings(): BotSettingsView {
|
||||
tokenSet: false,
|
||||
apiBase: "https://ilinkai.weixin.qq.com",
|
||||
},
|
||||
dingtalk: {
|
||||
enabled: false,
|
||||
clientId: "",
|
||||
clientSecretEnv: "DINGTALK_CLIENT_SECRET",
|
||||
secretSet: false,
|
||||
botName: "",
|
||||
requireMention: true,
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
access: defaultBotAccess(),
|
||||
},
|
||||
connections: [],
|
||||
};
|
||||
}
|
||||
@@ -1210,6 +1229,7 @@ function normalizeBotSettings(bot: BotSettingsView | null | undefined): BotSetti
|
||||
qq: asArray(selfUserIds.qq),
|
||||
feishu: asArray(selfUserIds.feishu),
|
||||
weixin: asArray(selfUserIds.weixin),
|
||||
dingtalk: asArray(selfUserIds.dingtalk),
|
||||
},
|
||||
control: {
|
||||
enabled: Boolean(control.enabled),
|
||||
@@ -1237,6 +1257,10 @@ function normalizeBotSettings(bot: BotSettingsView | null | undefined): BotSetti
|
||||
qqGroups: asArray(allowlist.qqGroups),
|
||||
feishuGroups: asArray(allowlist.feishuGroups),
|
||||
weixinGroups: asArray(allowlist.weixinGroups),
|
||||
dingtalkUsers: asArray(allowlist.dingtalkUsers),
|
||||
dingtalkApprovers: asArray(allowlist.dingtalkApprovers),
|
||||
dingtalkAdmins: asArray(allowlist.dingtalkAdmins),
|
||||
dingtalkGroups: asArray(allowlist.dingtalkGroups),
|
||||
},
|
||||
qq: {
|
||||
...fallback.qq,
|
||||
@@ -1248,6 +1272,12 @@ function normalizeBotSettings(bot: BotSettingsView | null | undefined): BotSetti
|
||||
},
|
||||
feishu: { ...fallback.feishu, ...bot?.feishu, domain: bot?.feishu?.domain === "lark" ? "lark" : "feishu", mode },
|
||||
weixin: { ...fallback.weixin, ...bot?.weixin },
|
||||
dingtalk: {
|
||||
...fallback.dingtalk,
|
||||
...bot?.dingtalk,
|
||||
toolApprovalMode: normalizeBotToolApprovalMode(bot?.dingtalk?.toolApprovalMode),
|
||||
access: normalizeBotAccess(bot?.dingtalk?.access, fallback.dingtalk.access),
|
||||
},
|
||||
connections: asArray(bot?.connections).map(normalizeBotConnection),
|
||||
};
|
||||
}
|
||||
@@ -2067,8 +2097,6 @@ function NetworkSection({ s, busy, apply }: SectionProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type BotInstallTarget = "qq" | "feishu" | "lark" | "weixin";
|
||||
type BotOfficialInstallTarget = Exclude<BotInstallTarget, "qq">;
|
||||
const BOT_ALLOWLIST_TEXT_KEYS = [
|
||||
"qqUsers",
|
||||
"feishuUsers",
|
||||
@@ -2082,6 +2110,10 @@ const BOT_ALLOWLIST_TEXT_KEYS = [
|
||||
"qqGroups",
|
||||
"feishuGroups",
|
||||
"weixinGroups",
|
||||
"dingtalkUsers",
|
||||
"dingtalkApprovers",
|
||||
"dingtalkAdmins",
|
||||
"dingtalkGroups",
|
||||
] as const;
|
||||
type BotAllowlistTextKey = typeof BOT_ALLOWLIST_TEXT_KEYS[number];
|
||||
type BotSelfUserTextKey = keyof BotSettingsView["selfUserIds"];
|
||||
@@ -2092,12 +2124,13 @@ type BotInstallState = {
|
||||
timeLeft: number;
|
||||
message: string;
|
||||
};
|
||||
const BOT_INSTALL_TARGETS: BotInstallTarget[] = ["qq", "feishu", "lark", "weixin"];
|
||||
const BOT_INSTALL_TARGETS: BotInstallTarget[] = ["qq", "feishu", "lark", "weixin", "dingtalk"];
|
||||
const BOT_INSTALL_DEFAULT_TIMEOUT_SECONDS = 300;
|
||||
const BOT_INSTALL_MIN_POLL_SECONDS = 3;
|
||||
const DEFAULT_QQ_SECRET_ENV = "QQ_BOT_APP_SECRET";
|
||||
const QQ_CONNECTION_ID = "__qq_bot__";
|
||||
const BOT_PLATFORM_KEYS = ["qq", "feishu", "weixin"] as const;
|
||||
const DINGTALK_CONNECTION_ID = "__dingtalk_bot__";
|
||||
const BOT_PLATFORM_KEYS = ["qq", "feishu", "weixin", "dingtalk"] as const;
|
||||
type BotPlatformKey = typeof BOT_PLATFORM_KEYS[number];
|
||||
const BOT_ALLOWLIST_ROLES = ["Users", "Groups", "Approvers", "Admins"] as const;
|
||||
type BotAllowlistRole = typeof BOT_ALLOWLIST_ROLES[number];
|
||||
@@ -2110,12 +2143,14 @@ function botAllowlistKey(platform: BotPlatformKey, role: BotAllowlistRole): BotA
|
||||
function botConnectionPlatform(connection: BotConnectionView): BotPlatformKey {
|
||||
if (connection.provider === "weixin") return "weixin";
|
||||
if (connection.provider === "qq") return "qq";
|
||||
if (connection.provider === "dingtalk") return "dingtalk";
|
||||
return "feishu";
|
||||
}
|
||||
|
||||
function botPlatformLabel(platform: BotPlatformKey, t: ReturnType<typeof useT>): string {
|
||||
if (platform === "qq") return "QQ";
|
||||
if (platform === "weixin") return t("settings.botWeixin");
|
||||
if (platform === "dingtalk") return t("settings.botDingtalk");
|
||||
return t("settings.botPlatformFeishuLark");
|
||||
}
|
||||
|
||||
@@ -2139,6 +2174,9 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
|
||||
const [connectionSecrets, setConnectionSecrets] = useState<Record<string, string>>({});
|
||||
const [accessText, setAccessText] = useState<Record<string, string>>({});
|
||||
const [qqSecretValue, setQQSecretValue] = useState("");
|
||||
const [dingtalkSecretValue, setDingtalkSecretValue] = useState("");
|
||||
const [dingtalkTesting, setDingtalkTesting] = useState(false);
|
||||
const [runtimePlatforms, setRuntimePlatforms] = useState<Record<string, string>>({});
|
||||
const [expandedConnectionId, setExpandedConnectionId] = useState("");
|
||||
const [advancedMode, setAdvancedMode] = useState(false);
|
||||
const installRef = useRef(install);
|
||||
@@ -2160,6 +2198,24 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
|
||||
setQQSecretValue("");
|
||||
setTestTargets({});
|
||||
}, [s.bot]);
|
||||
// 轮询 bot runtime 的真实平台连接状态,用于 channel 在线徽章(如钉钉黄/绿点)。
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refresh = () => {
|
||||
if (typeof app.BotRuntimeStatus !== "function") return;
|
||||
void app.BotRuntimeStatus()
|
||||
.then((status) => {
|
||||
if (!cancelled) setRuntimePlatforms(status.platforms ?? {});
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
refresh();
|
||||
const id = window.setInterval(refresh, 5000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
const focusAccessStep = () => {
|
||||
if (!expandedConnectionId && connectionItems.length > 0) {
|
||||
const first = connectionItems[0];
|
||||
@@ -2262,6 +2318,14 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
|
||||
updateQQ({ access: normalizeBotAccess({ ...draft.qq.access, ...patch }) });
|
||||
const persistQQAccess = (patch: Partial<BotAccessView>) =>
|
||||
persistQQ({ access: normalizeBotAccess({ ...draft.qq.access, ...patch }) });
|
||||
const updateDingtalk = (patch: Partial<BotSettingsView["dingtalk"]>) =>
|
||||
setDraft((prev) => ({ ...prev, dingtalk: { ...prev.dingtalk, ...patch } }));
|
||||
const persistDingtalk = (patch: Partial<BotSettingsView["dingtalk"]>) =>
|
||||
persistBotDraft({ ...draft, dingtalk: { ...draft.dingtalk, ...patch } });
|
||||
const updateDingtalkAccess = (patch: Partial<BotAccessView>) =>
|
||||
updateDingtalk({ access: normalizeBotAccess({ ...draft.dingtalk.access, ...patch }) });
|
||||
const persistDingtalkAccess = (patch: Partial<BotAccessView>) =>
|
||||
persistDingtalk({ access: normalizeBotAccess({ ...draft.dingtalk.access, ...patch }) });
|
||||
const updateConnectionAccess = (id: string, patch: Partial<BotAccessView>) =>
|
||||
setConnections((items) => items.map((item) => item.id === id ? { ...item, access: normalizeBotAccess({ ...item.access, ...patch }) } : item));
|
||||
const persistConnectionAccess = (connection: BotConnectionView, patch: Partial<BotAccessView>) =>
|
||||
@@ -2294,6 +2358,7 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
|
||||
const installQrURL = install.result?.url ?? "";
|
||||
const installQrIsImage = installQrURL.startsWith("data:image/");
|
||||
const isQQInstallTarget = installTarget === "qq";
|
||||
const isDingtalkInstallTarget = installTarget === "dingtalk";
|
||||
const selectedInstallLabel = botTargetLabel(installTarget, t);
|
||||
const installUserCode = install.result?.userCode && installTarget !== "weixin" ? formatInstallUserCode(install.result.userCode) : "";
|
||||
const qqSecretEnv = draft.qq.appSecretEnv.trim() || DEFAULT_QQ_SECRET_ENV;
|
||||
@@ -2304,12 +2369,17 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
|
||||
const nativeRuntimeAvailable = typeof window !== "undefined" && Boolean(window.runtime);
|
||||
const browserPreviewBotConfigured = !nativeRuntimeAvailable && (qqAdded || draft.connections.length > 0);
|
||||
const qqOnline = qqConfigured && nativeRuntimeAvailable;
|
||||
const dingtalkSecretEnv = draft.dingtalk.clientSecretEnv.trim() || "DINGTALK_CLIENT_SECRET";
|
||||
const dingtalkConfigured = Boolean(draft.dingtalk.enabled && draft.dingtalk.clientId.trim() && dingtalkSecretEnv && draft.dingtalk.secretSet);
|
||||
// 保存按钮仅在用户输入了新的密钥后才可点;已保存过密钥但没有新输入时置灰。
|
||||
const dingtalkCanSaveAndEnable = Boolean(draft.dingtalk.clientId.trim() && dingtalkSecretEnv && dingtalkSecretValue.trim());
|
||||
const dingtalkOnline = runtimePlatforms["dingtalk"] === "running" || runtimePlatforms["dingtalk"] === "degraded";
|
||||
const connectionItems: BotConnectionListItem[] = [
|
||||
...(qqAdded ? [{ kind: "qq" as const }] : []),
|
||||
...draft.connections.map((connection) => ({ kind: "connection" as const, connection })),
|
||||
];
|
||||
const selectedInstallConnection = isQQInstallTarget ? undefined : draft.connections.find((connection) => botInstallTargetMatchesConnection(installTarget, connection));
|
||||
const selectedChannelConfigured = isQQInstallTarget ? qqAdded : Boolean(selectedInstallConnection);
|
||||
const selectedInstallConnection = isQQInstallTarget || isDingtalkInstallTarget ? undefined : draft.connections.find((connection) => botInstallTargetMatchesConnection(installTarget, connection));
|
||||
const selectedChannelConfigured = isQQInstallTarget ? qqAdded : isDingtalkInstallTarget ? dingtalkConfigured : Boolean(selectedInstallConnection);
|
||||
const routeConnectionOptions = [
|
||||
...(qqAdded ? [{ id: "qq", label: "QQ" }] : []),
|
||||
...draft.connections.map((connection) => ({
|
||||
@@ -2439,6 +2509,15 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
|
||||
}));
|
||||
}
|
||||
};
|
||||
const testDingtalkBot = async () => {
|
||||
setDingtalkTesting(true);
|
||||
try {
|
||||
const diag = await app.TestDingtalkBot();
|
||||
setDiagnostics((prev) => ({ ...prev, dingtalk: diag }));
|
||||
} finally {
|
||||
setDingtalkTesting(false);
|
||||
}
|
||||
};
|
||||
const ensureReportableDiagnostic = async (connection: BotConnectionView) => {
|
||||
return diagnoseConnection(connection.id);
|
||||
};
|
||||
@@ -2495,6 +2574,15 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
|
||||
});
|
||||
setQQSecretValue("");
|
||||
};
|
||||
const clearDingtalkSecret = async () => {
|
||||
const env = draft.dingtalk.clientSecretEnv.trim() || "DINGTALK_CLIENT_SECRET";
|
||||
if (!env) return;
|
||||
await apply(async () => {
|
||||
await saveBot();
|
||||
await app.ClearBotSecret(env);
|
||||
});
|
||||
setDingtalkSecretValue("");
|
||||
};
|
||||
const focusQQAccessSettings = () => {
|
||||
setDiagnostics((prev) => ({ ...prev, [QQ_CONNECTION_ID]: t("settings.botQQAccessRequired") }));
|
||||
setExpandedConnectionId(QQ_CONNECTION_ID);
|
||||
@@ -2524,6 +2612,38 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
|
||||
setDraft(nextDraft);
|
||||
setQQSecretValue("");
|
||||
};
|
||||
const saveDingtalkAndEnable = async () => {
|
||||
const env = draft.dingtalk.clientSecretEnv.trim() || "DINGTALK_CLIENT_SECRET";
|
||||
const secret = dingtalkSecretValue.trim();
|
||||
const nextDraft = botDraftWithDerivedGatewayState({
|
||||
...draft,
|
||||
dingtalk: {
|
||||
...draft.dingtalk,
|
||||
enabled: true,
|
||||
clientId: draft.dingtalk.clientId.trim(),
|
||||
clientSecretEnv: env,
|
||||
secretSet: draft.dingtalk.secretSet || Boolean(secret),
|
||||
},
|
||||
});
|
||||
await apply(async () => {
|
||||
await app.SetBotSettings(nextDraft);
|
||||
if (secret) await app.SetBotSecret(env, secret);
|
||||
});
|
||||
setDraft(nextDraft);
|
||||
setDingtalkSecretValue("");
|
||||
};
|
||||
const removeDingtalkBot = async () => {
|
||||
const env = draft.dingtalk.clientSecretEnv.trim() || "DINGTALK_CLIENT_SECRET";
|
||||
const nextDraft = botDraftWithDerivedGatewayState({
|
||||
...draft,
|
||||
dingtalk: { enabled: false, clientId: "", clientSecretEnv: "DINGTALK_CLIENT_SECRET", secretSet: false, botName: "", requireMention: true, model: "", toolApprovalMode: "", workspaceRoot: "", access: defaultBotAccess() },
|
||||
});
|
||||
await apply(async () => {
|
||||
await app.SetBotSettings(nextDraft);
|
||||
if (draft.dingtalk.secretSet) await app.ClearBotSecret(env);
|
||||
});
|
||||
setDraft(nextDraft);
|
||||
};
|
||||
const removeQQBot = async () => {
|
||||
const env = draft.qq.appSecretEnv.trim() || DEFAULT_QQ_SECRET_ENV;
|
||||
const nextDraft = botDraftWithDerivedGatewayState({
|
||||
@@ -2539,7 +2659,8 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
|
||||
setExpandedConnectionId("");
|
||||
};
|
||||
const selectedQQ = isQQInstallTarget && qqAdded;
|
||||
const selectedConnection = isQQInstallTarget ? null : selectedInstallConnection ?? null;
|
||||
const selectedConnection = isQQInstallTarget || isDingtalkInstallTarget ? null : selectedInstallConnection ?? null;
|
||||
const selectedDingtalk = isDingtalkInstallTarget && dingtalkConfigured;
|
||||
const selectedDiagnostic = selectedConnection ? diagnostics[selectedConnection.id] : undefined;
|
||||
const selectedDiagnosticDetail = diagnosticReportDetail(selectedDiagnostic);
|
||||
const selectedConnectionRemote = selectedConnection ? firstConnectionRemote(selectedConnection) : "";
|
||||
@@ -2555,9 +2676,9 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
|
||||
const platformFilterAvailable = connectedPlatforms.size > 0 &&
|
||||
BOT_PLATFORM_KEYS.some((platform) => !connectedPlatforms.has(platform) && !platformHasAllowlistText(platform));
|
||||
const botChannelConnectionForTarget = (target: BotInstallTarget) =>
|
||||
target === "qq" ? null : draft.connections.find((connection) => botInstallTargetMatchesConnection(target, connection));
|
||||
target === "qq" || target === "dingtalk" ? null : draft.connections.find((connection) => botInstallTargetMatchesConnection(target, connection));
|
||||
const botChannelIsConfigured = (target: BotInstallTarget) =>
|
||||
target === "qq" ? qqAdded : Boolean(botChannelConnectionForTarget(target));
|
||||
target === "qq" ? qqAdded : target === "dingtalk" ? dingtalkConfigured : Boolean(botChannelConnectionForTarget(target));
|
||||
const openBotChannel = (target: BotInstallTarget) => {
|
||||
setInstallTarget(target);
|
||||
const connection = botChannelConnectionForTarget(target);
|
||||
@@ -2856,6 +2977,208 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
|
||||
</article>
|
||||
);
|
||||
|
||||
const dingtalkDetailCard = (
|
||||
<article className="bot-detail-card" aria-labelledby="bot-detail-title">
|
||||
<div className="bot-detail-card__head">
|
||||
<div className="bot-detail-card__identity">
|
||||
<div className="bot-detail-card__title" id="bot-detail-title">
|
||||
DingTalk Bot
|
||||
<span className="badge badge--neutral">DingTalk</span>
|
||||
<span className={`badge ${dingtalkOnline ? "badge--project" : dingtalkConfigured ? "badge--feedback" : "badge--feedback"}`}>
|
||||
{dingtalkOnline ? t("settings.botConnectionConnected") : dingtalkConfigured ? t("settings.botConnectionConfigured") : t("settings.botConnectionDisconnected")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bot-detail-card__desc">{t("settings.botAutoSaveHint")}</div>
|
||||
</div>
|
||||
<div className="bot-detail-card__actions">
|
||||
<button type="button" className="btn btn--small" disabled={busy || !dingtalkConfigured || dingtalkTesting} onClick={() => void testDingtalkBot()}>
|
||||
{dingtalkTesting ? t("settings.botTesting") : t("settings.botTest")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="bot-detail-section">
|
||||
<div className="bot-detail-section__head">{t("settings.botConnectionSummary")}</div>
|
||||
<div className="bot-detail-summary">
|
||||
<div>
|
||||
<span>{t("settings.botConnectionColumnChannel")}</span>
|
||||
<strong>DingTalk</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{t("settings.botDingtalkClientId")}</span>
|
||||
<code title={draft.dingtalk.clientId.trim() || undefined}>{draft.dingtalk.clientId.trim() || "—"}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span>{t("settings.botConnectionColumnScope")}</span>
|
||||
<strong>{t("settings.botScopeGlobal")}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{t("settings.botConnectionColumnStatus")}</span>
|
||||
<strong>{dingtalkOnline ? t("settings.botConnectionConnected") : dingtalkConfigured ? t("settings.botConnectionConfigured") : t("settings.botConnectionDisconnected")}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bot-detail-section bot-detail-section--runtime-primary">
|
||||
<SettingsField label={t("settings.botEnableBot")} hint={t("settings.botGatewayEnabled")}>
|
||||
<ToggleSegment
|
||||
value={draft.dingtalk.enabled}
|
||||
disabled={busy}
|
||||
onChange={(enabled) => {
|
||||
updateDingtalk({ enabled });
|
||||
void persistDingtalk({ enabled });
|
||||
}}
|
||||
/>
|
||||
</SettingsField>
|
||||
<SettingsField label={t("settings.botToolApprovalMode")} hint={t("settings.botToolApprovalModeHint")}>
|
||||
<div className="provider-add-segmented" role="group" aria-label={t("settings.botToolApprovalMode")}>
|
||||
{TOOL_APPROVAL_MODES.map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={normalizeBotToolApprovalMode(draft.dingtalk.toolApprovalMode, true) === mode ? "provider-add-segmented__item provider-add-segmented__item--active" : "provider-add-segmented__item"}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
updateDingtalk({ toolApprovalMode: mode });
|
||||
// 热更新运行中 gateway,不重启 bot runtime(避免面板跳变)。
|
||||
void app.SetBotDingtalkToolApprovalMode(mode).catch((e) => {
|
||||
console.warn("set dingtalk approval mode failed", e);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t(`settings.botToolApprovalMode.${mode}` as DictKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingsField>
|
||||
<SettingsField label={t("settings.botChannelModel")} hint={t("settings.botChannelModelHint")}>
|
||||
<ModelPicker
|
||||
s={s}
|
||||
refs={refs}
|
||||
value={toRef(draft.dingtalk.model, s)}
|
||||
disabled={busy}
|
||||
emptyOptionLabel={t("settings.botChannelModelAuto")}
|
||||
emptyOptionHint={settingsModelMeta(s, t)}
|
||||
onPick={(model) => {
|
||||
updateDingtalk({ model });
|
||||
void persistDingtalk({ model });
|
||||
}}
|
||||
/>
|
||||
</SettingsField>
|
||||
</section>
|
||||
|
||||
{renderBotAccessSection(DINGTALK_CONNECTION_ID, draft.dingtalk.access, updateDingtalkAccess, (patch) => void persistDingtalkAccess(patch))}
|
||||
|
||||
<section className="bot-detail-section">
|
||||
<div className="bot-detail-section__head">{t("settings.botRuntimeSettings")}</div>
|
||||
<SettingsField label={t("settings.botWorkspaceRoot")} hint={t("settings.botWorkspaceRootHint")}>
|
||||
<input
|
||||
className="mem-input"
|
||||
value={draft.dingtalk.workspaceRoot}
|
||||
disabled={busy}
|
||||
placeholder={t("settings.botWorkspaceRootPlaceholder")}
|
||||
spellCheck={false}
|
||||
onChange={(event) => updateDingtalk({ workspaceRoot: event.target.value })}
|
||||
onBlur={(event) => void persistDingtalk({ workspaceRoot: event.currentTarget.value })}
|
||||
/>
|
||||
</SettingsField>
|
||||
<SettingsField label={t("settings.botDingtalkBotName")} hint={t("settings.botDingtalkBotNameHint")}>
|
||||
<input
|
||||
className="mem-input"
|
||||
value={draft.dingtalk.botName}
|
||||
disabled={busy}
|
||||
placeholder={t("settings.botDingtalkBotName")}
|
||||
spellCheck={false}
|
||||
onChange={(event) => updateDingtalk({ botName: event.target.value })}
|
||||
onBlur={(event) => void persistDingtalk({ botName: event.currentTarget.value })}
|
||||
/>
|
||||
</SettingsField>
|
||||
<SettingsField label={t("settings.botDingtalkRequireMention")} hint={t("settings.botDingtalkRequireMentionHint")}>
|
||||
<ToggleSegment
|
||||
value={draft.dingtalk.requireMention}
|
||||
disabled={busy}
|
||||
onLabel={t("settings.toggleOn")}
|
||||
offLabel={t("settings.toggleOff")}
|
||||
onChange={(requireMention) => {
|
||||
updateDingtalk({ requireMention });
|
||||
void persistDingtalk({ requireMention });
|
||||
}}
|
||||
/>
|
||||
</SettingsField>
|
||||
</section>
|
||||
|
||||
<section className="bot-detail-section">
|
||||
<div className="bot-detail-section__head">{t("settings.botCredential")}</div>
|
||||
<div className="bot-credential-stack">
|
||||
<div className="bot-credential-line">
|
||||
<span>{draft.dingtalk.clientId.trim() ? t("settings.botCredentialClientId", { value: draft.dingtalk.clientId.trim() }) : t("settings.botCredentialConfigured")}</span>
|
||||
<strong>{draft.dingtalk.secretSet ? t("settings.botSecretSet") : t("settings.botSecretMissing")}</strong>
|
||||
</div>
|
||||
<div className="bot-secret-row bot-secret-row--dingtalk">
|
||||
<input
|
||||
className="mem-input"
|
||||
value={draft.dingtalk.clientId}
|
||||
disabled={busy}
|
||||
placeholder={t("settings.botDingtalkClientId")}
|
||||
spellCheck={false}
|
||||
aria-label={t("settings.botDingtalkClientId")}
|
||||
onChange={(event) => updateDingtalk({ clientId: event.target.value })}
|
||||
onBlur={(event) => void persistDingtalk({ clientId: event.currentTarget.value })}
|
||||
/>
|
||||
<input
|
||||
className="mem-input"
|
||||
value={draft.dingtalk.clientSecretEnv || "DINGTALK_CLIENT_SECRET"}
|
||||
disabled={busy}
|
||||
placeholder="DINGTALK_CLIENT_SECRET"
|
||||
spellCheck={false}
|
||||
aria-label={t("settings.botSecretEnv")}
|
||||
onChange={(event) => updateDingtalk({ clientSecretEnv: event.target.value })}
|
||||
onBlur={(event) => void persistDingtalk({ clientSecretEnv: event.currentTarget.value || "DINGTALK_CLIENT_SECRET" })}
|
||||
/>
|
||||
<input
|
||||
className="mem-input"
|
||||
type="password"
|
||||
value={dingtalkSecretValue}
|
||||
disabled={busy}
|
||||
placeholder={draft.dingtalk.secretSet ? t("settings.botSecretReplace") : t("settings.botSecretPaste")}
|
||||
aria-label={t("settings.botDingtalkClientSecret")}
|
||||
onChange={(event) => setDingtalkSecretValue(event.target.value)}
|
||||
/>
|
||||
<div className="bot-secret-row__actions">
|
||||
<button type="button" className="btn btn--secondary btn--small" disabled={busy || !dingtalkCanSaveAndEnable} onClick={() => void saveDingtalkAndEnable()}>
|
||||
{draft.dingtalk.secretSet ? t("settings.saveKey") : t("settings.botSaveAndEnable")}
|
||||
</button>
|
||||
<button type="button" className="btn btn--secondary btn--small" disabled={busy || !draft.dingtalk.secretSet} onClick={() => void clearDingtalkSecret()}>
|
||||
{t("settings.clearKey")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{diagnosticMessage(diagnostics.dingtalk) ? (
|
||||
<div className="bot-detail-notice">
|
||||
<span>{diagnosticMessage(diagnostics.dingtalk)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="bot-detail-section bot-detail-section--danger">
|
||||
<div>
|
||||
<div className="bot-detail-section__head">{t("settings.botDangerZone")}</div>
|
||||
<p>{t("settings.deleteBotHint")}</p>
|
||||
</div>
|
||||
<InlineConfirmButton
|
||||
label={t("settings.deleteBot")}
|
||||
confirmLabel={t("settings.confirmDeleteBot")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
disabled={busy}
|
||||
danger
|
||||
onConfirm={() => void removeDingtalkBot()}
|
||||
/>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
|
||||
const connectionDetailCard = selectedConnection ? (
|
||||
<article className="bot-detail-card" aria-labelledby="bot-detail-title">
|
||||
<div className="bot-detail-card__head">
|
||||
@@ -3005,12 +3328,14 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
|
||||
placeholder={selectedConnection.credential.secretSet ? t("settings.botSecretReplace") : t("settings.botSecretPaste")}
|
||||
onChange={(event) => setConnectionSecrets((prev) => ({ ...prev, [selectedConnection.id]: event.target.value }))}
|
||||
/>
|
||||
<button type="button" className="btn btn--secondary btn--small" disabled={busy || !(connectionSecrets[selectedConnection.id] ?? "").trim()} onClick={() => void saveConnectionSecret(selectedConnection)}>
|
||||
{t("settings.saveKey")}
|
||||
</button>
|
||||
<button type="button" className="btn btn--secondary btn--small" disabled={busy || !selectedConnection.credential.secretSet} onClick={() => void clearConnectionSecret(selectedConnection)}>
|
||||
{t("settings.clearKey")}
|
||||
</button>
|
||||
<div className="bot-secret-row__actions">
|
||||
<button type="button" className="btn btn--secondary btn--small" disabled={busy || !(connectionSecrets[selectedConnection.id] ?? "").trim()} onClick={() => void saveConnectionSecret(selectedConnection)}>
|
||||
{t("settings.saveKey")}
|
||||
</button>
|
||||
<button type="button" className="btn btn--secondary btn--small" disabled={busy || !selectedConnection.credential.secretSet} onClick={() => void clearConnectionSecret(selectedConnection)}>
|
||||
{t("settings.clearKey")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -3087,6 +3412,71 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : isDingtalkInstallTarget ? (
|
||||
<div className="bot-connect-panel bot-connect-panel--manual bot-connect-panel--dingtalk">
|
||||
<div className="bot-connect-panel__body">
|
||||
<div className="bot-qq-simple__head">
|
||||
<div>
|
||||
<strong>{selectedInstallLabel}</strong>
|
||||
<p>{t("settings.botInstallManualDingtalk")}</p>
|
||||
</div>
|
||||
<span className={`bot-qq-simple__status${dingtalkConfigured ? " bot-qq-simple__status--ready" : ""}`}>
|
||||
{dingtalkConfigured ? <CheckCircle2 aria-hidden="true" /> : <KeyRound aria-hidden="true" />}
|
||||
{draft.dingtalk.secretSet ? t("settings.botSecretSet") : t("settings.botSecretMissing")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bot-manual-form bot-manual-form--dingtalk">
|
||||
<div className="bot-card-field">
|
||||
<span>{t("settings.botDingtalkClientId")}</span>
|
||||
<div>
|
||||
<input
|
||||
className="mem-input"
|
||||
aria-label={t("settings.botDingtalkClientId")}
|
||||
value={draft.dingtalk.clientId}
|
||||
disabled={busy}
|
||||
spellCheck={false}
|
||||
onChange={(event) => updateDingtalk({ clientId: event.target.value })}
|
||||
onBlur={(event) => void persistDingtalk({ clientId: event.currentTarget.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bot-card-field">
|
||||
<span>{t("settings.botDingtalkClientSecret")}</span>
|
||||
<div>
|
||||
<input
|
||||
className="mem-input"
|
||||
type="password"
|
||||
value={dingtalkSecretValue}
|
||||
disabled={busy}
|
||||
placeholder={draft.dingtalk.secretSet ? t("settings.botSecretSavedOptional") : t("settings.botSecretPaste")}
|
||||
spellCheck={false}
|
||||
aria-label={t("settings.botDingtalkClientSecret")}
|
||||
onChange={(event) => setDingtalkSecretValue(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bot-card-field">
|
||||
<span>{t("settings.botDingtalkBotName")}</span>
|
||||
<div>
|
||||
<input
|
||||
className="mem-input"
|
||||
aria-label={t("settings.botDingtalkBotName")}
|
||||
value={draft.dingtalk.botName}
|
||||
disabled={busy}
|
||||
spellCheck={false}
|
||||
onChange={(event) => updateDingtalk({ botName: event.target.value })}
|
||||
onBlur={(event) => void persistDingtalk({ botName: event.currentTarget.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bot-qq-simple__actions">
|
||||
<button type="button" className="btn btn--primary btn--small" disabled={busy || !dingtalkCanSaveAndEnable} onClick={() => void saveDingtalkAndEnable()}>
|
||||
{t("settings.botSaveAndEnable")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bot-connect-panel bot-connect-panel--phone">
|
||||
<div className="bot-connect-panel__qr">
|
||||
@@ -3179,7 +3569,10 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
|
||||
onClick={() => openBotChannel(target)}
|
||||
>
|
||||
<span className="bot-channel-tab__icon" aria-hidden="true">
|
||||
{target === "qq" || target === "weixin" ? <MessageCircle size={24} /> : <BotIcon size={24} />}
|
||||
{(() => {
|
||||
const Icon = CHANNEL_ICONS[target];
|
||||
return <Icon className="bot-channel-tab__brand" />;
|
||||
})()}
|
||||
</span>
|
||||
<span className="bot-channel-tab__text">
|
||||
<strong>{botTargetLabel(target, t)}</strong>
|
||||
@@ -3204,6 +3597,8 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
|
||||
</article>
|
||||
) : selectedQQ ? (
|
||||
qqDetailCard
|
||||
) : selectedDingtalk ? (
|
||||
dingtalkDetailCard
|
||||
) : selectedConnection ? (
|
||||
connectionDetailCard
|
||||
) : (
|
||||
@@ -3650,114 +4045,6 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function diagnosticMessage(diag?: BotConnectionDiagnostic | string): string {
|
||||
if (typeof diag === "string") return diag;
|
||||
return diag?.message || diag?.status || "";
|
||||
}
|
||||
|
||||
function diagnosticReportDetail(diag?: BotConnectionDiagnostic | string): string {
|
||||
if (typeof diag === "string") return "";
|
||||
return diag?.reportDetail || "";
|
||||
}
|
||||
|
||||
function botTargetLabel(target: BotInstallTarget, t: ReturnType<typeof useT>): string {
|
||||
switch (target) {
|
||||
case "qq": return "QQ";
|
||||
case "lark": return "Lark";
|
||||
case "weixin": return t("settings.botWeixin");
|
||||
default: return t("settings.botFeishu");
|
||||
}
|
||||
}
|
||||
|
||||
function botTargetHint(target: BotInstallTarget, t: ReturnType<typeof useT>): string {
|
||||
switch (target) {
|
||||
case "qq": return t("settings.botInstallQQHint");
|
||||
case "lark": return t("settings.botInstallLarkHint");
|
||||
case "weixin": return t("settings.botInstallWeixinHint");
|
||||
default: return t("settings.botInstallFeishuHint");
|
||||
}
|
||||
}
|
||||
|
||||
function qqBotAdded(qq: BotSettingsView["qq"]): boolean {
|
||||
return Boolean(qq.enabled || qq.secretSet || qq.appId.trim());
|
||||
}
|
||||
|
||||
function botAccessEntryCount(access: BotAccessView): number {
|
||||
return [
|
||||
...asArray(access.users),
|
||||
...asArray(access.groups),
|
||||
...asArray(access.approvers),
|
||||
...asArray(access.admins),
|
||||
].filter((value) => value.trim()).length;
|
||||
}
|
||||
|
||||
function botAccessReady(access: BotAccessView): boolean {
|
||||
if (access.allowAll || access.pairingEnabled) return true;
|
||||
if (!access.enabled) return false;
|
||||
return botAccessEntryCount(access) > 0;
|
||||
}
|
||||
|
||||
function botInstallTargetMatchesConnection(target: BotOfficialInstallTarget, connection: BotConnectionView): boolean {
|
||||
if (target === "weixin") return connection.provider === "weixin";
|
||||
if (target === "lark") return connection.provider === "feishu" && connection.domain === "lark";
|
||||
return connection.provider === "feishu" && connection.domain !== "lark";
|
||||
}
|
||||
|
||||
function botInstallTargetForConnection(connection: BotConnectionView): BotInstallTarget {
|
||||
if (connection.provider === "weixin") return "weixin";
|
||||
if (connection.provider === "feishu" && connection.domain === "lark") return "lark";
|
||||
if (connection.provider === "qq") return "qq";
|
||||
return "feishu";
|
||||
}
|
||||
|
||||
function formatInstallUserCode(code: string): string {
|
||||
const compact = code.replace(/[^a-z0-9]/gi, "").toUpperCase().slice(0, 8);
|
||||
if (compact.length <= 4) return compact;
|
||||
return `${compact.slice(0, 4)}-${compact.slice(4)}`;
|
||||
}
|
||||
|
||||
function formatInstallTimeLeft(seconds: number): string {
|
||||
const value = Math.max(0, Math.floor(seconds));
|
||||
const minutes = Math.floor(value / 60);
|
||||
const rest = value % 60;
|
||||
return `${minutes}:${String(rest).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function botConnectionLabel(connection: BotConnectionView, t: ReturnType<typeof useT>): string {
|
||||
if (connection.domain === "lark") return "Lark";
|
||||
if (connection.provider === "weixin") return t("settings.botWeixin");
|
||||
if (connection.provider === "qq") return "QQ";
|
||||
return t("settings.botFeishu");
|
||||
}
|
||||
|
||||
function firstConnectionRemote(connection: BotConnectionView): string {
|
||||
return connection.sessionMappings.find((mapping) => mapping.remoteId.trim())?.remoteId ?? "";
|
||||
}
|
||||
|
||||
function botConnectionScopeLabel(connection: BotConnectionView, t: ReturnType<typeof useT>): string {
|
||||
return connection.workspaceRoot.trim() ? t("settings.botScopeProject") : t("settings.botScopeGlobal");
|
||||
}
|
||||
|
||||
function botConnectionSecretEnv(connection: BotConnectionView): string {
|
||||
return connection.provider === "weixin" ? connection.credential.tokenEnv : connection.credential.appSecretEnv;
|
||||
}
|
||||
|
||||
function botConnectionSecretPatch(connection: BotConnectionView, value: string): Partial<BotConnectionView["credential"]> {
|
||||
return connection.provider === "weixin" ? { tokenEnv: value } : { appSecretEnv: value };
|
||||
}
|
||||
|
||||
function botConnectionCredentialSummary(connection: BotConnectionView, t: ReturnType<typeof useT>): string {
|
||||
if (connection.provider === "weixin") {
|
||||
return connection.credential.accountId
|
||||
? t("settings.botCredentialAccount", { value: connection.credential.accountId })
|
||||
: t("settings.botCredentialLocalWeixin");
|
||||
}
|
||||
if (connection.credential.appId) {
|
||||
return t("settings.botCredentialApp", { value: connection.credential.appId });
|
||||
}
|
||||
return t("settings.botCredentialConfigured");
|
||||
}
|
||||
|
||||
function ToggleSegment({
|
||||
value,
|
||||
disabled,
|
||||
@@ -3840,6 +4127,7 @@ function sanitizeBotDraft(draft: BotSettingsView): BotSettingsView {
|
||||
qq: uniqueStrings(bot.selfUserIds.qq.map((v) => v.trim())),
|
||||
feishu: uniqueStrings(bot.selfUserIds.feishu.map((v) => v.trim())),
|
||||
weixin: uniqueStrings(bot.selfUserIds.weixin.map((v) => v.trim())),
|
||||
dingtalk: uniqueStrings(bot.selfUserIds.dingtalk.map((v) => v.trim())),
|
||||
},
|
||||
control: {
|
||||
enabled: bot.control.enabled,
|
||||
@@ -3866,6 +4154,10 @@ function sanitizeBotDraft(draft: BotSettingsView): BotSettingsView {
|
||||
qqGroups: uniqueStrings(bot.allowlist.qqGroups.map((v) => v.trim())),
|
||||
feishuGroups: uniqueStrings(bot.allowlist.feishuGroups.map((v) => v.trim())),
|
||||
weixinGroups: uniqueStrings(bot.allowlist.weixinGroups.map((v) => v.trim())),
|
||||
dingtalkUsers: uniqueStrings(bot.allowlist.dingtalkUsers.map((v) => v.trim())),
|
||||
dingtalkApprovers: uniqueStrings(bot.allowlist.dingtalkApprovers.map((v) => v.trim())),
|
||||
dingtalkAdmins: uniqueStrings(bot.allowlist.dingtalkAdmins.map((v) => v.trim())),
|
||||
dingtalkGroups: uniqueStrings(bot.allowlist.dingtalkGroups.map((v) => v.trim())),
|
||||
},
|
||||
qq: {
|
||||
...bot.qq,
|
||||
@@ -3891,6 +4183,14 @@ function sanitizeBotDraft(draft: BotSettingsView): BotSettingsView {
|
||||
tokenEnv: bot.weixin.tokenEnv.trim(),
|
||||
apiBase: bot.weixin.apiBase.trim().replace(/\/+$/, ""),
|
||||
},
|
||||
dingtalk: {
|
||||
...bot.dingtalk,
|
||||
clientId: bot.dingtalk.clientId.trim(),
|
||||
clientSecretEnv: bot.dingtalk.clientSecretEnv.trim(),
|
||||
botName: bot.dingtalk.botName.trim(),
|
||||
toolApprovalMode: normalizeBotToolApprovalMode(bot.dingtalk.toolApprovalMode),
|
||||
access: sanitizeBotAccess(bot.dingtalk.access),
|
||||
},
|
||||
connections: bot.connections.map((conn) => ({ ...normalizeBotConnection(conn), access: sanitizeBotAccess(conn.access) })).filter((conn) => conn.id && conn.provider),
|
||||
};
|
||||
}
|
||||
@@ -3910,7 +4210,7 @@ function botDraftWithDerivedGatewayState(draft: BotSettingsView): BotSettingsVie
|
||||
const bot = sanitizeBotDraft(draft);
|
||||
return {
|
||||
...bot,
|
||||
enabled: bot.qq.enabled || bot.connections.some((connection) => connection.enabled),
|
||||
enabled: bot.qq.enabled || bot.dingtalk.enabled || bot.connections.some((connection) => connection.enabled),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4982,7 +5282,7 @@ const OFFICIAL_PROVIDER_CHOICES: Array<{ kind: OfficialProviderKind; labelKey: D
|
||||
|
||||
type ProviderTemplateChoice =
|
||||
| { id: string; source: "official"; kind: OfficialProviderKind; label: string; description: string; keyEnv: string; added: boolean; keySet: boolean }
|
||||
| { id: string; source: "preset"; presetID: string; label: string; description: string; keyEnv: string; added: boolean; status: ProviderPresetStatus; statusProviderNames: string[]; keySet: boolean };
|
||||
| { id: string; source: "preset"; presetID: string; label: string; description: string; keyEnv: string; added: boolean; status: ProviderPresetStatus; statusProviderNames: string[]; keySet: boolean; recommended: boolean; billingMode: string };
|
||||
|
||||
function providerTemplateCanAdd(choice: ProviderTemplateChoice | undefined): boolean {
|
||||
if (!choice) return false;
|
||||
@@ -5068,7 +5368,7 @@ function providerPresetDescription(preset: ProviderPresetView, t: ReturnType<typ
|
||||
return t("settings.addProvider.preset.zaiCodingPlanGlobalDesc");
|
||||
case "zai-coding-plan-global-anthropic":
|
||||
return t("settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc");
|
||||
case "opencode-go": case "opencode-go-anthropic":
|
||||
case "opencode-go-recommended": case "opencode-go": case "opencode-go-anthropic": case "opencode-go-responses":
|
||||
case "opencode-go-deepseek-anthropic": case "opencode-go-deepseek-responses":
|
||||
return t(opencodeGoPresetDescriptionKeys[preset.id]);
|
||||
case "opencode-zen-anthropic":
|
||||
@@ -5187,11 +5487,16 @@ export function AddProviderPanel({
|
||||
status: normalizeProviderPresetStatus(preset.status, preset.added),
|
||||
statusProviderNames: asArray(preset.statusProviderNames),
|
||||
keySet: preset.keySet,
|
||||
recommended: Boolean(preset.recommended),
|
||||
billingMode: String(preset.billingMode ?? ""),
|
||||
})),
|
||||
], [officialProviders, providerPresets, t]);
|
||||
const [templateID, setTemplateID] = useState("official:deepseek");
|
||||
const [key, setKey] = useState("");
|
||||
const firstAvailableTemplateID = templateChoices.find(providerTemplateCanAdd)?.id ?? templateChoices[0]?.id ?? "";
|
||||
const firstAvailableTemplateID = templateChoices.find((choice) => choice.source === "preset" && choice.recommended && providerTemplateCanAdd(choice))?.id
|
||||
?? templateChoices.find(providerTemplateCanAdd)?.id
|
||||
?? templateChoices[0]?.id
|
||||
?? "";
|
||||
const selected = templateChoices.find((choice) => choice.id === templateID) ?? templateChoices.find((choice) => choice.id === firstAvailableTemplateID) ?? templateChoices[0];
|
||||
useEffect(() => {
|
||||
const current = templateChoices.find((choice) => choice.id === templateID);
|
||||
@@ -5246,6 +5551,7 @@ export function AddProviderPanel({
|
||||
{templateChoices.map((choice) => {
|
||||
const canAdd = providerTemplateCanAdd(choice);
|
||||
const badge = providerTemplateStatusBadge(choice, t);
|
||||
const recommendationBadge = choice.source === "preset" && choice.recommended ? t("settings.recommended") : "";
|
||||
const conflictProviderName = providerTemplateConflictProviderName(choice);
|
||||
if (choice.source === "preset" && (choice.status === "name_conflict" || choice.status === "installed_modified")) {
|
||||
return (
|
||||
@@ -5255,6 +5561,7 @@ export function AddProviderPanel({
|
||||
>
|
||||
<strong>
|
||||
{choice.label}
|
||||
{recommendationBadge ? ` · ${recommendationBadge}` : ""}
|
||||
{badge ? ` · ${badge}` : ""}
|
||||
</strong>
|
||||
<span>{choice.description}</span>
|
||||
@@ -5289,6 +5596,7 @@ export function AddProviderPanel({
|
||||
>
|
||||
<strong>
|
||||
{choice.label}
|
||||
{recommendationBadge ? ` · ${recommendationBadge}` : ""}
|
||||
{badge ? ` · ${badge}` : ""}
|
||||
</strong>
|
||||
<span>{choice.description}</span>
|
||||
@@ -6052,6 +6360,10 @@ function botAllowlistTextValues(allowlist: BotAllowlistView): Record<BotAllowlis
|
||||
qqGroups: allowlist.qqGroups.join("\n"),
|
||||
feishuGroups: allowlist.feishuGroups.join("\n"),
|
||||
weixinGroups: allowlist.weixinGroups.join("\n"),
|
||||
dingtalkUsers: allowlist.dingtalkUsers.join("\n"),
|
||||
dingtalkApprovers: allowlist.dingtalkApprovers.join("\n"),
|
||||
dingtalkAdmins: allowlist.dingtalkAdmins.join("\n"),
|
||||
dingtalkGroups: allowlist.dingtalkGroups.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6060,6 +6372,7 @@ function botSelfUserTextValues(selfUserIds: BotSettingsView["selfUserIds"]): Rec
|
||||
qq: selfUserIds.qq.join("\n"),
|
||||
feishu: selfUserIds.feishu.join("\n"),
|
||||
weixin: selfUserIds.weixin.join("\n"),
|
||||
dingtalk: selfUserIds.dingtalk.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6225,6 +6538,8 @@ export function ProviderEditor({
|
||||
initial?.requestUrl ?? "",
|
||||
initial?.chatUrl ?? "",
|
||||
));
|
||||
const providerNameInputId = useId();
|
||||
const providerNameHelpId = useId();
|
||||
const providerUrlInputId = useId();
|
||||
const providerUrlHelpId = useId();
|
||||
const [models, setModels] = useState((initial?.models ?? []).join(", "));
|
||||
@@ -6611,8 +6926,21 @@ export function ProviderEditor({
|
||||
|
||||
return (
|
||||
<div className={`provider-editor${isNewCustomProvider ? " provider-editor--wizard" : ""}`}>
|
||||
<label className="set-label">{t("settings.customProviderName")}</label>
|
||||
<input className="mem-input" placeholder={t("settings.customProviderNamePlaceholder")} value={name} onChange={(e) => setName(e.target.value)} disabled={!!initial} />
|
||||
<label className="set-label" htmlFor={providerNameInputId}>{t("settings.customProviderName")}</label>
|
||||
<input
|
||||
id={providerNameInputId}
|
||||
className="mem-input provider-name-input"
|
||||
aria-describedby={initial ? providerNameHelpId : undefined}
|
||||
placeholder={t("settings.customProviderNamePlaceholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={!!initial}
|
||||
/>
|
||||
{initial && (
|
||||
<div id={providerNameHelpId} className="mem-hint provider-name-readonly-hint">
|
||||
{t("settings.customProviderNameReadonlyHint")}
|
||||
</div>
|
||||
)}
|
||||
<label className="set-label">{t("settings.providerProtocol")}</label>
|
||||
<select className="mem-select" value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||
{providerKindChoices.map((choice) => (
|
||||
|
||||
@@ -85,23 +85,20 @@ export function TodoPanel({
|
||||
badges={<PromptBadge>{done}/{todos.length}</PromptBadge>}
|
||||
meta={summary}
|
||||
role="region"
|
||||
cardClassName="prompt-shelf--todo"
|
||||
cardCollapsible
|
||||
collapsed={!open}
|
||||
onToggleCollapse={() => setOpen((value) => {
|
||||
const next = !value;
|
||||
saveOpenState(stateKey, next);
|
||||
return next;
|
||||
})}
|
||||
headerActions={
|
||||
<>
|
||||
<PromptHeaderAction
|
||||
onClick={() => setOpen((value) => {
|
||||
const next = !value;
|
||||
saveOpenState(stateKey, next);
|
||||
return next;
|
||||
})}
|
||||
>
|
||||
{open ? t("common.collapse") : t("common.expand")}
|
||||
allDone && (
|
||||
<PromptHeaderAction onClick={onDismiss}>
|
||||
{t("common.close")}
|
||||
</PromptHeaderAction>
|
||||
{allDone && (
|
||||
<PromptHeaderAction onClick={onDismiss}>
|
||||
{t("common.close")}
|
||||
</PromptHeaderAction>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{open && (
|
||||
|
||||
@@ -41,6 +41,7 @@ export function Tooltip({
|
||||
block = false,
|
||||
disabled = false,
|
||||
className,
|
||||
delay = 180,
|
||||
}: {
|
||||
label?: ReactNode;
|
||||
children: ReactNode;
|
||||
@@ -49,6 +50,7 @@ export function Tooltip({
|
||||
block?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}) {
|
||||
const id = useId();
|
||||
const triggerRef = useRef<HTMLElement | null>(null);
|
||||
@@ -148,7 +150,7 @@ export function Tooltip({
|
||||
const triggerProps = {
|
||||
className: triggerClass,
|
||||
"aria-describedby": open ? id : undefined,
|
||||
onMouseEnter: () => show(),
|
||||
onMouseEnter: () => show(delay),
|
||||
onMouseLeave: hide,
|
||||
onPointerDownCapture: hide,
|
||||
onFocus: () => show(0),
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import type { TopicbarMoreMenuContentProps } from "./TopicbarMoreMenuContent";
|
||||
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
const loadMenuContent = () => import("./TopicbarMoreMenuContent");
|
||||
const TopicbarMoreMenuContent = lazy(async () => ({
|
||||
default: (await loadMenuContent()).TopicbarMoreMenuContent,
|
||||
}));
|
||||
|
||||
type TopicbarMoreMenuProps = Omit<TopicbarMoreMenuContentProps, "copied" | "initialFocus" | "onClose" | "onCopied">;
|
||||
|
||||
/** Keeps the topic bar compact while loading overflow actions only on demand. */
|
||||
export function TopicbarMoreMenu(props: TopicbarMoreMenuProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [initialFocus, setInitialFocus] = useState<"first" | "last">("first");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copiedTimerRef = useRef<number | null>(null);
|
||||
|
||||
const closeMenu = (restoreFocus: boolean) => {
|
||||
setMenuOpen(false);
|
||||
if (restoreFocus) triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) closeMenu(false);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") closeMenu(true);
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (copiedTimerRef.current != null) window.clearTimeout(copiedTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const markCopied = () => {
|
||||
setCopied(true);
|
||||
if (copiedTimerRef.current != null) window.clearTimeout(copiedTimerRef.current);
|
||||
copiedTimerRef.current = window.setTimeout(() => setCopied(false), 1200);
|
||||
};
|
||||
|
||||
const openFromKeyboard = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
||||
event.preventDefault();
|
||||
setInitialFocus(event.key === "ArrowUp" ? "last" : "first");
|
||||
setMenuOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={`topicbar__more${menuOpen ? " topicbar__more--open" : ""}`}>
|
||||
<Tooltip label={t("topicBar.more")}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
|
||||
type="button"
|
||||
aria-label={t("topicBar.more")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onPointerEnter={() => { void loadMenuContent(); }}
|
||||
onFocus={() => { void loadMenuContent(); }}
|
||||
onKeyDown={openFromKeyboard}
|
||||
onClick={() => {
|
||||
setInitialFocus("first");
|
||||
setMenuOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{menuOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<TopicbarMoreMenuContent
|
||||
{...props}
|
||||
copied={copied}
|
||||
initialFocus={initialFocus}
|
||||
onClose={closeMenu}
|
||||
onCopied={markCopied}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
.topicbar__more-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: var(--z-topicbar-menu);
|
||||
min-width: 168px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-elev);
|
||||
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.36);
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
|
||||
.topicbar__menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 9px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--fg-dim);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.topicbar__menu-item:hover,
|
||||
.topicbar__menu-item:focus-visible {
|
||||
color: var(--fg);
|
||||
background: var(--button-bg);
|
||||
}
|
||||
|
||||
.topicbar__menu-item:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.topicbar__menu-item--active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.topicbar__more-export {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.topicbar__more-export-menu {
|
||||
position: absolute;
|
||||
/* The primary menu sits at the far right, so the submenu opens left. */
|
||||
right: calc(100% - 8px);
|
||||
top: -5px;
|
||||
z-index: var(--z-topicbar-menu);
|
||||
min-width: 168px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-elev);
|
||||
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.36);
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
|
||||
.topicbar__more-export-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 9px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--fg-dim);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.topicbar__more-export-menu button:hover,
|
||||
.topicbar__more-export-menu button:focus-visible {
|
||||
color: var(--fg);
|
||||
background: var(--button-bg);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Check,
|
||||
Copy,
|
||||
Download,
|
||||
FileDown,
|
||||
FileImage,
|
||||
FileJson,
|
||||
FileText,
|
||||
GitBranch,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
import "./TopicbarMoreMenuContent.css";
|
||||
|
||||
import { writeClipboardText } from "../lib/clipboard";
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
export interface TopicbarMoreMenuContentProps {
|
||||
sessionHasContent: boolean;
|
||||
getSessionMarkdown: () => string | Promise<string>;
|
||||
exportSession: (format: "markdown" | "json" | "pdf" | "image") => void;
|
||||
openChangedDock: () => void;
|
||||
toggleTerminal: () => void;
|
||||
prefetchTerminal?: () => void;
|
||||
openSessionSummary: () => void;
|
||||
tasksOpen: boolean;
|
||||
copied: boolean;
|
||||
initialFocus: "first" | "last";
|
||||
onClose: (restoreFocus: boolean) => void;
|
||||
onCopied: () => void;
|
||||
}
|
||||
|
||||
function enabledItems(menu: HTMLElement): HTMLButtonElement[] {
|
||||
return Array.from(menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'))
|
||||
.filter((item) => !item.disabled && item.closest('[role="menu"]') === menu);
|
||||
}
|
||||
|
||||
function moveMenuFocus(event: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const items = enabledItems(event.currentTarget);
|
||||
if (items.length === 0) return;
|
||||
const current = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const next = event.key === "Home" ? 0
|
||||
: event.key === "End" ? items.length - 1
|
||||
: event.key === "ArrowDown" ? (current + 1 + items.length) % items.length
|
||||
: (current - 1 + items.length) % items.length;
|
||||
items[next]?.focus();
|
||||
}
|
||||
|
||||
export function TopicbarMoreMenuContent({
|
||||
sessionHasContent,
|
||||
getSessionMarkdown,
|
||||
exportSession,
|
||||
openChangedDock,
|
||||
toggleTerminal,
|
||||
prefetchTerminal,
|
||||
openSessionSummary,
|
||||
tasksOpen,
|
||||
copied,
|
||||
initialFocus,
|
||||
onClose,
|
||||
onCopied,
|
||||
}: TopicbarMoreMenuContentProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const exportTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const exportMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const items = menuRef.current ? enabledItems(menuRef.current) : [];
|
||||
(initialFocus === "last" ? items[items.length - 1] : items[0])?.focus();
|
||||
}, [initialFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!exportOpen || !exportMenuRef.current) return;
|
||||
enabledItems(exportMenuRef.current)[0]?.focus();
|
||||
}, [exportOpen]);
|
||||
|
||||
const closeAndRun = (action: () => void) => {
|
||||
onClose(true);
|
||||
action();
|
||||
};
|
||||
|
||||
const copySession = async () => {
|
||||
try {
|
||||
await writeClipboardText(await getSessionMarkdown());
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
onCopied();
|
||||
};
|
||||
|
||||
const onMainMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose(true);
|
||||
return;
|
||||
}
|
||||
moveMenuFocus(event);
|
||||
};
|
||||
|
||||
const onExportMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft" || event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setExportOpen(false);
|
||||
exportTriggerRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
moveMenuFocus(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="topicbar__more-menu" role="menu" aria-label={t("topicBar.more")} onKeyDown={onMainMenuKeyDown}>
|
||||
<button tabIndex={-1} className="topicbar__menu-item" type="button" role="menuitem" onClick={() => closeAndRun(() => { void copySession(); })}>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
<span>{copied ? t("msg.copied") : t("topicBar.copyAll")}</span>
|
||||
</button>
|
||||
<div className={`topicbar__more-export${exportOpen ? " topicbar__more-export--open" : ""}`}>
|
||||
<button
|
||||
ref={exportTriggerRef}
|
||||
tabIndex={-1}
|
||||
className="topicbar__menu-item"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={exportOpen}
|
||||
disabled={!sessionHasContent}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "ArrowRight") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setExportOpen(true);
|
||||
}}
|
||||
onClick={() => setExportOpen((open) => !open)}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span>{t("topicBar.export")}</span>
|
||||
</button>
|
||||
{exportOpen && (
|
||||
<div ref={exportMenuRef} className="topicbar__more-export-menu" role="menu" aria-label={t("topicBar.export")} onKeyDown={onExportMenuKeyDown}>
|
||||
<button tabIndex={-1} type="button" role="menuitem" onClick={() => closeAndRun(() => exportSession("markdown"))}>
|
||||
<FileText size={13} />
|
||||
<span>{t("topicBar.exportMarkdown")}</span>
|
||||
</button>
|
||||
<button tabIndex={-1} type="button" role="menuitem" onClick={() => closeAndRun(() => exportSession("json"))}>
|
||||
<FileJson size={13} />
|
||||
<span>{t("topicBar.exportJson")}</span>
|
||||
</button>
|
||||
<button tabIndex={-1} type="button" role="menuitem" onClick={() => closeAndRun(() => exportSession("pdf"))}>
|
||||
<FileDown size={13} />
|
||||
<span>{t("topicBar.exportPdf")}</span>
|
||||
</button>
|
||||
<button tabIndex={-1} type="button" role="menuitem" onClick={() => closeAndRun(() => exportSession("image"))}>
|
||||
<FileImage size={13} />
|
||||
<span>{t("topicBar.exportImage")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button tabIndex={-1} className="topicbar__menu-item" type="button" role="menuitem" onClick={() => closeAndRun(openChangedDock)}>
|
||||
<GitBranch size={14} />
|
||||
<span>{t("workspace.changedTab")}</span>
|
||||
</button>
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className="topicbar__menu-item"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onPointerEnter={prefetchTerminal}
|
||||
onFocus={prefetchTerminal}
|
||||
onClick={() => closeAndRun(toggleTerminal)}
|
||||
>
|
||||
<TerminalSquare size={14} />
|
||||
<span>{t("rightDock.terminal")}</span>
|
||||
</button>
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className={`topicbar__menu-item${tasksOpen ? " topicbar__menu-item--active" : ""}`}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-pressed={tasksOpen}
|
||||
onClick={() => closeAndRun(openSessionSummary)}
|
||||
>
|
||||
<Activity size={14} />
|
||||
<span>{t("summary.session")}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 375 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user