mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 08:04:50 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76f8d6b6f9 | ||
|
|
2dfe5e75dc | ||
|
|
d6a743a118 | ||
|
|
5190740b0f | ||
|
|
a88f117a66 | ||
|
|
6589c5679c | ||
|
|
297463f9d4 | ||
|
|
1b725b0c56 | ||
|
|
e2986d4d59 | ||
|
|
c28511e6af | ||
|
|
6a78ee7c6b | ||
|
|
8f43c251b2 | ||
|
|
b41b4de9df | ||
|
|
f96c4c0867 | ||
|
|
56c1793bb4 | ||
|
|
22787f6804 | ||
|
|
adc0292c67 | ||
|
|
37e9547d38 | ||
|
|
4206bb5ad9 | ||
|
|
d42cfd1942 | ||
|
|
de3618b275 | ||
|
|
3da97d401e | ||
|
|
3acb3c5b0d | ||
|
|
b58e3e3539 | ||
|
|
15abb0394b | ||
|
|
c4fbdd1d07 | ||
|
|
434bf27500 | ||
|
|
0b749791b3 | ||
|
|
511d7ff741 | ||
|
|
4bf88ad6bc | ||
|
|
0cc808a884 | ||
|
|
49f24d1970 | ||
|
|
ca686b17e0 | ||
|
|
345a71dc71 | ||
|
|
ebdd48354c | ||
|
|
c7bc2f3e19 | ||
|
|
9aaf8d381a | ||
|
|
42a9de71de | ||
|
|
7b82fc3bc8 | ||
|
|
fd8d85ed65 | ||
|
|
64fb71cc3f | ||
|
|
f525db811e | ||
|
|
730454e7b6 | ||
|
|
fa64f632d6 | ||
|
|
6e8459c0ad | ||
|
|
1ac7817ff3 | ||
|
|
940aa4a8a6 | ||
|
|
a59a27915e | ||
|
|
c69a9df7e7 | ||
|
|
cb066bc22f | ||
|
|
ddff911ace | ||
|
|
8fc6882c22 | ||
|
|
f53edb599a |
No files matched your search
@@ -145,14 +145,15 @@ jobs:
|
||||
# internal/agent 82s, 92s, 144s and 180s — a 2.2x spread from runner
|
||||
# variance alone, with four heavy packages sharing four vCPUs. At 3m the
|
||||
# step failed on slow runners without a code change; a hang still trips
|
||||
# the 15 minute job budget above.
|
||||
# the 15 minute job budget above. internal/control runs in its own
|
||||
# Windows job below so its durable-session I/O does not compete here.
|
||||
- name: test (Windows smoke)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -p 4 -timeout=8m ./internal/agent/... ./internal/appidentity/... ./internal/boot/... ./internal/checkpoint/... ./internal/cli/... ./internal/control/... ./internal/desktoplauncher/... ./internal/filelock/... ./internal/fileutil/... ./internal/hook/... ./internal/instruction/... ./internal/mcplaunch/... ./internal/notify/... ./internal/proc/... ./internal/remote/... ./internal/repair/... ./internal/sandbox/... ./internal/sysproxy/... ./internal/workspacelease/... ./cmd/...
|
||||
run: go test -p 4 -timeout=8m ./internal/agent/... ./internal/appidentity/... ./internal/boot/... ./internal/checkpoint/... ./internal/cli/... ./internal/desktoplauncher/... ./internal/filelock/... ./internal/fileutil/... ./internal/hook/... ./internal/instruction/... ./internal/mcplaunch/... ./internal/notify/... ./internal/proc/... ./internal/remote/... ./internal/repair/... ./internal/sandbox/... ./internal/sysproxy/... ./internal/workspacelease/... ./cmd/...
|
||||
|
||||
# The general Windows PR smoke list intentionally omits internal/tool.
|
||||
# Keep the session-temp portability contract covered without widening the
|
||||
@@ -195,6 +196,30 @@ jobs:
|
||||
shell: powershell
|
||||
run: .\scripts\test-scoop-desktop-launch.ps1
|
||||
|
||||
windows-control:
|
||||
needs: changes
|
||||
if: always()
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.code != 'false' }}
|
||||
steps:
|
||||
- if: env.RUN_STEPS == 'true'
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- if: env.RUN_STEPS == 'true'
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: test
|
||||
if: env.RUN_STEPS == 'true'
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -p 1 -timeout=8m ./internal/control/...
|
||||
|
||||
race:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
|
||||
@@ -528,9 +553,14 @@ jobs:
|
||||
|
||||
- name: test (Windows desktop and update helper)
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
go test ./...
|
||||
(cd third_party/go-webview2 && go test ./pkg/edge)
|
||||
run: go test ./...
|
||||
|
||||
# A cold nested-module cache can spend several minutes compiling the
|
||||
# real WebView2 cookie integration test. Keep its budget independent of
|
||||
# the already-complete desktop suite and retain a Go timeout stack dump.
|
||||
- name: test (vendored WebView2 edge)
|
||||
timeout-minutes: 10
|
||||
run: (cd third_party/go-webview2 && go test -timeout=8m ./pkg/edge)
|
||||
|
||||
# Installer packaging is packaging validation, not a code gate: run it
|
||||
# on pushes to main-v2 (the release pipeline builds installers again
|
||||
|
||||
+78
-319
@@ -13,11 +13,9 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
goruntime "runtime"
|
||||
@@ -51,6 +49,7 @@ import (
|
||||
"reasonix/internal/notify"
|
||||
"reasonix/internal/plugin"
|
||||
"reasonix/internal/pluginpkg"
|
||||
"reasonix/internal/proc"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/repair"
|
||||
"reasonix/internal/sessioncatalog"
|
||||
@@ -190,6 +189,7 @@ type App struct {
|
||||
// may or may not hold a.mu, so it cannot re-lock). Never write it after
|
||||
// startup.
|
||||
projectTreeChangedHook func()
|
||||
projectTreeRuntime projectTreeRuntimeState
|
||||
|
||||
// singleSurfaceMu serializes open/reuse plus visible-tab pruning for the
|
||||
// one-conversation layout so overlapping navigation cannot remove the tab
|
||||
@@ -362,7 +362,8 @@ type App struct {
|
||||
healthyUpdateTransactionID string
|
||||
// startupReady records that the window reached domReady so LKG config
|
||||
// snapshots and update health are only committed after a real UI boot.
|
||||
startupReady atomic.Bool
|
||||
startupReady atomic.Bool
|
||||
webView2Recovery *webView2RecoveryCoordinator
|
||||
}
|
||||
|
||||
type skillRootsCache struct {
|
||||
@@ -371,121 +372,6 @@ type skillRootsCache struct {
|
||||
roots []SkillRootView
|
||||
}
|
||||
|
||||
// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.
|
||||
type mediaTokenEntry struct {
|
||||
absPath string
|
||||
filename string
|
||||
mime string
|
||||
kind string
|
||||
size int64
|
||||
modTime time.Time
|
||||
createdAt time.Time
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// mediaTokenStore manages temporary tokens that grant access to workspace files
|
||||
// through the AssetServer middleware. Tokens expire after a fixed TTL and are
|
||||
// capped at a maximum count; creating a new token evicts the oldest entry when
|
||||
// the store is full.
|
||||
type mediaTokenStore struct {
|
||||
mu sync.Mutex
|
||||
byTok map[string]*mediaTokenEntry
|
||||
order []string // oldest first
|
||||
maxN int
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
const mediaTokenMax = 256
|
||||
|
||||
func newMediaTokenStore() *mediaTokenStore {
|
||||
return &mediaTokenStore{
|
||||
byTok: map[string]*mediaTokenEntry{},
|
||||
maxN: mediaTokenMax,
|
||||
ttl: 10 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mediaTokenStore) cleanupLocked() {
|
||||
now := time.Now()
|
||||
for len(s.order) > 0 {
|
||||
tok := s.order[0]
|
||||
e := s.byTok[tok]
|
||||
if e == nil {
|
||||
s.order = s.order[1:]
|
||||
continue
|
||||
}
|
||||
if !now.Before(e.expiresAt) {
|
||||
delete(s.byTok, tok)
|
||||
s.order = s.order[1:]
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
for len(s.order) > s.maxN {
|
||||
oldest := s.order[0]
|
||||
delete(s.byTok, oldest)
|
||||
s.order = s.order[1:]
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.cleanupLocked()
|
||||
|
||||
tok := make([]byte, 16)
|
||||
if _, err := rand.Read(tok); err != nil {
|
||||
panic("crypto/rand.Read failed: " + err.Error())
|
||||
}
|
||||
token := hex.EncodeToString(tok)
|
||||
|
||||
now := time.Now()
|
||||
s.byTok[token] = &mediaTokenEntry{
|
||||
absPath: absPath,
|
||||
filename: filename,
|
||||
mime: mime,
|
||||
kind: kind,
|
||||
size: size,
|
||||
modTime: modTime,
|
||||
createdAt: now,
|
||||
expiresAt: now.Add(s.ttl),
|
||||
}
|
||||
s.order = append(s.order, token)
|
||||
|
||||
// Trim oldest if the new token pushed us over the limit.
|
||||
for len(s.order) > s.maxN {
|
||||
oldest := s.order[0]
|
||||
delete(s.byTok, oldest)
|
||||
s.order = s.order[1:]
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
func (s *mediaTokenStore) get(token string) *mediaTokenEntry {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e := s.byTok[token]
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(e.expiresAt) {
|
||||
delete(s.byTok, token)
|
||||
return nil
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func (a *App) ensureMediaTokenStore() *mediaTokenStore {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.mediaTokens == nil {
|
||||
a.mediaTokens = newMediaTokenStore()
|
||||
}
|
||||
return a.mediaTokens
|
||||
}
|
||||
|
||||
// jsProfilingMiddleware opts every asset response into the JS Self-Profiling
|
||||
// document policy so the frontend performance monitor can attach sampled stacks
|
||||
// to long-task reports. Chromium WebViews (WebView2) honor it; WebKit ignores
|
||||
@@ -499,54 +385,6 @@ func (a *App) jsProfilingMiddleware() func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// workspaceMediaMiddleware returns an HTTP middleware that intercepts
|
||||
// /__reasonix_workspace_media/{token}/{filename} requests and serves the
|
||||
// corresponding workspace file. All other paths pass through to the Wails
|
||||
// default asset handler unchanged.
|
||||
func (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
prefix := "/__reasonix_workspace_media/"
|
||||
if !strings.HasPrefix(r.URL.Path, prefix) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
rest := strings.TrimPrefix(r.URL.Path, prefix)
|
||||
parts := strings.SplitN(rest, "/", 2)
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
token := parts[0]
|
||||
|
||||
entry := a.ensureMediaTokenStore().get(token)
|
||||
if entry == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Open(entry.absPath)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w.Header().Set("Content-Type", entry.mime)
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": entry.filename}))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Cache-Control", "private, max-age=600")
|
||||
http.ServeContent(w, r, entry.filename, entry.modTime, f)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NewApp constructs the bound object. Tabs are restored in startup from the
|
||||
// last session's desktop-tabs.json.
|
||||
func NewApp() *App {
|
||||
@@ -561,6 +399,7 @@ func NewApp() *App {
|
||||
remoteWindows: newRemoteWindowRegistry(),
|
||||
remoteWindowOwnerID: newRemoteWindowOwnerID(),
|
||||
}
|
||||
a.webView2Recovery = newWebView2RecoveryCoordinator(a)
|
||||
a.workspaceHub = newWorkspaceChangeHub(a)
|
||||
a.terminals = newTerminalManager(a)
|
||||
a.botBridge = a.newBotBridge()
|
||||
@@ -591,6 +430,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
// OnStartup before its DBus single-instance handoff.
|
||||
initializeLifecycleDiagnostics(a)
|
||||
a.startWindowsWebView2StartupFallback(ctx)
|
||||
a.webView2Recovery.startGuidance(ctx)
|
||||
a.lifecycle.tracker.markAsync("ready")
|
||||
if a.remoteWindowTicket != "" {
|
||||
// Remote web window child: no local tabs, tray, heartbeat, providers,
|
||||
@@ -1016,6 +856,7 @@ func (a *App) domReady(_ context.Context) {
|
||||
a.domReadyRemoteWindow()
|
||||
return
|
||||
}
|
||||
a.webView2Recovery.reportReady()
|
||||
|
||||
state, ok := loadWindowState()
|
||||
if ok {
|
||||
@@ -1073,6 +914,16 @@ func (a *App) domReady(_ context.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ReportDesktopWebViewReady is the content-process heartbeat. OnDomReady proves
|
||||
// native navigation completed; this bound call additionally proves that React
|
||||
// and the Wails bridge are responsive after a renderer reload.
|
||||
func (a *App) ReportDesktopWebViewReady() {
|
||||
if a == nil || a.webView2Recovery == nil {
|
||||
return
|
||||
}
|
||||
a.webView2Recovery.reportReady()
|
||||
}
|
||||
|
||||
func (a *App) commitPendingUpdateHealth() error {
|
||||
if a == nil || strings.TrimSpace(a.healthyUpdateCreatedAt) == "" ||
|
||||
strings.TrimSpace(a.healthyUpdateTransactionID) == "" {
|
||||
@@ -1111,90 +962,6 @@ func (a *App) SubmitToTab(tabID, input string) error {
|
||||
return a.submitToTab(tabID, input, false)
|
||||
}
|
||||
|
||||
// tabTurnAdmission owns both locks acquired while a foreground turn starts.
|
||||
// Call finish after invoking the controller; callers should also defer abort so
|
||||
// every early-return and recovered-panic path releases the admission exactly
|
||||
// once.
|
||||
type tabTurnAdmission struct {
|
||||
app *App
|
||||
tab *WorkspaceTab
|
||||
released bool
|
||||
}
|
||||
|
||||
func (admission *tabTurnAdmission) finish(ctrl control.SessionAPI) bool {
|
||||
if admission == nil || admission.released {
|
||||
return false
|
||||
}
|
||||
admission.released = true
|
||||
tab := admission.tab
|
||||
if tab != nil {
|
||||
// Release even if a controller implementation panics while reporting its
|
||||
// status. The caller's deferred abort sees released=true and cannot be the
|
||||
// fallback once finish has taken ownership of the release.
|
||||
defer admission.app.runtimeAdmissionMu.RUnlock()
|
||||
defer tab.turnStartMu.Unlock()
|
||||
}
|
||||
started := ctrl != nil && ctrl.RuntimeStatus().Running
|
||||
if !started && tab != nil && tab.sink != nil {
|
||||
tab.sink.cancelTurnStart()
|
||||
}
|
||||
return started
|
||||
}
|
||||
|
||||
func (admission *tabTurnAdmission) abort() {
|
||||
admission.finish(nil)
|
||||
}
|
||||
|
||||
// beginTabTurn locks the tab's foreground-turn admission gate and reserves the
|
||||
// event sink until TurnDone has completed all of its fan-out.
|
||||
func (a *App) beginTabTurn(tabID string, reclaim bool, submissionID ...string) (*tabTurnAdmission, control.SessionAPI, error) {
|
||||
tab, ctrl := a.tabAndCtrlByID(tabID)
|
||||
if a.tabIsReadOnly(tab) {
|
||||
return nil, nil, readOnlyChannelErr()
|
||||
}
|
||||
if err := a.workspaceRuntimeAdmissionErr(tab, ctrl); err != nil {
|
||||
return nil, nil, a.workspaceNotReadyErr(tab)
|
||||
}
|
||||
// Rebuild stale workspace bindings before admission so shutdown never waits
|
||||
// behind filesystem or plugin I/O. The later re-check protects publication.
|
||||
if err := a.ensureTabControllerWorkspace(tab); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Runtime work-admission barrier: held (shared) from here until the turn is
|
||||
// observably running and the returned admission token releases it, so an MCP
|
||||
// MCP authorization or plugin uninstall holding the write side either waits out
|
||||
// this admission or sees its work in the gated re-check. Never acquire
|
||||
// runtimeRebuildMu while this read lock is held.
|
||||
a.runtimeAdmissionMu.RLock()
|
||||
abort := func() {
|
||||
tab.turnStartMu.Unlock()
|
||||
a.runtimeAdmissionMu.RUnlock()
|
||||
}
|
||||
tab.turnStartMu.Lock()
|
||||
if a.tabIsReadOnly(tab) {
|
||||
abort()
|
||||
return nil, nil, readOnlyChannelErr()
|
||||
}
|
||||
if reclaim && a.botBridge != nil {
|
||||
a.botBridge.reclaimFromDesktop(tab.ID)
|
||||
}
|
||||
ctrl = a.controllerForTab(tab)
|
||||
if err := a.workspaceRuntimeAdmissionErr(tab, ctrl); err != nil {
|
||||
abort()
|
||||
return nil, nil, err
|
||||
}
|
||||
ctrl = a.controllerForTab(tab)
|
||||
if err := a.workspaceRuntimeAdmissionErr(tab, ctrl); err != nil {
|
||||
abort()
|
||||
return nil, nil, err
|
||||
}
|
||||
if ctrl.RuntimeStatus().Running || (tab.sink != nil && !tab.sink.tryBeginTurn(submissionID...)) {
|
||||
abort()
|
||||
return nil, nil, control.ErrTurnRunning
|
||||
}
|
||||
return &tabTurnAdmission{app: a, tab: tab}, ctrl, nil
|
||||
}
|
||||
|
||||
// submitToTab is the shared submit body. fromBridge marks submissions driven
|
||||
// by the IM takeover bridge; local (frontend) submissions on a taken-over tab
|
||||
// reclaim remote control first — typing locally is the grab-back gesture.
|
||||
@@ -1796,6 +1563,17 @@ func (a *App) ReplayPendingPrompts() {
|
||||
}
|
||||
}
|
||||
|
||||
// ReplayPendingPromptsForTab re-emits only the prompt owned by tabID. Tab
|
||||
// switches use this scoped form so a background session's ask/approval cannot
|
||||
// depend on whichever tab happens to be backend-active when the replay RPC
|
||||
// arrives. ReplayPendingPrompts remains bound for reconnect compatibility.
|
||||
func (a *App) ReplayPendingPromptsForTab(tabID string) {
|
||||
ctrl := a.ctrlByTabID(tabID)
|
||||
if ctrl != nil {
|
||||
ctrl.ReplayPendingPrompts()
|
||||
}
|
||||
}
|
||||
|
||||
// SetPlanMode toggles the plan-first workflow while preserving the current
|
||||
// tool-approval posture and sandbox settings.
|
||||
func (a *App) SetPlanMode(on bool) {
|
||||
@@ -3761,25 +3539,7 @@ func (a *App) ResumeSessionPage(path string, limit int) (HistoryPage, error) {
|
||||
}
|
||||
|
||||
func (a *App) ResumeSessionPageForTab(tabID, path string, limit int) (HistoryPage, error) {
|
||||
tab, ctrl := a.tabAndCtrlByID(tabID)
|
||||
if tab == nil || ctrl == nil {
|
||||
return HistoryPage{}, fmt.Errorf("tab is not ready")
|
||||
}
|
||||
sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
|
||||
if err != nil {
|
||||
return HistoryPage{}, err
|
||||
}
|
||||
loaded, err := loadResumableSession(sessionPath)
|
||||
if err != nil {
|
||||
return HistoryPage{}, err
|
||||
}
|
||||
if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {
|
||||
if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
|
||||
return HistoryPage{}, err
|
||||
}
|
||||
}
|
||||
a.setTabReadOnly(tab.ID, false)
|
||||
return a.HistoryPageForTab(tab.ID, 0, limit), nil
|
||||
return a.resumeSessionPageForTab(tabID, path, limit)
|
||||
}
|
||||
|
||||
// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session
|
||||
@@ -3790,8 +3550,8 @@ func (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error)
|
||||
if tab == nil || ctrl == nil {
|
||||
return []HistoryMessage{}, fmt.Errorf("tab is not ready")
|
||||
}
|
||||
if canonical := a.resolveCanonicalSessionPath(path); canonical != "" {
|
||||
path = canonical
|
||||
if continued := a.continuePathForOpen(path); continued != "" {
|
||||
path = continued
|
||||
}
|
||||
sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
|
||||
if err != nil {
|
||||
@@ -4245,11 +4005,11 @@ func (a *App) reattachDetachedSessionRuntimeForRebind(
|
||||
applyRuntimeTab(tab, detached, sessionPath, a.ctx, a)
|
||||
a.saveTabsLocked()
|
||||
attachedCtrl := tab.Ctrl
|
||||
attachedSink := tab.sink
|
||||
attachedEpoch := a.runtimeEpochForTabLocked(tab)
|
||||
a.mu.Unlock()
|
||||
|
||||
if attachedCtrl != nil {
|
||||
attachedCtrl.ReplayPendingPrompts()
|
||||
}
|
||||
a.replayPendingPromptsAfterRuntimeAttach(tab.ID, attachedSink, attachedCtrl, attachedEpoch)
|
||||
return oldCtrl, oldSink, oldLease, oldHostKey, true
|
||||
}
|
||||
|
||||
@@ -5274,13 +5034,15 @@ type HistoryMessage struct {
|
||||
ToolResultError string `json:"toolResultError,omitempty"`
|
||||
// Execution is local shell metadata restored onto ToolCards after history
|
||||
// reload. Omitted when absent so older frontends ignore it safely.
|
||||
Execution *provider.ToolExecution `json:"execution,omitempty"`
|
||||
Pending bool `json:"pending,omitempty"`
|
||||
Trigger string `json:"trigger,omitempty"`
|
||||
Messages int `json:"messages,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Archive string `json:"archive,omitempty"`
|
||||
DecisionReceipt *provider.DecisionReceipt `json:"decisionReceipt,omitempty"`
|
||||
Execution *provider.ToolExecution `json:"execution,omitempty"`
|
||||
Pending bool `json:"pending,omitempty"`
|
||||
Trigger string `json:"trigger,omitempty"`
|
||||
Messages int `json:"messages,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Archive string `json:"archive,omitempty"`
|
||||
DecisionReceipt *provider.DecisionReceipt `json:"decisionReceipt,omitempty"`
|
||||
Readiness *event.FinalReadiness `json:"readiness,omitempty"`
|
||||
ServerSearch []provider.ServerSearchCall `json:"serverSearch,omitempty"`
|
||||
}
|
||||
|
||||
type HistoryToolCall struct {
|
||||
@@ -5679,15 +5441,8 @@ func (state *historyMessageConvertState) convertHistoryMessage(
|
||||
DecisionReceipt: cloneDecisionReceipt(m.DecisionReceipt),
|
||||
})
|
||||
}
|
||||
if m.LocalOnly {
|
||||
if steerText, isSteer := agent.SteerText(agent.UserMessageText(m)); isSteer {
|
||||
return append(out, HistoryMessage{
|
||||
Role: "notice",
|
||||
Content: agent.UnappliedSteerNotice(steerText),
|
||||
Code: event.NoticeCodeUnappliedSteer,
|
||||
Level: "warn",
|
||||
})
|
||||
}
|
||||
if rows, handled := historyLocalOnlyRows(m); handled {
|
||||
return append(out, rows...)
|
||||
}
|
||||
if state.suppressCanonicalTurn {
|
||||
if m.Role != provider.RoleUser || !agent.IsUserAuthoredTurn(agent.UserMessageText(m)) {
|
||||
@@ -5704,8 +5459,8 @@ func (state *historyMessageConvertState) convertHistoryMessage(
|
||||
// regular user bubble or being filtered as synthetic (#4044).
|
||||
// Check against the raw m.Content: resolveUserContent applies
|
||||
// StripComposePrefixes which trims trailing whitespace.
|
||||
if steerText, isSteer := agent.SteerText(agent.UserMessageText(m)); isSteer {
|
||||
return append(out, HistoryMessage{Role: "notice", Content: "↪ " + steerText})
|
||||
if rows, handled := historySteerRows(agent.UserMessageText(m), false); handled {
|
||||
return append(out, rows...)
|
||||
}
|
||||
content = historyUserDisplayContent(m, resolveUserContent)
|
||||
if control.IsSyntheticUserMessage(content) {
|
||||
@@ -5741,6 +5496,7 @@ func (state *historyMessageConvertState) convertHistoryMessage(
|
||||
hm.SubmitText = replay
|
||||
}
|
||||
}
|
||||
hm.ServerSearch = historyServerSearch(m.ServerSearch)
|
||||
if (m.Role == provider.RoleAssistant || m.LocalOnly) && len(m.ToolCalls) > 0 {
|
||||
hm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))
|
||||
for i, tc := range m.ToolCalls {
|
||||
@@ -6721,6 +6477,8 @@ type Meta struct {
|
||||
GoalRuntime *GoalRuntimeView `json:"goalRuntime,omitempty"`
|
||||
// Nil means no authoritative snapshot; non-nil empty means clear the panel.
|
||||
CanonicalTodos *[]evidence.TodoItem `json:"canonicalTodos,omitempty"`
|
||||
// Closed completed todo fingerprints from this session and its lineage.
|
||||
DismissedTodoBatches []string `json:"dismissedTodoBatches,omitempty"`
|
||||
}
|
||||
|
||||
type GoalRuntimeView struct {
|
||||
@@ -6820,30 +6578,31 @@ func (a *App) MetaForTab(tabID string) Meta {
|
||||
sessionDigest = branchMeta.ContentDigest
|
||||
}
|
||||
return Meta{
|
||||
Label: snap.label,
|
||||
Ready: runtimeView.Phase == sessionRuntimeReady && snap.ctrl != nil,
|
||||
Runtime: runtimeView,
|
||||
StartupErr: snap.startupErr,
|
||||
EventChannel: eventChannel,
|
||||
SessionPath: sessionPath,
|
||||
SessionRevision: sessionRevision,
|
||||
SessionDigest: sessionDigest,
|
||||
Cwd: cwd,
|
||||
WorkspaceRoot: cwd,
|
||||
WorkspaceName: tabWorkspaceNameForScope(snap.scope, cwd),
|
||||
WorkspacePath: cwd,
|
||||
GitBranch: extras.gitBranch,
|
||||
ImageInputEnabled: extras.imageInputEnabled,
|
||||
AutoApproveTools: autoApproveTools,
|
||||
Bypass: autoApproveTools,
|
||||
CollaborationMode: collaborationMode,
|
||||
ToolApprovalMode: toolApprovalMode,
|
||||
AgentPreset: boot.NormalizeAgentPreset(tokenMode),
|
||||
TokenMode: tokenMode,
|
||||
Goal: goal,
|
||||
GoalStatus: goalStatus,
|
||||
GoalRuntime: goalRuntimeViewFromController(snap.ctrl),
|
||||
CanonicalTodos: ctrlTodos(snap.ctrl),
|
||||
Label: snap.label,
|
||||
Ready: runtimeView.Phase == sessionRuntimeReady && snap.ctrl != nil,
|
||||
Runtime: runtimeView,
|
||||
StartupErr: snap.startupErr,
|
||||
EventChannel: eventChannel,
|
||||
SessionPath: sessionPath,
|
||||
SessionRevision: sessionRevision,
|
||||
SessionDigest: sessionDigest,
|
||||
Cwd: cwd,
|
||||
WorkspaceRoot: cwd,
|
||||
WorkspaceName: tabWorkspaceNameForScope(snap.scope, cwd),
|
||||
WorkspacePath: cwd,
|
||||
GitBranch: extras.gitBranch,
|
||||
ImageInputEnabled: extras.imageInputEnabled,
|
||||
AutoApproveTools: autoApproveTools,
|
||||
Bypass: autoApproveTools,
|
||||
CollaborationMode: collaborationMode,
|
||||
ToolApprovalMode: toolApprovalMode,
|
||||
AgentPreset: boot.NormalizeAgentPreset(tokenMode),
|
||||
TokenMode: tokenMode,
|
||||
Goal: goal,
|
||||
GoalStatus: goalStatus,
|
||||
GoalRuntime: goalRuntimeViewFromController(snap.ctrl),
|
||||
CanonicalTodos: ctrlTodos(snap.ctrl),
|
||||
DismissedTodoBatches: a.dismissedTodoBatchesForSession(sessionPath),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10765,7 +10524,7 @@ var revealPath = defaultRevealPath
|
||||
func defaultRevealPath(path string) error {
|
||||
switch goruntime.GOOS {
|
||||
case "darwin":
|
||||
return exec.Command("open", "-R", path).Start()
|
||||
return proc.VisibleCommand("open", "-R", path).Start()
|
||||
case "windows":
|
||||
// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the
|
||||
// launch environment can strip it), so resolve it directly rather than
|
||||
@@ -10778,13 +10537,13 @@ func defaultRevealPath(path string) error {
|
||||
if root != "" {
|
||||
explorer = filepath.Join(root, "explorer.exe")
|
||||
}
|
||||
return exec.Command(explorer, "/select,", path).Start()
|
||||
return proc.VisibleCommand(explorer, "/select,", path).Start()
|
||||
default:
|
||||
dir := path
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
dir = filepath.Dir(path)
|
||||
}
|
||||
return exec.Command("xdg-open", dir).Start()
|
||||
return proc.VisibleCommand("xdg-open", dir).Start()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/build"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWindowsDesktopCommandsUseProcConstructors keeps new Windows-capable
|
||||
// desktop code from bypassing internal/proc. Background commands must be
|
||||
// hidden by default; user-visible launches have to opt in with VisibleCommand.
|
||||
func TestWindowsDesktopCommandsUseProcConstructors(t *testing.T) {
|
||||
roots := []string{".", "../internal"}
|
||||
// These packages either are the constructor implementation itself or launch
|
||||
// a real user-facing terminal/application from the CLI/desktop launcher.
|
||||
visibleOrInfrastructure := []string{
|
||||
"../internal/cli/",
|
||||
"../internal/desktoplauncher/",
|
||||
"../internal/notify/",
|
||||
"../internal/proc/",
|
||||
}
|
||||
windowsBuild := build.Default
|
||||
windowsBuild.GOOS = "windows"
|
||||
windowsBuild.GOARCH = "amd64"
|
||||
|
||||
checkRoot := func(root string) error {
|
||||
return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
cleanPath := filepath.ToSlash(path)
|
||||
for _, prefix := range visibleOrInfrastructure {
|
||||
if strings.HasPrefix(cleanPath, prefix) {
|
||||
if entry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if entry.Name() == "third_party" || entry.Name() == "frontend" || entry.Name() == "build" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
dir, name := filepath.Split(path)
|
||||
matched, err := windowsBuild.MatchFile(filepath.Clean(dir), name)
|
||||
if err != nil || !matched {
|
||||
return err
|
||||
}
|
||||
|
||||
file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
execNames := map[string]bool{}
|
||||
for _, imp := range file.Imports {
|
||||
importPath, err := strconv.Unquote(imp.Path.Value)
|
||||
if err != nil || importPath != "os/exec" {
|
||||
continue
|
||||
}
|
||||
name := "exec"
|
||||
if imp.Name != nil {
|
||||
name = imp.Name.Name
|
||||
}
|
||||
execNames[name] = true
|
||||
}
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
selector, ok := node.(*ast.SelectorExpr)
|
||||
if !ok || (selector.Sel.Name != "Command" && selector.Sel.Name != "CommandContext") {
|
||||
return true
|
||||
}
|
||||
ident, ok := selector.X.(*ast.Ident)
|
||||
if ok && execNames[ident.Name] {
|
||||
t.Errorf("%s bypasses internal/proc with %s.%s", path, ident.Name, selector.Sel.Name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
for _, root := range roots {
|
||||
if err := checkRoot(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// PickBlankProjectParent opens a folder chooser defaulting to the active
|
||||
// project's parent, where sibling projects are normally created.
|
||||
func (a *App) PickBlankProjectParent() (string, error) {
|
||||
if a.ctx == nil {
|
||||
return "", nil
|
||||
}
|
||||
cur, _ := os.Getwd()
|
||||
a.mu.RLock()
|
||||
if tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != "" {
|
||||
cur = filepath.Dir(tab.WorkspaceRoot)
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: "Choose where to create the project",
|
||||
DefaultDirectory: dialogDefaultDirectory(cur),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateBlankProject creates one new directory below parentDir and returns its
|
||||
// absolute path; opening it remains on the existing workspace navigation path.
|
||||
func (a *App) CreateBlankProject(parentDir, projectName string) (string, error) {
|
||||
return createBlankProject(parentDir, projectName)
|
||||
}
|
||||
|
||||
func createBlankProject(parentDir, projectName string) (string, error) {
|
||||
parentDir = strings.TrimSpace(parentDir)
|
||||
if parentDir == "" {
|
||||
return "", fmt.Errorf("parent folder is required")
|
||||
}
|
||||
if abs, err := filepath.Abs(parentDir); err == nil {
|
||||
parentDir = abs
|
||||
} else {
|
||||
return "", fmt.Errorf("resolve parent folder: %w", err)
|
||||
}
|
||||
info, err := os.Stat(parentDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open parent folder: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("parent path is not a directory: %s", parentDir)
|
||||
}
|
||||
|
||||
projectName = strings.TrimSpace(projectName)
|
||||
if projectName == "" {
|
||||
return "", fmt.Errorf("project name is required")
|
||||
}
|
||||
if projectName == "." || projectName == ".." || strings.ContainsAny(projectName, `/\\`) {
|
||||
return "", fmt.Errorf("project name must be a single folder name")
|
||||
}
|
||||
for _, r := range projectName {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return "", fmt.Errorf("project name cannot contain control characters")
|
||||
}
|
||||
}
|
||||
|
||||
target := filepath.Join(parentDir, projectName)
|
||||
if filepath.Dir(target) != filepath.Clean(parentDir) {
|
||||
return "", fmt.Errorf("project name must be a single folder name")
|
||||
}
|
||||
if err := os.Mkdir(target, 0o755); err != nil {
|
||||
if os.IsExist(err) {
|
||||
return "", fmt.Errorf("project folder already exists: %s", target)
|
||||
}
|
||||
return "", fmt.Errorf("create project folder: %w", err)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateBlankProjectCreatesOneChildDirectory(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
want := filepath.Join(parent, "new-project")
|
||||
|
||||
got, err := createBlankProject(parent, " new-project ")
|
||||
if err != nil {
|
||||
t.Fatalf("createBlankProject: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("createBlankProject path = %q, want %q", got, want)
|
||||
}
|
||||
if info, err := os.Stat(got); err != nil {
|
||||
t.Fatalf("stat created project: %v", err)
|
||||
} else if !info.IsDir() {
|
||||
t.Fatalf("created path is not a directory: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBlankProjectRejectsUnsafeNames(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
tests := []string{"", " ", ".", "..", "nested/project", `nested\project`, "bad\nname"}
|
||||
for _, name := range tests {
|
||||
t.Run(strings.ReplaceAll(name, "/", "_"), func(t *testing.T) {
|
||||
if got, err := createBlankProject(parent, name); err == nil {
|
||||
t.Fatalf("createBlankProject(%q) = %q, want error", name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
if entries, err := os.ReadDir(parent); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(entries) != 0 {
|
||||
t.Fatalf("unsafe names created entries: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBlankProjectRejectsInvalidParent(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
file := filepath.Join(parent, "file.txt")
|
||||
if err := os.WriteFile(file, []byte("not a directory"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, invalidParent := range []string{"", filepath.Join(parent, "missing"), file} {
|
||||
if got, err := createBlankProject(invalidParent, "project"); err == nil {
|
||||
t.Fatalf("createBlankProject(%q) = %q, want error", invalidParent, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBlankProjectNeverOverwritesExistingFolder(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
existing := filepath.Join(parent, "existing")
|
||||
if err := os.Mkdir(existing, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker := filepath.Join(existing, "keep.txt")
|
||||
if err := os.WriteFile(marker, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got, err := createBlankProject(parent, "existing"); err == nil || !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("createBlankProject existing = %q, %v; want already exists error", got, err)
|
||||
}
|
||||
if body, err := os.ReadFile(marker); err != nil {
|
||||
t.Fatalf("existing marker was removed: %v", err)
|
||||
} else if string(body) != "keep" {
|
||||
t.Fatalf("existing marker changed to %q", body)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -22,6 +21,7 @@ import (
|
||||
|
||||
"reasonix/desktop/internal/winuninstall"
|
||||
"reasonix/internal/installlayout"
|
||||
"reasonix/internal/proc"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
@@ -522,7 +522,7 @@ func waitForProcessExit(pid uint32, timeout time.Duration) error {
|
||||
}
|
||||
|
||||
func runInstaller(installer, installDir string) error {
|
||||
cmd := exec.Command(installer)
|
||||
cmd := proc.VisibleCommand(installer)
|
||||
// Keep the helper itself hidden, but let the NSIS update-progress window be
|
||||
// visible. /REASONIXSTAGE makes the signed installer extract only; the helper
|
||||
// performs every live replacement through the claimed transaction.
|
||||
@@ -574,7 +574,7 @@ func cleanupOwnedWindowsUpdateDirectory(path string, owner os.FileInfo) error {
|
||||
}
|
||||
|
||||
func startRelaunch(relaunch, installDir string) error {
|
||||
cmd := exec.Command(relaunch)
|
||||
cmd := proc.VisibleCommand(relaunch)
|
||||
cmd.Dir = installDir
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
return cmd.Start()
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
"reasonix/internal/proc"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -374,11 +376,11 @@ func launchPlatformExternalOpener(spec externalOpenerSpec, path string, isDir bo
|
||||
case "shell-open":
|
||||
return openWorkspacePathWithType(path, isDir)
|
||||
case "path":
|
||||
cmd = exec.Command(spec.Target, path)
|
||||
cmd = proc.VisibleCommand(spec.Target, path)
|
||||
case "windows-terminal":
|
||||
// exec.Command uses CreateProcess argument escaping, not cmd.exe, so
|
||||
// workspace paths with shell metacharacters stay a single -d argument.
|
||||
cmd = exec.Command(spec.Target, "-d", launchPath)
|
||||
cmd = proc.VisibleCommand(spec.Target, "-d", launchPath)
|
||||
case "console":
|
||||
// Never route console openers through cmd.exe / start: working-directory
|
||||
// text would be re-parsed as shell syntax (& | ^ etc.). ShellExecute
|
||||
@@ -389,7 +391,7 @@ func launchPlatformExternalOpener(spec externalOpenerSpec, path string, isDir bo
|
||||
}
|
||||
return shellExecuteOpenFile(plan.File, "", plan.Dir)
|
||||
default:
|
||||
cmd = exec.Command(spec.Target, path)
|
||||
cmd = proc.VisibleCommand(spec.Target, path)
|
||||
}
|
||||
return startDetachedExternalOpener(cmd)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
func historyLocalOnlyRows(m provider.Message) ([]HistoryMessage, bool) {
|
||||
if !m.LocalOnly {
|
||||
return nil, false
|
||||
}
|
||||
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.",
|
||||
Readiness: &event.FinalReadiness{
|
||||
Attempts: 1,
|
||||
Missing: append([]string(nil), recovery.Missing...),
|
||||
},
|
||||
}}, true
|
||||
}
|
||||
return historySteerRows(agent.UserMessageText(m), true)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
func TestHistoryMessagesRestorePendingFinalReadinessAction(t *testing.T) {
|
||||
marker := provider.Message{
|
||||
Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName, LocalOnly: true,
|
||||
FinalReadinessRecovery: &provider.FinalReadinessRecovery{Pending: true, Missing: []string{"verification", "review"}},
|
||||
}
|
||||
got := historyMessages([]provider.Message{
|
||||
{Role: provider.RoleUser, Content: "fix it"},
|
||||
{Role: provider.RoleAssistant, Content: "partial result"},
|
||||
marker,
|
||||
}, strings.TrimSpace)
|
||||
if len(got) != 3 || got[2].Role != "notice" || got[2].Code != event.NoticeCodeFinalReadiness || !got[2].Pending || got[2].Readiness == nil {
|
||||
t.Fatalf("pending readiness history row = %+v", got)
|
||||
}
|
||||
if missing := got[2].Readiness.Missing; len(missing) != 2 || missing[0] != "verification" || missing[1] != "review" {
|
||||
t.Fatalf("pending readiness missing = %v", missing)
|
||||
}
|
||||
wired, err := json.Marshal(got[2])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pending readiness: %v", err)
|
||||
}
|
||||
if text := string(wired); !strings.Contains(text, `"readiness":{"attempts":1,"missing":["verification","review"]}`) || strings.Contains(text, `"Attempts"`) {
|
||||
t.Fatalf("pending readiness JSON = %s, want frontend-compatible camel case", text)
|
||||
}
|
||||
|
||||
marker.FinalReadinessRecovery.Pending = false
|
||||
consumed := historyMessages([]provider.Message{{Role: provider.RoleUser, Content: "fix it"}, marker}, strings.TrimSpace)
|
||||
if len(consumed) != 1 {
|
||||
t.Fatalf("consumed readiness marker should be hidden: %+v", consumed)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
// REASONIX_BENCH_LONGTASK_P95_MS (50)
|
||||
// REASONIX_BENCH_LONGTASK_MAX_MS (500)
|
||||
// REASONIX_BENCH_MARKDOWN_PARSE_MAX_MS (3000)
|
||||
// REASONIX_BENCH_IDLE_CPU_PERCENT (3)
|
||||
// REASONIX_BENCH_HEAP_GROWTH_MIB (20)
|
||||
// REASONIX_BENCH_SWITCHES (100)
|
||||
// REASONIX_BENCH_COLD_RUNS (5)
|
||||
@@ -58,6 +59,7 @@ const GATES = {
|
||||
longTaskP95Ms: numEnv("REASONIX_BENCH_LONGTASK_P95_MS", 50),
|
||||
longTaskMaxMs: numEnv("REASONIX_BENCH_LONGTASK_MAX_MS", 500),
|
||||
markdownParseMaxMs: numEnv("REASONIX_BENCH_MARKDOWN_PARSE_MAX_MS", 3000),
|
||||
idleCpuPercent: numEnv("REASONIX_BENCH_IDLE_CPU_PERCENT", 3),
|
||||
heapGrowthMiB: numEnv("REASONIX_BENCH_HEAP_GROWTH_MIB", 20),
|
||||
};
|
||||
const SWITCHES = Math.round(numEnv("REASONIX_BENCH_SWITCHES", 100));
|
||||
@@ -213,6 +215,12 @@ async function forceGcAndHeap(cdp, page) {
|
||||
});
|
||||
}
|
||||
|
||||
async function rendererTaskDuration(cdp) {
|
||||
await cdp.send("Performance.enable").catch(() => {});
|
||||
const response = await cdp.send("Performance.getMetrics");
|
||||
return response.metrics.find((metric) => metric.name === "TaskDuration")?.value ?? 0;
|
||||
}
|
||||
|
||||
const INTERACTIVE_FN = () => {
|
||||
const input = document.querySelector("textarea.composer__input");
|
||||
const inputReady = Boolean(input && !input.disabled);
|
||||
@@ -268,14 +276,14 @@ async function settleMarkdownWorker(page, timeoutMs = 10_000) {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Progressive history blocks intentionally mount in idle slices after parsing.
|
||||
// Stabilize that deferred work only around heap/DOM snapshots; waiting on every
|
||||
// switch would change the interaction workload the benchmark is measuring.
|
||||
// Giant history rows retain only a viewport-driven Markdown tail. Stabilize
|
||||
// worker/React publication around heap/DOM snapshots without forcing cold
|
||||
// blocks into the DOM; scrolling the older sentinel owns those later pages.
|
||||
async function settleMarkdownMounts(page, timeoutMs = 10_000) {
|
||||
try {
|
||||
await page.waitForFunction(() => (
|
||||
[...document.querySelectorAll(".transcript [data-markdown-blocks]")].every((element) => (
|
||||
element.getAttribute("data-markdown-visible-blocks") === element.getAttribute("data-markdown-blocks")
|
||||
Number(element.getAttribute("data-markdown-visible-blocks")) > 0
|
||||
))
|
||||
), undefined, { timeout: timeoutMs, polling: 100 });
|
||||
} catch (error) {
|
||||
@@ -370,6 +378,14 @@ async function main() {
|
||||
}
|
||||
|
||||
await settleMarkdownMounts(page);
|
||||
// Session activation is complete and the worker is drained. Sustained task
|
||||
// time here is background UI churn; the original regression kept
|
||||
// committing Markdown blocks after the visible switch had finished.
|
||||
const idleSampleMs = 3_000;
|
||||
const idleTaskStart = await rendererTaskDuration(cdp);
|
||||
await page.waitForTimeout(idleSampleMs);
|
||||
const idleTaskEnd = await rendererTaskDuration(cdp);
|
||||
const idleMainThreadPercent = ((idleTaskEnd - idleTaskStart) * 1000 / idleSampleMs) * 100;
|
||||
|
||||
// Freeze interaction metrics before the explicit HeapProfiler GC below.
|
||||
// The GC is part of retained-heap measurement, not the user switching
|
||||
@@ -409,6 +425,7 @@ async function main() {
|
||||
domNodes: { baseline: baseline.domNodes, final: final.domNodes },
|
||||
transcriptCache: cache,
|
||||
markdownWorker: worker,
|
||||
idleMainThreadPercent,
|
||||
baselineCache: baselineStats?.transcriptCache,
|
||||
};
|
||||
|
||||
@@ -426,6 +443,7 @@ async function main() {
|
||||
GATES.markdownParseMaxMs,
|
||||
(worker.completed ?? 0) >= 1 && (worker.maxParseMs ?? Infinity) <= GATES.markdownParseMaxMs,
|
||||
);
|
||||
check("B settled renderer task time", idleMainThreadPercent, GATES.idleCpuPercent, idleMainThreadPercent <= GATES.idleCpuPercent, "%");
|
||||
check("B retained heap growth", heapGrowthMiB, GATES.heapGrowthMiB, heapGrowthMiB <= GATES.heapGrowthMiB, "MiB");
|
||||
check(
|
||||
"B cache: body bytes within budget",
|
||||
|
||||
@@ -12,6 +12,10 @@ process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH ||
|
||||
const { chromium } = await import("playwright");
|
||||
const port = Number(process.env.REASONIX_TRANSCRIPT_SCROLL_PORT ?? 4619);
|
||||
const url = `http://127.0.0.1:${port}/?mock=bench&bench=1`;
|
||||
const maxFrameGapMs = Number(process.env.REASONIX_TRANSCRIPT_MAX_FRAME_GAP_MS ?? 250);
|
||||
const p95FrameGapMs = Number(process.env.REASONIX_TRANSCRIPT_P95_FRAME_GAP_MS ?? 80);
|
||||
const maxLongTaskMs = Number(process.env.REASONIX_TRANSCRIPT_MAX_LONG_TASK_MS ?? 250);
|
||||
const totalLongTaskMs = Number(process.env.REASONIX_TRANSCRIPT_TOTAL_LONG_TASK_MS ?? 750);
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
@@ -49,15 +53,194 @@ try {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => !document.querySelector(".startup-splash"), undefined, { timeout: 30_000 });
|
||||
await page.click('.project-tree__topic-main:has-text("bench:tools-38t")');
|
||||
await page.click('.project-tree__topic-main:has-text("bench:small-6t")');
|
||||
await page.waitForFunction(() => document.querySelectorAll(".transcript__row").length > 4, undefined, { timeout: 30_000 });
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("Asynchronously hydrated verification appendix"), undefined, { timeout: 30_000 });
|
||||
await page.evaluate(() => new Promise((resolve) => {
|
||||
let frames = 8;
|
||||
const settle = () => frames-- <= 0 ? resolve() : requestAnimationFrame(settle);
|
||||
requestAnimationFrame(settle);
|
||||
}));
|
||||
const hydrationTranscript = page.locator(".transcript");
|
||||
const hydrationBox = await hydrationTranscript.boundingBox();
|
||||
assert(hydrationBox != null, "async hydration exposes the transcript viewport");
|
||||
const hydrationStart = await hydrationTranscript.evaluate((element) => ({
|
||||
top: element.scrollTop,
|
||||
max: element.scrollHeight - element.clientHeight,
|
||||
}));
|
||||
assert(hydrationStart.max > 0, `async hydration fixture is scrollable (${hydrationStart.max}px)`);
|
||||
await page.mouse.move(hydrationBox.x + hydrationBox.width / 2, hydrationBox.y + hydrationBox.height / 2);
|
||||
await page.mouse.wheel(0, hydrationStart.top < hydrationStart.max / 2 ? 360 : -360);
|
||||
await page.waitForFunction(
|
||||
(startTop) => {
|
||||
const element = document.querySelector(".transcript");
|
||||
return element instanceof HTMLElement
|
||||
&& element.dataset.scrollMode === "manual"
|
||||
&& Math.abs(element.scrollTop - startTop) > 1;
|
||||
},
|
||||
hydrationStart.top,
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const element = document.querySelector(".transcript");
|
||||
if (!(element instanceof HTMLElement)) return;
|
||||
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight * 2.5);
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector(".transcript")?.dataset.scrollMode === "manual",
|
||||
undefined,
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
const hydrationAnchor = await page.evaluate(() => {
|
||||
const element = document.querySelector(".transcript");
|
||||
if (!(element instanceof HTMLElement)) return null;
|
||||
const viewport = element.getBoundingClientRect();
|
||||
const visible = [...element.querySelectorAll(".transcript__row")]
|
||||
.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];
|
||||
return anchor ? { key: anchor.dataset.rowKey, offset: anchor.getBoundingClientRect().top - viewport.top } : null;
|
||||
});
|
||||
assert(hydrationAnchor?.key, "async hydration starts from a stable manual-reading anchor");
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("ASYNC LAYOUT EXPANSION COMPLETE"), undefined, { timeout: 30_000 });
|
||||
await page.waitForFunction((anchor) => {
|
||||
const element = document.querySelector(".transcript");
|
||||
if (!(element instanceof HTMLElement)) return false;
|
||||
const row = [...element.querySelectorAll(".transcript__row")].find((candidate) => candidate.dataset.rowKey === anchor.key);
|
||||
return Boolean(row && Math.abs((row.getBoundingClientRect().top - element.getBoundingClientRect().top) - anchor.offset) <= 8);
|
||||
}, hydrationAnchor, { timeout: 5_000 });
|
||||
const hydratedState = await page.evaluate((anchor) => {
|
||||
const element = document.querySelector(".transcript");
|
||||
if (!(element instanceof HTMLElement)) return { occupied: false, anchorOffset: null };
|
||||
const viewport = element.getBoundingClientRect();
|
||||
const rows = [...element.querySelectorAll(".transcript__row")];
|
||||
const anchored = rows.find((row) => row.dataset.rowKey === anchor.key);
|
||||
return { occupied: rows.some((row) => {
|
||||
const rect = row.getBoundingClientRect();
|
||||
return rect.bottom > viewport.top && rect.top < viewport.bottom;
|
||||
}), anchorOffset: anchored ? anchored.getBoundingClientRect().top - viewport.top : null };
|
||||
}, hydrationAnchor);
|
||||
assert(hydratedState.occupied, "async full-content hydration keeps a transcript row in the viewport");
|
||||
assert(
|
||||
hydratedState.anchorOffset != null && Math.abs(hydratedState.anchorOffset - hydrationAnchor.offset) <= 8,
|
||||
`async hydration preserves the manual-reading anchor (${hydrationAnchor.offset} → ${hydratedState.anchorOffset})`,
|
||||
);
|
||||
await page.evaluate(() => new Promise((resolve) => {
|
||||
let frames = 12;
|
||||
const settle = () => frames-- <= 0 ? resolve() : requestAnimationFrame(settle);
|
||||
requestAnimationFrame(settle);
|
||||
}));
|
||||
await page.evaluate(() => {
|
||||
const element = document.querySelector(".transcript");
|
||||
if (!(element instanceof HTMLElement)) return;
|
||||
element.scrollTop = element.scrollHeight;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector(".transcript");
|
||||
return element instanceof HTMLElement
|
||||
&& element.dataset.scrollMode === "tail-follow"
|
||||
&& element.scrollHeight - element.scrollTop - element.clientHeight <= 1;
|
||||
});
|
||||
await page.click('.project-tree__topic-main:has-text("bench:tools-38t")');
|
||||
await page.waitForFunction(() => document.querySelector(".project-tree__topic--active .project-tree__topic-label")?.textContent?.includes("bench:tools-38t"));
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("pkg-41/mod.go"), undefined, { timeout: 30_000 });
|
||||
const markdownVisibility = await page.evaluate(() => {
|
||||
const row = document.querySelector(".transcript__row");
|
||||
if (!(row instanceof HTMLElement)) return { inside: null, outside: null };
|
||||
const mount = (parent) => {
|
||||
const host = document.createElement("div");
|
||||
host.className = "md";
|
||||
const probe = document.createElement("p");
|
||||
host.append(probe);
|
||||
parent.append(host);
|
||||
const value = getComputedStyle(probe).contentVisibility;
|
||||
host.remove();
|
||||
return value;
|
||||
};
|
||||
return { inside: mount(row), outside: mount(document.body) };
|
||||
});
|
||||
assert(
|
||||
markdownVisibility.inside === "visible",
|
||||
`mounted transcript markdown stays measurable (${markdownVisibility.inside})`,
|
||||
);
|
||||
assert(
|
||||
markdownVisibility.outside === "auto",
|
||||
`markdown outside the transcript still culls with content-visibility (${markdownVisibility.outside})`,
|
||||
);
|
||||
|
||||
const transcript = page.locator(".transcript");
|
||||
const box = await transcript.boundingBox();
|
||||
assert(box != null, "bench exposes the Virtuoso transcript viewport");
|
||||
assert(await page.locator('[data-virtuoso-scroller="true"]').count() === 1, "Transcript is backed by React Virtuoso");
|
||||
|
||||
// Stay on the tail. Opening the workspace dock must not crop right-aligned
|
||||
// user bubbles — that is a width/padding bug, not the scroll-up overlap.
|
||||
const measureDockCrop = () => page.evaluate(() => {
|
||||
const layout = document.querySelector(".layout");
|
||||
const chat = document.querySelector(".chat-pane");
|
||||
const dock = document.querySelector(".workbench-dock");
|
||||
const scroller = document.querySelector(".transcript");
|
||||
const bubbles = [...document.querySelectorAll(".msg--user .msg__body")];
|
||||
const bubble = bubbles.at(-1);
|
||||
if (!(chat instanceof HTMLElement) || !(bubble instanceof HTMLElement) || !(scroller instanceof HTMLElement)) {
|
||||
return { ok: false };
|
||||
}
|
||||
const chatBox = chat.getBoundingClientRect();
|
||||
const bubbleBox = bubble.getBoundingClientRect();
|
||||
const dockBox = dock instanceof HTMLElement ? dock.getBoundingClientRect() : null;
|
||||
return {
|
||||
ok: true,
|
||||
workspaceOpen: Boolean(layout?.classList.contains("layout--workspace-open")),
|
||||
overflowChatRight: +(bubbleBox.right - chatBox.right).toFixed(2),
|
||||
overflowDock: dockBox ? +(bubbleBox.right - dockBox.left).toFixed(2) : null,
|
||||
fromBottom: +(scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight).toFixed(2),
|
||||
};
|
||||
});
|
||||
const dockOpen = await measureDockCrop();
|
||||
assert(dockOpen.ok, "tail-follow dock check can see the chat and a user bubble");
|
||||
assert(dockOpen.workspaceOpen === true, "bench starts with the workspace dock open");
|
||||
assert(dockOpen.fromBottom <= 1, `dock-open check stays on the tail without scrolling up (${dockOpen.fromBottom})`);
|
||||
assert(dockOpen.overflowChatRight <= 1, `user bubble stays inside the chat column with the dock open (${dockOpen.overflowChatRight})`);
|
||||
assert(
|
||||
dockOpen.overflowDock == null || dockOpen.overflowDock <= 1,
|
||||
`user bubble does not extend into the workspace dock (${dockOpen.overflowDock})`,
|
||||
);
|
||||
|
||||
// Width changes remasure Virtuoso and can leave a few pixels off the
|
||||
// physical bottom. Keep the crop assertions tight; only the post-resize
|
||||
// stick-to-tail check gets this slack (CI saw 7px after collapse).
|
||||
const tailAfterResizePx = 16;
|
||||
const waitNearTailAfterResize = () => page.waitForFunction((limit) => {
|
||||
const scroller = document.querySelector(".transcript");
|
||||
return Boolean(scroller && scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= limit);
|
||||
}, tailAfterResizePx);
|
||||
|
||||
const collapse = page.getByRole("button", { name: /Collapse workspace|收起工作区/ });
|
||||
if (await collapse.count()) {
|
||||
await collapse.click();
|
||||
await page.waitForFunction(() => !document.querySelector(".layout")?.classList.contains("layout--workspace-open"));
|
||||
await waitNearTailAfterResize();
|
||||
const dockClosed = await measureDockCrop();
|
||||
assert(dockClosed.ok && dockClosed.workspaceOpen === false, "workspace toggle collapses the dock");
|
||||
assert(dockClosed.fromBottom <= tailAfterResizePx, `collapsing the dock does not require scrolling up (${dockClosed.fromBottom})`);
|
||||
assert(dockClosed.overflowChatRight <= 1, `user bubble stays inside the chat column with the dock closed (${dockClosed.overflowChatRight})`);
|
||||
const expand = page.getByRole("button", { name: /Expand workspace|展开工作区/ });
|
||||
await expand.click();
|
||||
await page.waitForFunction(() => Boolean(document.querySelector(".layout")?.classList.contains("layout--workspace-open")));
|
||||
await waitNearTailAfterResize();
|
||||
const dockReopen = await measureDockCrop();
|
||||
assert(dockReopen.ok && dockReopen.workspaceOpen === true, "workspace toggle reopens the dock");
|
||||
assert(dockReopen.fromBottom <= tailAfterResizePx, `reopening the dock stays on the tail (${dockReopen.fromBottom})`);
|
||||
assert(dockReopen.overflowChatRight <= 1, `user bubble stays inside the chat column after reopening the dock (${dockReopen.overflowChatRight})`);
|
||||
assert(
|
||||
dockReopen.overflowDock == null || dockReopen.overflowDock <= 1,
|
||||
`user bubble still does not enter the dock after reopen (${dockReopen.overflowDock})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Start away from either edge and record a visible stable row. Growing an
|
||||
// already-mounted row above it reproduces async Markdown/tool hydration.
|
||||
const beforeGrowth = await transcript.evaluate((element) => {
|
||||
@@ -135,11 +318,59 @@ try {
|
||||
assert(afterGrowth.samples.every((sample) => sample.occupied), "dynamic measurement never exposes a blank transcript viewport");
|
||||
|
||||
// Rapid direction changes are the exact user report. Sample every frame and
|
||||
// require that Virtuoso always maintains mounted coverage.
|
||||
for (const delta of [-700, -700, 480, -600, 520, -460]) {
|
||||
// require that Virtuoso always maintains mounted coverage. Keep a real
|
||||
// Chromium long-task budget around 60 events so accidental synchronous
|
||||
// storage/layout work cannot return unnoticed.
|
||||
await page.evaluate(() => {
|
||||
const probe = {
|
||||
active: true,
|
||||
frameGaps: [],
|
||||
lastFrame: null,
|
||||
longTasks: [],
|
||||
observer: null,
|
||||
};
|
||||
if (PerformanceObserver.supportedEntryTypes?.includes("longtask")) {
|
||||
probe.observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) probe.longTasks.push(entry.duration);
|
||||
});
|
||||
probe.observer.observe({ type: "longtask", buffered: false });
|
||||
}
|
||||
const sampleFrame = (now) => {
|
||||
if (!probe.active) return;
|
||||
if (probe.lastFrame != null) probe.frameGaps.push(now - probe.lastFrame);
|
||||
probe.lastFrame = now;
|
||||
requestAnimationFrame(sampleFrame);
|
||||
};
|
||||
window.__reasonixScrollPerfProbe = probe;
|
||||
requestAnimationFrame(sampleFrame);
|
||||
});
|
||||
const rapidDeltas = Array.from({ length: 10 }, () => [-700, -700, 480, -600, 520, -460]).flat();
|
||||
for (const delta of rapidDeltas) {
|
||||
await page.mouse.wheel(0, delta);
|
||||
await page.waitForTimeout(24);
|
||||
await page.waitForTimeout(16);
|
||||
}
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
const perf = await page.evaluate(() => {
|
||||
const probe = window.__reasonixScrollPerfProbe;
|
||||
if (!probe) return null;
|
||||
probe.active = false;
|
||||
probe.observer?.disconnect();
|
||||
const gaps = [...probe.frameGaps].sort((left, right) => left - right);
|
||||
const percentileIndex = Math.max(0, Math.ceil(gaps.length * 0.95) - 1);
|
||||
return {
|
||||
frames: gaps.length,
|
||||
maxFrameGap: gaps.at(-1) ?? 0,
|
||||
p95FrameGap: gaps[percentileIndex] ?? 0,
|
||||
maxLongTask: Math.max(0, ...probe.longTasks),
|
||||
totalLongTask: probe.longTasks.reduce((sum, duration) => sum + duration, 0),
|
||||
longTasks: probe.longTasks.length,
|
||||
};
|
||||
});
|
||||
assert(perf && perf.frames >= 30, `rapid-scroll performance probe samples enough frames (${perf?.frames ?? 0})`);
|
||||
assert(perf.maxFrameGap <= maxFrameGapMs, `rapid-scroll maximum frame gap stays within budget (${perf.maxFrameGap.toFixed(1)}ms <= ${maxFrameGapMs}ms)`);
|
||||
assert(perf.p95FrameGap <= p95FrameGapMs, `rapid-scroll p95 frame gap stays within budget (${perf.p95FrameGap.toFixed(1)}ms <= ${p95FrameGapMs}ms)`);
|
||||
assert(perf.maxLongTask <= maxLongTaskMs, `rapid-scroll longest main-thread task stays within budget (${perf.maxLongTask.toFixed(1)}ms <= ${maxLongTaskMs}ms)`);
|
||||
assert(perf.totalLongTask <= totalLongTaskMs, `rapid-scroll total long-task time stays within budget (${perf.totalLongTask.toFixed(1)}ms <= ${totalLongTaskMs}ms; ${perf.longTasks} tasks)`);
|
||||
const rapid = await transcript.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const visible = [...element.querySelectorAll(".transcript__row")].filter((row) => {
|
||||
@@ -176,46 +407,53 @@ try {
|
||||
scrollHeight: element.scrollHeight,
|
||||
};
|
||||
});
|
||||
assert(nativeThumbProbe && nativeThumbProbe.gutter > 1, `workbench exposes a native scrollbar gutter (${nativeThumbProbe?.gutter ?? 0}px)`);
|
||||
assert(nativeThumbProbe.knownSize > 0, `native scrollbar probe starts from a measured row (${nativeThumbProbe.knownSize}px)`);
|
||||
await page.mouse.move(nativeThumbProbe.x, nativeThumbProbe.y);
|
||||
await page.mouse.down();
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.nativeScrollbarDrag === "true");
|
||||
await page.waitForFunction(
|
||||
(knownSize) => document.querySelector('[data-native-scrollbar-probe="true"]')?.style.height === `${knownSize}px`,
|
||||
nativeThumbProbe.knownSize,
|
||||
);
|
||||
await transcript.evaluate((element) => {
|
||||
const row = element.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
const content = row?.firstElementChild;
|
||||
if (content instanceof HTMLElement) content.style.paddingBottom = `${Number.parseFloat(content.style.paddingBottom || "0") + 900}px`;
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
const duringNativeThumbDrag = await transcript.evaluate((element) => {
|
||||
const row = element.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
return {
|
||||
knownSize: row instanceof HTMLElement ? Number.parseFloat(row.dataset.knownSize || "0") : 0,
|
||||
fixedHeight: row instanceof HTMLElement ? row.style.height : "",
|
||||
rowHeight: row instanceof HTMLElement ? row.getBoundingClientRect().height : 0,
|
||||
listHeight: element.querySelector('[data-testid="virtuoso-item-list"]')?.getBoundingClientRect().height ?? 0,
|
||||
scrollHeight: element.scrollHeight,
|
||||
};
|
||||
});
|
||||
assert(duringNativeThumbDrag.knownSize === nativeThumbProbe.knownSize, `native thumb drag freezes new row measurements (${duringNativeThumbDrag.knownSize}px)`);
|
||||
assert(duringNativeThumbDrag.fixedHeight === `${nativeThumbProbe.knownSize}px`, `native thumb drag fixes mounted row layout (${duringNativeThumbDrag.fixedHeight})`);
|
||||
assert(Math.abs(duringNativeThumbDrag.scrollHeight - nativeThumbProbe.scrollHeight) <= 8, `native thumb drag keeps the physical scroll range stable (${nativeThumbProbe.scrollHeight} → ${duringNativeThumbDrag.scrollHeight}; row ${duringNativeThumbDrag.rowHeight}; list ${duringNativeThumbDrag.listHeight})`);
|
||||
await page.mouse.up();
|
||||
await page.waitForFunction(
|
||||
(knownSize) => {
|
||||
const transcriptElement = document.querySelector(".transcript");
|
||||
const row = document.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
return transcriptElement?.dataset.nativeScrollbarDrag !== "true"
|
||||
&& row instanceof HTMLElement
|
||||
&& Number.parseFloat(row.dataset.knownSize || "0") > knownSize + 800;
|
||||
},
|
||||
nativeThumbProbe.knownSize,
|
||||
);
|
||||
assert(true, "native thumb release resumes real row measurement");
|
||||
if (process.platform === "darwin" && (!nativeThumbProbe || nativeThumbProbe.gutter <= 1)) {
|
||||
// macOS Chromium inherits system overlay scrollbars, so there is no
|
||||
// pointer-addressable native gutter. Linux/Windows CI still exercises the
|
||||
// complete thumb ownership and measurement-freeze path below.
|
||||
process.stdout.write(" SKIP native thumb drag (host uses overlay scrollbars)\n");
|
||||
} else {
|
||||
assert(nativeThumbProbe && nativeThumbProbe.gutter > 1, `workbench exposes a native scrollbar gutter (${nativeThumbProbe?.gutter ?? 0}px)`);
|
||||
assert(nativeThumbProbe.knownSize > 0, `native scrollbar probe starts from a measured row (${nativeThumbProbe.knownSize}px)`);
|
||||
await page.mouse.move(nativeThumbProbe.x, nativeThumbProbe.y);
|
||||
await page.mouse.down();
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.nativeScrollbarDrag === "true");
|
||||
await page.waitForFunction(
|
||||
(knownSize) => document.querySelector('[data-native-scrollbar-probe="true"]')?.style.height === `${knownSize}px`,
|
||||
nativeThumbProbe.knownSize,
|
||||
);
|
||||
await transcript.evaluate((element) => {
|
||||
const row = element.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
const content = row?.firstElementChild;
|
||||
if (content instanceof HTMLElement) content.style.paddingBottom = `${Number.parseFloat(content.style.paddingBottom || "0") + 900}px`;
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
const duringNativeThumbDrag = await transcript.evaluate((element) => {
|
||||
const row = element.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
return {
|
||||
knownSize: row instanceof HTMLElement ? Number.parseFloat(row.dataset.knownSize || "0") : 0,
|
||||
fixedHeight: row instanceof HTMLElement ? row.style.height : "",
|
||||
rowHeight: row instanceof HTMLElement ? row.getBoundingClientRect().height : 0,
|
||||
listHeight: element.querySelector('[data-testid="virtuoso-item-list"]')?.getBoundingClientRect().height ?? 0,
|
||||
scrollHeight: element.scrollHeight,
|
||||
};
|
||||
});
|
||||
assert(duringNativeThumbDrag.knownSize === nativeThumbProbe.knownSize, `native thumb drag freezes new row measurements (${duringNativeThumbDrag.knownSize}px)`);
|
||||
assert(duringNativeThumbDrag.fixedHeight === `${nativeThumbProbe.knownSize}px`, `native thumb drag fixes mounted row layout (${duringNativeThumbDrag.fixedHeight})`);
|
||||
assert(Math.abs(duringNativeThumbDrag.scrollHeight - nativeThumbProbe.scrollHeight) <= 8, `native thumb drag keeps the physical scroll range stable (${nativeThumbProbe.scrollHeight} → ${duringNativeThumbDrag.scrollHeight}; row ${duringNativeThumbDrag.rowHeight}; list ${duringNativeThumbDrag.listHeight})`);
|
||||
await page.mouse.up();
|
||||
await page.waitForFunction(
|
||||
(knownSize) => {
|
||||
const transcriptElement = document.querySelector(".transcript");
|
||||
const row = document.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
return transcriptElement?.dataset.nativeScrollbarDrag !== "true"
|
||||
&& row instanceof HTMLElement
|
||||
&& Number.parseFloat(row.dataset.knownSize || "0") > knownSize + 800;
|
||||
},
|
||||
nativeThumbProbe.knownSize,
|
||||
);
|
||||
assert(true, "native thumb release resumes real row measurement");
|
||||
}
|
||||
|
||||
// Explicit bottom owns the tail. Subsequent async growth must use Virtuoso's
|
||||
// autoscroll API and remain at the physical bottom without Reasonix scrollTop
|
||||
|
||||
@@ -12,12 +12,103 @@ process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH ||
|
||||
const { chromium } = await import("playwright");
|
||||
const port = Number(process.env.REASONIX_TRANSCRIPT_BROWSER_PORT ?? 4618);
|
||||
const url = `http://127.0.0.1:${port}/?mock=bench&bench=1`;
|
||||
const bottomTimeout = Number(process.env.REASONIX_TRANSCRIPT_BOTTOM_TIMEOUT ?? 30_000);
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
process.stdout.write(` PASS ${message}\n`);
|
||||
}
|
||||
|
||||
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);
|
||||
const delta = wheelDelta ?? (preferHighest ? -400 : 400);
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
const points = await page.evaluate((prefer) => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return null;
|
||||
const viewport = transcript.getBoundingClientRect();
|
||||
const candidates = [...transcript.querySelectorAll("[data-transcript-selectable]")]
|
||||
.map((element) => ({
|
||||
element,
|
||||
turn: Number(element.textContent?.match(/\bbench turn (\d+):/)?.[1] ?? -1),
|
||||
rect: element.getBoundingClientRect(),
|
||||
}))
|
||||
.filter(({ turn, rect }) => turn >= 0 && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom)
|
||||
.sort((left, right) => (prefer ? right.turn - left.turn : left.turn - right.turn));
|
||||
const candidate = candidates[0];
|
||||
if (!candidate) return null;
|
||||
const walker = document.createTreeWalker(candidate.element, NodeFilter.SHOW_TEXT);
|
||||
const rects = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (!node.textContent?.trim()) continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
rects.push(...range.getClientRects());
|
||||
}
|
||||
const start = rects.find((rect) => rect.width > 8 && rect.bottom > viewport.top && rect.top < viewport.bottom) ?? candidate.rect;
|
||||
if (!start) return null;
|
||||
const startX = Math.min(start.right - 4, start.left + Math.max(start.width * 0.45, 60));
|
||||
const y = (Math.max(start.top, viewport.top) + Math.min(start.bottom, viewport.bottom)) / 2;
|
||||
return {
|
||||
start: { x: startX, y },
|
||||
activate: { x: Math.min(start.right - 2, startX + 30), y },
|
||||
edge: { x: startX, y: prefer ? viewport.top + 2 : viewport.bottom - 2 },
|
||||
anchorTurn: candidate.turn,
|
||||
};
|
||||
}, preferHighest);
|
||||
if (points) return points;
|
||||
// Wheel, do not assign scrollTop: a pinned tail-follow snaps programmatic
|
||||
// writes back to the native bottom before Virtuoso can mount a user row.
|
||||
await page.mouse.wheel(0, delta);
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
const diag = await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
const viewport = transcript?.getBoundingClientRect();
|
||||
return {
|
||||
rows: document.querySelectorAll(".transcript__row").length,
|
||||
selectables: document.querySelectorAll("[data-transcript-selectable]").length,
|
||||
turnHits: [...document.querySelectorAll("[data-transcript-selectable]")].filter((el) => /\bbench turn \d+:/.test(el.textContent || "")).length,
|
||||
scrollTop: transcript?.scrollTop ?? null,
|
||||
scrollHeight: transcript?.scrollHeight ?? null,
|
||||
clientHeight: transcript?.clientHeight ?? null,
|
||||
mode: transcript?.dataset.scrollMode ?? null,
|
||||
viewport: viewport ? { top: viewport.top, bottom: viewport.bottom } : null,
|
||||
};
|
||||
});
|
||||
throw new Error(`no visible bench-turn selectable (${JSON.stringify(diag)})`);
|
||||
}
|
||||
|
||||
async function findVisibleTurnTarget(page, turnPredicate) {
|
||||
return page.evaluate((predicate) => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return null;
|
||||
const viewport = transcript.getBoundingClientRect();
|
||||
const root = [...transcript.querySelectorAll("[data-transcript-selectable]")].find((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const turn = Number(element.textContent?.match(/\bbench turn (\d+):/)?.[1] ?? -1);
|
||||
return (predicate.min != null ? turn >= predicate.min : turn <= predicate.max)
|
||||
&& rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom;
|
||||
});
|
||||
if (!root) return null;
|
||||
const rect = root.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.min(rect.right - 2, rect.left + Math.max(rect.width * 0.45, 24)),
|
||||
y: (Math.max(rect.top, viewport.top) + Math.min(rect.bottom, viewport.bottom)) / 2,
|
||||
};
|
||||
}, turnPredicate);
|
||||
}
|
||||
|
||||
async function waitForLogicalSelection(page, timeout = 8_000) {
|
||||
return page.waitForFunction(
|
||||
() => document.querySelector(".transcript")?.dataset.scrollMode === "selection",
|
||||
undefined,
|
||||
{ timeout },
|
||||
).then(() => true, () => false);
|
||||
}
|
||||
|
||||
async function waitForServer() {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -42,7 +133,10 @@ const preview = spawn("pnpm", ["exec", "vite", "preview", "--port", String(port)
|
||||
let browser;
|
||||
try {
|
||||
await waitForServer();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Performance.enable");
|
||||
@@ -74,66 +168,36 @@ try {
|
||||
}
|
||||
const jumpBottom = page.locator(".transcript__jump-bottom");
|
||||
if (await jumpBottom.count()) await jumpBottom.click();
|
||||
await page.waitForFunction(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
return transcript && transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight <= 1;
|
||||
}, undefined, { timeout: 30_000 });
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
const visibleSelectable = await page.evaluate(() => {
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return false;
|
||||
const viewport = transcript.getBoundingClientRect();
|
||||
const visible = [...transcript.querySelectorAll("[data-transcript-selectable]")].some((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom;
|
||||
});
|
||||
if (!visible) transcript.scrollTop -= transcript.clientHeight;
|
||||
return visible;
|
||||
return transcript && transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight <= 1;
|
||||
}, undefined, { timeout: bottomTimeout });
|
||||
} catch (error) {
|
||||
const bottomState = await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return null;
|
||||
return {
|
||||
distance: transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight,
|
||||
scrollTop: transcript.scrollTop,
|
||||
scrollHeight: transcript.scrollHeight,
|
||||
clientHeight: transcript.clientHeight,
|
||||
mode: transcript.dataset.scrollMode,
|
||||
rows: transcript.querySelectorAll(".transcript__row").length,
|
||||
padding: getComputedStyle(transcript).padding,
|
||||
viewportRect: transcript.querySelector('[data-viewport-type="element"]')?.getBoundingClientRect().toJSON(),
|
||||
listRect: transcript.querySelector(".transcript__virtual-sizer")?.getBoundingClientRect().toJSON(),
|
||||
lastRowRect: [...transcript.querySelectorAll(".transcript__row")].at(-1)?.getBoundingClientRect().toJSON(),
|
||||
transcriptRect: transcript.getBoundingClientRect().toJSON(),
|
||||
};
|
||||
});
|
||||
if (visibleSelectable) break;
|
||||
await page.waitForTimeout(50);
|
||||
throw new Error(`jump-bottom did not settle at the native tail (${JSON.stringify(bottomState)})`, { cause: error });
|
||||
}
|
||||
|
||||
// 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.
|
||||
const points = await waitForVisibleSelectionStart(page, { preferHighest: true });
|
||||
const baselineRows = await page.locator(".transcript__row").count();
|
||||
const points = await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return null;
|
||||
const viewport = transcript.getBoundingClientRect();
|
||||
const candidates = [...transcript.querySelectorAll("[data-transcript-selectable]")]
|
||||
.map((element) => ({
|
||||
element,
|
||||
turn: Number(element.textContent?.match(/\bbench turn (\d+):/)?.[1] ?? -1),
|
||||
rect: element.getBoundingClientRect(),
|
||||
}))
|
||||
.filter(({ turn, rect }) => turn >= 0 && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom)
|
||||
.sort((left, right) => right.turn - left.turn);
|
||||
const candidate = candidates[0];
|
||||
const textRects = candidate ? (() => {
|
||||
const walker = document.createTreeWalker(candidate.element, NodeFilter.SHOW_TEXT);
|
||||
const rects = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (!node.textContent?.trim()) continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
rects.push(...range.getClientRects());
|
||||
}
|
||||
return rects;
|
||||
})() : [];
|
||||
const start = textRects.find((rect) => rect.width > 8 && rect.bottom > viewport.top && rect.top < viewport.bottom) ?? candidate?.rect;
|
||||
if (!start) return null;
|
||||
// Press mid-text rather than at the row's left edge. A left-edge caret
|
||||
// freezes the anchor at offset 0, so an upward drag legitimately copies
|
||||
// nothing from the anchor turn and the 20-turn count would depend only on
|
||||
// how far the focus lands. Mid-text also exercises the frozen anchor's
|
||||
// real character offset instead of the trivial row boundary.
|
||||
const startX = Math.min(start.right - 4, start.left + Math.max(start.width * 0.45, 60));
|
||||
return {
|
||||
start: { x: startX, y: (Math.max(start.top, viewport.top) + Math.min(start.bottom, viewport.bottom)) / 2 },
|
||||
activate: { x: Math.min(start.right - 2, startX + 30), y: (Math.max(start.top, viewport.top) + Math.min(start.bottom, viewport.bottom)) / 2 },
|
||||
edge: { x: startX, y: viewport.top + 2 },
|
||||
anchorTurn: candidate.turn,
|
||||
};
|
||||
});
|
||||
assert(points != null, "bench transcript exposes a selectable visible message");
|
||||
|
||||
await page.evaluate(() => {
|
||||
@@ -149,7 +213,7 @@ try {
|
||||
mode: document.querySelector(".transcript")?.dataset.scrollMode,
|
||||
target: document.elementFromPoint(x, y)?.outerHTML.slice(0, 300),
|
||||
}), points.start);
|
||||
assert(downState.mode === "native-selecting", `primary pointerdown transfers scroll ownership to native selection (${downState.mode}; ${downState.target})`);
|
||||
assert(downState.mode === "selection", `primary pointerdown transfers scroll ownership to selection (${downState.mode}; ${downState.target})`);
|
||||
await page.mouse.move(points.activate.x, points.activate.y, { steps: 6 });
|
||||
await page.waitForTimeout(50);
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
@@ -272,7 +336,7 @@ try {
|
||||
};
|
||||
}, logicalFocusPoint);
|
||||
assert(during.collapsed, `cross-row drag releases the browser Range after logical promotion (${JSON.stringify(during)})`);
|
||||
assert(during.mode === "logical-selecting", `cross-page drag remains owned by logical selection (${during.mode})`);
|
||||
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")) {
|
||||
@@ -335,53 +399,28 @@ try {
|
||||
assert(after.rows <= Math.ceil(baselineRows * 1.1) + 2, "clearing logical selection preserves the normal virtual DOM window");
|
||||
const selectionHeapBaseline = await retainedHeap();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return;
|
||||
transcript.scrollTop = 0;
|
||||
transcript.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
// Query and scroll advancement are separate steps: advancing scrollTop in
|
||||
// the same evaluate as a missed query lets the scroll position run ahead of
|
||||
// Virtuoso's row mounting under load, skipping every selectable row. Give
|
||||
// each position two settle periods and wrap around at the bottom.
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.scrollMode === "manual", undefined, { timeout: 10_000 });
|
||||
await page.mouse.up();
|
||||
// Same contract as the upward drag: start mid-text on an early mounted
|
||||
// turn, then land on a target at least 21 turns later. Programmatic
|
||||
// scrollTop=0 does not unpin tail-follow or load older virtual pages.
|
||||
const transcriptBox = await page.locator(".transcript").boundingBox();
|
||||
if (transcriptBox) await page.mouse.move(transcriptBox.x + transcriptBox.width / 2, transcriptBox.y + transcriptBox.height / 2);
|
||||
const topDeadline = Date.now() + 15_000;
|
||||
let forwardPoints = null;
|
||||
for (let index = 0; index < 120 && !forwardPoints; index += 1) {
|
||||
await page.waitForTimeout(50);
|
||||
forwardPoints = await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return null;
|
||||
const viewport = transcript.getBoundingClientRect();
|
||||
const textRects = [...transcript.querySelectorAll("[data-transcript-selectable]")].flatMap((element) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
const rects = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (!node.textContent?.trim()) continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
rects.push(...range.getClientRects());
|
||||
}
|
||||
return rects;
|
||||
}).filter((rect) => rect.width > 8 && rect.bottom > viewport.top + 4 && rect.top < viewport.bottom - 4);
|
||||
const start = textRects[0];
|
||||
if (!start) return null;
|
||||
const y = (Math.max(start.top, viewport.top + 4) + Math.min(start.bottom, viewport.bottom - 4)) / 2;
|
||||
return {
|
||||
start: { x: start.left + 2, y },
|
||||
activate: { x: Math.min(start.right - 2, start.left + 32), y },
|
||||
edge: { x: start.left + 2, y: viewport.bottom - 2 },
|
||||
};
|
||||
});
|
||||
if (!forwardPoints && index % 2 === 1) {
|
||||
await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return;
|
||||
const max = transcript.scrollHeight - transcript.clientHeight;
|
||||
transcript.scrollTop = transcript.scrollTop >= max - 4 ? 0 : transcript.scrollTop + transcript.clientHeight / 2;
|
||||
});
|
||||
while (Date.now() < topDeadline && (!forwardPoints || forwardPoints.anchorTurn > 17)) {
|
||||
const older = page.locator(".transcript__older");
|
||||
if (await older.count()) await older.click();
|
||||
await page.mouse.wheel(0, -700);
|
||||
await page.waitForTimeout(80);
|
||||
try {
|
||||
forwardPoints = await waitForVisibleSelectionStart(page, { preferHighest: false, wheelDelta: -400, timeout: 1_200 });
|
||||
} catch {
|
||||
forwardPoints = null;
|
||||
}
|
||||
}
|
||||
assert(forwardPoints != null, "settled reverse selection leaves a viewport where forward selection can start");
|
||||
assert(forwardPoints.anchorTurn <= 17, `forward drag must start early enough to cover 20 turns (${forwardPoints.anchorTurn})`);
|
||||
await page.evaluate(() => {
|
||||
window.__transcriptProgrammaticWrites = [];
|
||||
window.__logicalClipboardText = null;
|
||||
@@ -392,44 +431,30 @@ try {
|
||||
await page.mouse.move(forwardPoints.start.x, forwardPoints.start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(forwardPoints.activate.x, forwardPoints.activate.y, { steps: 6 });
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
await page.mouse.wheel(0, 500);
|
||||
await page.waitForTimeout(50);
|
||||
const visibleTarget = await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return null;
|
||||
const viewport = transcript.getBoundingClientRect();
|
||||
const element = [...transcript.querySelectorAll("[data-transcript-selectable]")].find((candidate) => {
|
||||
const rect = candidate.getBoundingClientRect();
|
||||
return rect.width > 8 && rect.height > 0 && rect.bottom > viewport.top + 4 && rect.top < viewport.bottom - 4;
|
||||
});
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.min(rect.right - 2, rect.left + 8),
|
||||
y: (Math.max(rect.top, viewport.top + 4) + Math.min(rect.bottom, viewport.bottom - 4)) / 2,
|
||||
};
|
||||
});
|
||||
if (visibleTarget) {
|
||||
await page.mouse.move(visibleTarget.x, visibleTarget.y, { steps: 4 });
|
||||
} else {
|
||||
await page.mouse.move(
|
||||
forwardPoints.edge.x + (index % 2 === 0 ? 2 : -2),
|
||||
forwardPoints.edge.y - (index % 2),
|
||||
{ steps: 4 },
|
||||
);
|
||||
const forwardTargetTurn = forwardPoints.anchorTurn + 21;
|
||||
let forwardFocus = null;
|
||||
for (let index = 0; index < 80 && !forwardFocus; index += 1) {
|
||||
forwardFocus = await findVisibleTurnTarget(page, { min: forwardTargetTurn });
|
||||
if (!forwardFocus) {
|
||||
await page.mouse.wheel(0, 250);
|
||||
await page.mouse.move(forwardPoints.edge.x, forwardPoints.edge.y, { steps: 4 });
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
const mode = await page.locator(".transcript").getAttribute("data-scroll-mode");
|
||||
if (mode === "logical-selecting") break;
|
||||
}
|
||||
await page.mouse.move(forwardPoints.edge.x, forwardPoints.edge.y);
|
||||
await page.waitForTimeout(8_000);
|
||||
assert(forwardFocus != null, "downward logical drag settles over a visible 20+ turn target");
|
||||
let forwardPromoted = false;
|
||||
for (let attempt = 0; attempt < 5 && !forwardPromoted; attempt += 1) {
|
||||
await page.mouse.move(forwardFocus.x + 24, forwardFocus.y, { steps: 4 });
|
||||
await page.mouse.move(forwardFocus.x, forwardFocus.y, { steps: 8 });
|
||||
forwardPromoted = await waitForLogicalSelection(page, 3_000);
|
||||
if (!forwardPromoted) forwardFocus = (await findVisibleTurnTarget(page, { min: forwardTargetTurn })) ?? forwardFocus;
|
||||
}
|
||||
const forwardDuring = await page.evaluate(() => ({
|
||||
mode: document.querySelector(".transcript")?.dataset.scrollMode,
|
||||
rows: document.querySelectorAll(".transcript__row").length,
|
||||
owners: [...new Set((window.__transcriptProgrammaticWrites ?? []).map((write) => write.owner))],
|
||||
}));
|
||||
assert(forwardDuring.mode === "logical-selecting", "downward cross-page drag also promotes to logical selection");
|
||||
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();
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
"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__/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__/workspace-refresh-store.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-context-menu.test.tsx",
|
||||
"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:settings-responsive": "tsx src/__tests__/settings-responsive-layout.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-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__/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",
|
||||
"test:transcript": "tsx src/__tests__/transcript-virtuoso-index.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__/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",
|
||||
"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",
|
||||
"test:composer-menu-viewport": "tsx src/__tests__/composer-menu-viewport.test.ts && tsx src/__tests__/virtual-menu-identity.test.tsx",
|
||||
|
||||
@@ -60,9 +60,15 @@ console.log("\nbundle budgets");
|
||||
// React Virtuoso replaces the transcript's custom measurement/anchor engine.
|
||||
// Its production runtime adds 16.9 KiB gzip (4.2%) over the 402 KiB baseline.
|
||||
// This exceptional overrun is locally attributable and trades ~1400 lines of
|
||||
// competing state machines for a maintained library. The new gates retain 1%
|
||||
// headroom (4.6 KiB gzip / 23.2 KiB raw) to bound incidental feature growth.
|
||||
assertBudget("initial JavaScript gzip", initialJSGzip, 423.5 * 1024);
|
||||
// competing state machines for a maintained library. Native-tail finish helpers
|
||||
// then sat on the 423.5 KiB gate (Windows CI: 423.5 / 423.5); this 0.5 KiB
|
||||
// raise (0.12%) absorbs that leave-cancel / remasure-once code without
|
||||
// widening the original Virtuoso exception. The project-tree archive race
|
||||
// guards add 611 bytes gzip over main-v2's 423.988 KiB startup path after the
|
||||
// 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);
|
||||
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 280 * 1024);
|
||||
assertBudget("render-blocking CSS gzip", initialCSSGzip, 4 * 1024);
|
||||
// Extension surfaces, Task Monitor, and compact decision receipts share the
|
||||
@@ -75,9 +81,11 @@ if (localeChunks.length !== 2) {
|
||||
for (const path of localeChunks) {
|
||||
const name = basename(path);
|
||||
// Task Monitor, billing, indexed history, Task Center, Extension UI, and
|
||||
// runtime controls plus execution-setting receipts add localized copy. Keep
|
||||
// both dictionaries bounded.
|
||||
const budget = name.startsWith("zh-TW-") ? 55.5 * 1024 : 54.5 * 1024;
|
||||
// runtime controls plus execution-setting receipts add localized copy. The
|
||||
// recovery "other saved versions" dialog adds ~0.1 KiB gzip to zh (54.6 over
|
||||
// the old 54.5 gate, +0.18%); both on-demand dictionaries stay bounded with
|
||||
// small headroom.
|
||||
const budget = name.startsWith("zh-TW-") ? 55.5 * 1024 : 54.75 * 1024;
|
||||
assertBudget(`${name} gzip`, gzipBytes(path), budget);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const packageJSON = JSON.parse(readFileSync(resolve(repoRoot, "desktop/frontend/
|
||||
const appSource = readFileSync(resolve(repoRoot, "desktop/frontend/src/App.tsx"), "utf8");
|
||||
const bridgeSource = readFileSync(resolve(repoRoot, "desktop/frontend/src/lib/bridge.ts"), "utf8");
|
||||
const desktopMainSource = readFileSync(resolve(repoRoot, "desktop/main.go"), "utf8");
|
||||
const transcriptScrollBenchSource = readFileSync(resolve(repoRoot, "desktop/frontend/bench/transcript-scroll-stability.mjs"), "utf8");
|
||||
|
||||
function jobBody(name, nextName) {
|
||||
const match = workflow.match(new RegExp(`\\n ${name}:\\n([\\s\\S]*?)\\n ${nextName}:`));
|
||||
@@ -138,5 +139,16 @@ for (const required of ["transcript-selection.mjs", "transcript-scroll-stability
|
||||
throw new Error(`motion-ci-contract: test:transcript-browser must include ${required}`);
|
||||
}
|
||||
}
|
||||
for (const required of [
|
||||
"PerformanceObserver",
|
||||
"__reasonixScrollPerfProbe",
|
||||
"REASONIX_TRANSCRIPT_MAX_FRAME_GAP_MS",
|
||||
"REASONIX_TRANSCRIPT_MAX_LONG_TASK_MS",
|
||||
"Array.from({ length: 10 }",
|
||||
]) {
|
||||
if (!transcriptScrollBenchSource.includes(required)) {
|
||||
throw new Error(`motion-ci-contract: transcript scroll browser gate must retain performance probe ${required}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("motion-ci-contract: browser approval behavior and exact-binary WebView2 startup are separate release gates without production smoke instrumentation");
|
||||
@@ -19,6 +19,7 @@ const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toStrin
|
||||
const {
|
||||
dismissedTodoKeyForScope,
|
||||
resolveTodoPanelTodos,
|
||||
sameStringList,
|
||||
sameTodoList,
|
||||
scopedTodoBatchKey,
|
||||
scopedTodoDismissalKey,
|
||||
@@ -161,6 +162,19 @@ assert.equal(
|
||||
true,
|
||||
"an incomplete restored todo list must reappear even after a stale local dismissal",
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowTodoPanel(activeKey, null, activeTodos, { batchKey: todoBatchKey(activeTodos), batches: [todoBatchKey(activeTodos)] }),
|
||||
true,
|
||||
"a persisted completed-batch dismissal cannot hide unfinished work",
|
||||
);
|
||||
const completedBatch = todoBatchKey(completedTodos);
|
||||
assert.equal(
|
||||
shouldShowTodoPanel(completedKey, null, completedTodos, { batchKey: completedBatch, batches: [completedBatch] }),
|
||||
false,
|
||||
"a session-sidecar batch dismissal hides the completed shelf after upgrade remount",
|
||||
);
|
||||
assert.equal(sameStringList(["a"], ["a"]), true, "identical dismissed batch lists compare equal");
|
||||
assert.equal(sameStringList(["a"], ["b"]), false, "changed dismissed batch lists compare unequal");
|
||||
assert.notEqual(
|
||||
activeKey,
|
||||
todoDismissalKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]),
|
||||
|
||||
@@ -52,6 +52,7 @@ import { ClearContextCard } from "./components/ClearContextCard";
|
||||
import { RuntimeDecisionCard } from "./components/RuntimeDecisionCard";
|
||||
import { decisionSurfaceMockFromInput, type DecisionSurfaceKind as MockDecisionSurfaceKind } from "./lib/decisionSurfaceMock";
|
||||
const UndoRewindBanner = lazy(() => import("./components/UndoRewindBanner").then((module) => ({ default: module.UndoRewindBanner })));
|
||||
const ProjectTree = lazy(() => import("./components/ProjectTree").then((module) => ({ default: module.ProjectTree })));
|
||||
/** Footer decision surface kinds. Runtime blockers are explicit recovery choices. */
|
||||
type DecisionSurfaceKind = MockDecisionSurfaceKind | "extension_form";
|
||||
import { StatusBar } from "./components/StatusBar";
|
||||
@@ -68,7 +69,6 @@ import { OnboardingOverlay } from "./components/OnboardingOverlay";
|
||||
import { dismissOnboarding, shouldOpenOnboarding } from "./lib/onboarding";
|
||||
import { AppChrome } from "./components/AppChrome";
|
||||
import { ShortcutsCheatsheet } from "./components/ShortcutsCheatsheet";
|
||||
import { ProjectTree } from "./components/ProjectTree";
|
||||
import { WorktreeBadge } from "./components/WorktreeBadge";
|
||||
import { HeartbeatPanel } from "./custom/features/heartbeat/HeartbeatPanel";
|
||||
import "./custom/features/heartbeat/heartbeat.css";
|
||||
@@ -112,7 +112,6 @@ import {
|
||||
} from "./lib/types";
|
||||
import type { InvocationMetadataMap, StructuredInvocationSubmit } from "./lib/invocationDisplay";
|
||||
import { formatSelectionReference, type SelectedTextInsertRequest } from "./lib/selectedTextContext";
|
||||
import { workspaceTreeVisitId } from "./lib/workspaceTreeMemory";
|
||||
import { resolveTaskMonitorSession } from "./lib/taskMonitorNavigation";
|
||||
import {
|
||||
composerProfileFromMeta,
|
||||
@@ -195,7 +194,7 @@ 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 { availableWorkspacePanelWidth, resolveLiveWorkspacePanelWidth, resolveWorkspacePanelWidth, workspacePanelAriaMinWidth } from "./lib/workspaceLayout";
|
||||
import { resolveLiveWorkspacePanelWidth, resolveWorkspacePanelPlacement, workspacePanelAriaMinWidth } from "./lib/workspaceLayout";
|
||||
import { createRafResizeUpdater } from "./lib/resizeDrag";
|
||||
import { useGlobalShortcut } from "./lib/keyboardShortcuts";
|
||||
import { useMountTransition } from "./lib/useMountTransition";
|
||||
@@ -288,7 +287,13 @@ const SettingsPanel = lazy(() => import("./components/SettingsPanelEntry").then(
|
||||
const RemotePanel = lazy(() => import("./components/RemotePanel").then((module) => ({ default: module.RemotePanel })));
|
||||
const TerminalPanel = lazy(() => import("./components/TerminalPanel").then((module) => ({ default: module.TerminalPanel })));
|
||||
const TaskMonitorPanel = lazy(() => import("./components/TaskMonitorPanel").then((module) => ({ default: module.TaskMonitorPanel })));
|
||||
const WorkspacePanel = lazy(() => import("./components/WorkspacePanel").then((module) => ({ default: module.WorkspacePanel })));
|
||||
const WorkspacePanel = lazy(async () => {
|
||||
const [module] = await Promise.all([
|
||||
import("./components/WorkspacePanel"),
|
||||
import("./components/WorkspacePanelStability.css"),
|
||||
]);
|
||||
return { default: module.WorkspacePanel };
|
||||
});
|
||||
|
||||
const CHAT_MIN_WIDTH = 400;
|
||||
const CHAT_COMFORT_MIN_WIDTH = 560;
|
||||
@@ -1597,32 +1602,18 @@ export default function App() {
|
||||
: 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 workspacePanelAvailableWidth = availableWorkspacePanelWidth({
|
||||
viewportWidth,
|
||||
sidebarCollapsed,
|
||||
sidebarWidth,
|
||||
chatMinWidth: chatReservedWidth,
|
||||
resizerWidth: WORKSPACE_RESIZER_WIDTH,
|
||||
const {
|
||||
renderWidth: workspacePanelRenderWidth,
|
||||
overlay: workspacePanelOverlay,
|
||||
renderable: workspacePanelRenderable,
|
||||
gridOpen: workspacePanelGridOpen,
|
||||
} = resolveWorkspacePanelPlacement({
|
||||
viewportWidth, sidebarCollapsed, sidebarWidth, chatMinWidth: chatReservedWidth,
|
||||
resizerWidth: WORKSPACE_RESIZER_WIDTH, open: workspacePanelOpen,
|
||||
maximized: workspacePanelMaximized, preferredWidth: preferredWorkspacePanelWidth,
|
||||
minWidth: workspacePanelMinWidth, minRenderWidth: rightDockMinRenderWidth,
|
||||
liveWidth: liveWorkspacePanelRenderWidth,
|
||||
});
|
||||
|
||||
const resolvedWorkspacePanelWidth = resolveWorkspacePanelWidth({
|
||||
open: workspacePanelOpen,
|
||||
maximized: workspacePanelMaximized,
|
||||
preferredWidth: preferredWorkspacePanelWidth,
|
||||
minWidth: workspacePanelMinWidth,
|
||||
availableWidth: workspacePanelAvailableWidth,
|
||||
});
|
||||
|
||||
const storedWorkspacePanelRenderWidth = workspacePanelMaximized ? preferredWorkspacePanelWidth : resolvedWorkspacePanelWidth;
|
||||
const workspacePanelRenderWidth = liveWorkspacePanelRenderWidth ?? storedWorkspacePanelRenderWidth;
|
||||
// The terminal is an independent bottom drawer; workspace panel renderability
|
||||
// no longer depends on terminal mode.
|
||||
const workspacePanelRenderable =
|
||||
workspacePanelOpen && (
|
||||
workspacePanelMaximized ||
|
||||
workspacePanelRenderWidth >= rightDockMinRenderWidth
|
||||
);
|
||||
const workspacePanelGridOpen = workspacePanelRenderable && !workspacePanelMaximized;
|
||||
const resolveLiveWorkspacePanelRenderWidth = useCallback(
|
||||
(preferredWidth: number, nextSidebarWidth = sidebarWidth) =>
|
||||
resolveLiveWorkspacePanelWidth({
|
||||
@@ -1671,14 +1662,17 @@ export default function App() {
|
||||
state.sessionGen,
|
||||
workspaceControllerEpoch,
|
||||
].join("\u0000");
|
||||
// A topic may contain multiple saved sessions; the concrete session path is
|
||||
// the runtime conversation identity, with topic/tab ids only as fallbacks.
|
||||
// Workspace navigation belongs to the project, not to a single conversation.
|
||||
// A session switch inside the same project must therefore retain the dock,
|
||||
// tree and selection state.
|
||||
const workspaceTreeMemoryKey = [
|
||||
activeTab?.scope ?? "",
|
||||
activeTab?.workspaceRoot ?? state.meta?.cwd ?? "",
|
||||
activeTab?.sessionPath || state.meta?.sessionPath || activeTab?.topicId || activeTabId || "",
|
||||
].join("\u0000");
|
||||
const workspaceTreeMemoryVisitId = workspaceTreeVisitId(workspaceTreeMemoryKey);
|
||||
const restoreWorkspaceDockWidths = useCallback((treeWidth: number, previewWidth: number) => {
|
||||
setRightDockTreeWidth(rightDockTreeWidthClamp(treeWidth));
|
||||
setRightDockPreviewWidth(clampRightDockPreviewWidth(previewWidth));
|
||||
}, [rightDockTreeWidthClamp]);
|
||||
const sidebarImDetailConnection = useMemo(
|
||||
() => sidebarImConnections.find((connection) => connection.id === sidebarImDetailConnectionId) ?? null,
|
||||
[sidebarImConnections, sidebarImDetailConnectionId],
|
||||
@@ -2107,12 +2101,10 @@ export default function App() {
|
||||
// a stale local dismissal cannot hide work that still blocks final readiness;
|
||||
// every new list starts collapsed while its header keeps showing live progress
|
||||
// and the current task; completed lists can then be dismissed. The dismissal
|
||||
// key is still based on stable todo content/state so history reloads
|
||||
// do not resurrect the same finished list under a different event id. The
|
||||
// batch key ignores status changes so progress within the same task list does
|
||||
// not look like a brand-new task batch. Dismissal and open state are scoped to
|
||||
// the active session/topic/tab so different projects and sessions do not hide
|
||||
// or reopen each other's todo panels.
|
||||
// key is still based on stable todo content/state so history reloads do not
|
||||
// resurrect the same finished list under a different event id. The batch key
|
||||
// ignores status so progress in the same list is not a new batch. Dismissal
|
||||
// is scoped per session/topic/tab and also persisted on the session sidecar.
|
||||
const todoEntry = useMemo(() => {
|
||||
for (let i = state.items.length - 1; i >= 0; i--) {
|
||||
const it = state.items[i];
|
||||
@@ -2141,7 +2133,7 @@ export default function App() {
|
||||
);
|
||||
const scopedTodoKey = useMemo(() => scopedTodoDismissalKey(todoScope, todoKey), [todoKey, todoScope]);
|
||||
const scopedTodoBatch = useMemo(() => scopedTodoBatchKey(todoScope, todoBatch), [todoBatch, todoScope]);
|
||||
const showTodos = shouldShowTodoPanel(todoKey, dismissedTodo, todos);
|
||||
const showTodos = shouldShowTodoPanel(todoKey, dismissedTodo, todos, { batchKey: todoBatch, batches: state.meta?.sessionPath === activeTab?.sessionPath ? state.meta?.dismissedTodoBatches : undefined });
|
||||
const dismissTodos = useCallback(() => {
|
||||
if (!scopedTodoKey) return;
|
||||
setDismissedTodoKeys((current) => {
|
||||
@@ -2151,7 +2143,8 @@ export default function App() {
|
||||
saveDismissedTodoKeys(next);
|
||||
return next;
|
||||
});
|
||||
}, [scopedTodoKey]);
|
||||
if (activeTabId && todoBatch) void app.DismissTodoBatchForTab(activeTabId, todoBatch).catch(() => undefined);
|
||||
}, [activeTabId, scopedTodoKey, todoBatch]);
|
||||
|
||||
const sessionTitle = topicTitle(activeTab);
|
||||
const sessionHasContent = state.items.length > 0 || Boolean(state.live?.text || state.live?.reasoning);
|
||||
@@ -4293,6 +4286,7 @@ export default function App() {
|
||||
sidebarCollapsed ? "layout--sidebar-collapsed" : "",
|
||||
sidebarResizing ? "layout--resizing layout--sidebar-resizing" : "",
|
||||
workspacePanelGridOpen ? "layout--workspace-open" : "",
|
||||
workspacePanelOverlay ? "layout--workspace-overlay" : "",
|
||||
"layout--terminal-drawer-open",
|
||||
terminalPanelOpen ? "layout--terminal-drawer-expanded" : "",
|
||||
terminalResizing ? "layout--terminal-resizing" : "",
|
||||
@@ -4424,30 +4418,30 @@ export default function App() {
|
||||
)}
|
||||
|
||||
<section className="sidebar__section sidebar__section--projects">
|
||||
<ProjectTree
|
||||
activeScope={activeTab?.scope}
|
||||
activeWorkspaceRoot={activeTab?.workspaceRoot}
|
||||
activeTopicId={activeTab?.topicId}
|
||||
activeSessionPath={activeTab?.sessionPath}
|
||||
imTopicSources={imTopicSources}
|
||||
onOpenTopic={handleOpenTopic}
|
||||
onCreateTopic={(scope, workspaceRoot) => openBlankSession(scope, scope === "project" ? workspaceRoot : "")}
|
||||
onCreateDeliveryWorktree={(workspaceRoot) => enqueueNavigation({ kind: "delivery-worktree", workspaceRoot })}
|
||||
onTopicsChanged={refreshProjectsAndTabs}
|
||||
onRenameTopic={renameTopic}
|
||||
refreshSignal={projectRevision}
|
||||
onAddProject={async () => {
|
||||
await switchFolder();
|
||||
}}
|
||||
timeFilter={topicTimeFilter}
|
||||
onTimeFilterChange={setTopicTimeFilter}
|
||||
variant={sidebarWorkbench ? "workbench" : sidebarCreation ? "creation" : "classic"}
|
||||
searchExpanded={!sidebarCreation || sidebarSearchOpen}
|
||||
searchFocusSignal={sidebarSearchFocusSignal}
|
||||
showShortcutBadges={showTopicBadges}
|
||||
shortcutPlatform={desktopPlatform}
|
||||
onVisibleTopicsChange={handleVisibleTopicsChange}
|
||||
/>
|
||||
<Suspense fallback={null}><ProjectTree
|
||||
activeScope={activeTab?.scope}
|
||||
activeWorkspaceRoot={activeTab?.workspaceRoot}
|
||||
activeTopicId={activeTab?.topicId}
|
||||
activeSessionPath={activeTab?.sessionPath}
|
||||
imTopicSources={imTopicSources}
|
||||
onOpenTopic={handleOpenTopic}
|
||||
onCreateTopic={(scope, workspaceRoot) => openBlankSession(scope, scope === "project" ? workspaceRoot : "")}
|
||||
onCreateDeliveryWorktree={(workspaceRoot) => enqueueNavigation({ kind: "delivery-worktree", workspaceRoot })}
|
||||
onTopicsChanged={refreshProjectsAndTabs}
|
||||
onRenameTopic={renameTopic}
|
||||
refreshSignal={projectRevision}
|
||||
onAddProject={async (path) => {
|
||||
await switchFolder(path);
|
||||
}}
|
||||
timeFilter={topicTimeFilter}
|
||||
onTimeFilterChange={setTopicTimeFilter}
|
||||
variant={sidebarWorkbench ? "workbench" : sidebarCreation ? "creation" : "classic"}
|
||||
searchExpanded={!sidebarCreation || sidebarSearchOpen}
|
||||
searchFocusSignal={sidebarSearchFocusSignal}
|
||||
showShortcutBadges={showTopicBadges}
|
||||
shortcutPlatform={desktopPlatform}
|
||||
onVisibleTopicsChange={handleVisibleTopicsChange}
|
||||
/></Suspense>
|
||||
</section>
|
||||
|
||||
{sidebarWorkbench ? (
|
||||
@@ -4876,6 +4870,7 @@ export default function App() {
|
||||
actionHoverMenus={sidebarCreation && !hydratePlaceholderActive}
|
||||
rewindSignal={rewindSignal}
|
||||
revealSignal={transcriptRevealSignal}
|
||||
historyLayoutRevision={state.historyLayoutRevision}
|
||||
hydrating={transcriptHydrating}
|
||||
hasOlderHistory={state.historyHasOlder && !rewindState}
|
||||
olderHistoryCount={state.historyStartTurn}
|
||||
@@ -5181,6 +5176,7 @@ export default function App() {
|
||||
className={[
|
||||
"workbench-dock",
|
||||
`workbench-dock--${rightDockMode}`,
|
||||
workspacePanelOverlay ? "workbench-dock--overlay" : "",
|
||||
].join(" ")}
|
||||
aria-label={t("rightDock.workbench")}
|
||||
>
|
||||
@@ -5263,7 +5259,9 @@ export default function App() {
|
||||
cwd={state.meta?.cwd}
|
||||
workspaceScopeKey={workspaceScopeKey}
|
||||
workspaceMemoryKey={workspaceTreeMemoryKey}
|
||||
workspaceMemoryVisitId={workspaceTreeMemoryVisitId}
|
||||
dockTreeWidth={rightDockTreeWidth}
|
||||
dockPreviewWidth={rightDockPreviewWidth}
|
||||
onRestoreDockWidths={restoreWorkspaceDockWidths}
|
||||
maximized={workspacePanelMaximized}
|
||||
panelWidth={workspacePanelRenderWidth}
|
||||
onClose={() => setWorkspacePanel(false)}
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { AppBindings } from "../lib/bridge";
|
||||
import { enqueueNavigationRequest, type NavigationCoalescingRefs } from "../lib/openTopicCoalescing";
|
||||
import { useController } from "../lib/useController";
|
||||
import { historySliceFromMessages } from "./mockHistorySlice";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, JobView, Meta, TabMeta, TopicActivationEvent, TopicActivationRequest } from "../lib/types";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, JobView, Meta, TabMeta, TopicActivationEvent, TopicActivationRequest, WireEvent } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -140,6 +140,8 @@ let backendActiveId = "tab-a";
|
||||
const activationHolds = new Map<string, Promise<void>>();
|
||||
const tabsById = new Map([tabA, tabX, tabY].map((tab) => [tab.id, tab]));
|
||||
const topicActivationHandlers: Array<(e: TopicActivationEvent) => void> = [];
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
const replayTargets: string[] = [];
|
||||
// The pending ticketed activation backend-side: a newer StartTopicActivation
|
||||
// supersedes it (cancelled), exactly like the real generation protocol.
|
||||
let mockPendingActivation: { requestId: string; tabId: string } | undefined;
|
||||
@@ -154,6 +156,7 @@ function currentTabs(): TabMeta[] {
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: (name: string, cb: (...data: unknown[]) => void) => {
|
||||
if (name === "agent:event") eventHandlers.push(cb as (e: WireEvent) => void);
|
||||
if (name === "topic:activation") topicActivationHandlers.push(cb as (e: TopicActivationEvent) => void);
|
||||
return () => {};
|
||||
},
|
||||
@@ -208,9 +211,22 @@ window.go = {
|
||||
return { requestId, tabId: target.id, meta: { ...target, active: true } };
|
||||
},
|
||||
SetActiveTab: async (tabID: string) => {
|
||||
const hold = activationHolds.get(tabID);
|
||||
if (hold) await hold;
|
||||
backendActiveId = tabID;
|
||||
},
|
||||
ReplayPendingPrompts: async () => {},
|
||||
ReplayPendingPromptsForTab: async (tabID: string) => {
|
||||
replayTargets.push(tabID);
|
||||
if (!tabsById.get(tabID)?.pendingPrompt) return;
|
||||
for (const handler of eventHandlers) {
|
||||
handler({
|
||||
kind: "ask_request",
|
||||
tabId: tabID,
|
||||
ask: { id: `pending-${tabID}`, questions: [{ id: "choice", prompt: "Keep me through A-X-A", options: [] }] },
|
||||
});
|
||||
}
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
@@ -328,6 +344,50 @@ await act(async () => {
|
||||
});
|
||||
await waitFor("queued last click applies once it runs", () => controller?.activeTabId === "tab-a");
|
||||
|
||||
// A prompt can arrive on A before a slow A→X→A switch completes. The final A
|
||||
// activation must preserve its card, and X's stale completion must reassert A
|
||||
// without clearing or replaying a sibling tab's prompt.
|
||||
const promptBlockedA = { ...tabA, running: true, pendingPrompt: true, cancellable: true };
|
||||
tabsById.set(tabA.id, promptBlockedA);
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) {
|
||||
handler({
|
||||
kind: "ask_request",
|
||||
tabId: tabA.id,
|
||||
ask: { id: "pending-tab-a", questions: [{ id: "choice", prompt: "Keep me through A-X-A", options: [] }] },
|
||||
});
|
||||
}
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.ask?.id, "pending-tab-a", "A starts the rapid switch with a visible ask");
|
||||
|
||||
const slowSwitchGate = deferred<void>();
|
||||
activationHolds.set(tabX.id, slowSwitchGate.promise);
|
||||
let slowSwitch: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
slowSwitch = controller?.switchTab(tabX.id, tabX);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, tabX.id, "A→X renders the slow target optimistically");
|
||||
|
||||
await act(async () => {
|
||||
await controller?.switchTab(tabA.id, promptBlockedA);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, tabA.id, "A→X→A returns to the prompt owner");
|
||||
eq(controller?.state.ask?.id, "pending-tab-a", "returning to A preserves its ask");
|
||||
ok(replayTargets.includes(tabA.id), "post-activation replay is scoped to A");
|
||||
|
||||
await act(async () => {
|
||||
slowSwitchGate.resolve();
|
||||
activationHolds.delete(tabX.id);
|
||||
await slowSwitch;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, tabA.id, "late X activation cannot replace A");
|
||||
eq(backendActiveId, tabA.id, "late X activation reasserts A as backend owner");
|
||||
eq(controller?.state.ask?.id, "pending-tab-a", "late X completion cannot clear A's ask");
|
||||
|
||||
// Wiring lock: App.enqueueNavigation must invalidate in-flight activations at
|
||||
// enqueue time — the queue-based scenario above only proves the mechanism.
|
||||
const appSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8");
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Run: tsx src/__tests__/blank-project-dialog.test.tsx
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
passed += 1;
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
} else {
|
||||
failed += 1;
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nblank project creation");
|
||||
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.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
|
||||
|
||||
const [{ createRoot }, { BlankProjectDialog, blankProjectNameProblem }, { BlankProjectFlow }, { LocaleProvider }] = await Promise.all([
|
||||
import("react-dom/client"),
|
||||
import("../components/BlankProjectDialog"),
|
||||
import("../components/BlankProjectFlow"),
|
||||
import("../lib/i18n"),
|
||||
]);
|
||||
|
||||
ok(blankProjectNameProblem(" ") === "required", "empty names are rejected before IPC");
|
||||
ok(blankProjectNameProblem("nested/project") === "invalid", "nested paths are rejected before IPC");
|
||||
ok(blankProjectNameProblem("project") === null, "a single folder name is accepted");
|
||||
|
||||
const submitted: string[] = [];
|
||||
let cancelled = 0;
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("missing root");
|
||||
const root = createRoot(rootElement);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<BlankProjectDialog
|
||||
parentDirectory="/Users/test/Projects"
|
||||
busy={false}
|
||||
onSubmit={(name) => submitted.push(name)}
|
||||
onCancel={() => { cancelled += 1; }}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = document.querySelector<HTMLElement>('[role="dialog"]');
|
||||
const input = document.querySelector<HTMLInputElement>(".blank-project-dialog__input");
|
||||
const submit = document.querySelector<HTMLButtonElement>('button[type="submit"]');
|
||||
ok(Boolean(dialog) && dialog?.getAttribute("aria-modal") === "true", "creation uses an accessible modal dialog");
|
||||
ok(document.body.textContent?.includes("/Users/test/Projects") === true, "the chosen parent folder is shown before creation");
|
||||
ok(document.activeElement === input, "project name receives initial focus");
|
||||
ok(submit?.disabled === true, "create stays disabled until the name is valid");
|
||||
|
||||
await act(async () => {
|
||||
if (!input) return;
|
||||
const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, " demo-project ");
|
||||
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
|
||||
});
|
||||
ok(submit?.disabled === false, "a valid project name enables creation");
|
||||
await act(async () => {
|
||||
dialog?.querySelector("form")?.dispatchEvent(new dom.window.Event("submit", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
ok(submitted[0] === "demo-project", "submission trims and forwards the project name");
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
});
|
||||
ok(cancelled === 1, "Escape cancels the dialog");
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const treeSource = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8");
|
||||
const flowSource = readFileSync(resolve(here, "../components/BlankProjectFlow.tsx"), "utf8");
|
||||
const creationSource = readFileSync(resolve(here, "../components/useProjectCreation.tsx"), "utf8");
|
||||
ok(/key: "blank-project"[\s\S]*openBlankProjectFlow\(\)/.test(treeSource), "workbench menu starts the blank-project flow");
|
||||
ok(/CreateBlankProject\(draft\.parentDirectory, projectName\)/.test(flowSource), "the dialog delegates atomic directory creation to Go");
|
||||
ok(/await onOpenProject\(createdPath\)/.test(flowSource), "the new directory reuses workspace navigation and registration");
|
||||
ok(/lazy\(\(\) => import\("\.\/BlankProjectFlow"\)/.test(creationSource), "the creation flow stays outside the startup bundle");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
|
||||
const bridgeCalls: string[] = [];
|
||||
window.go = { main: { App: {
|
||||
async PickBlankProjectParent() {
|
||||
bridgeCalls.push("pick");
|
||||
return "/Users/test/Projects";
|
||||
},
|
||||
async CreateBlankProject(parentDirectory: string, projectName: string) {
|
||||
bridgeCalls.push(`create:${parentDirectory}:${projectName}`);
|
||||
return `${parentDirectory}/${projectName}`;
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings } };
|
||||
const flowRoot = createRoot(rootElement);
|
||||
await act(async () => {
|
||||
flowRoot.render(
|
||||
<LocaleProvider>
|
||||
<BlankProjectFlow
|
||||
onOpenProject={async (path) => { bridgeCalls.push(`open:${path}`); }}
|
||||
onRefresh={async () => { bridgeCalls.push("refresh"); }}
|
||||
onClose={() => { bridgeCalls.push("close"); }}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const flowInput = document.querySelector<HTMLInputElement>(".blank-project-dialog__input");
|
||||
await act(async () => {
|
||||
if (!flowInput) return;
|
||||
const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(flowInput, "flow-project");
|
||||
flowInput.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||
flowInput.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
flowInput?.closest("form")?.dispatchEvent(new dom.window.Event("submit", { bubbles: true, cancelable: true }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
ok(
|
||||
bridgeCalls.join("|") === "pick|create:/Users/test/Projects:flow-project|open:/Users/test/Projects/flow-project|refresh|close",
|
||||
"the live flow picks, creates, opens, refreshes, and closes in order",
|
||||
);
|
||||
|
||||
await act(async () => flowRoot.unmount());
|
||||
dom.window.close();
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -48,6 +48,14 @@ ok(
|
||||
appSource.includes('import("./components/HistoryPanel")'),
|
||||
"App loads secondary drawers on demand",
|
||||
);
|
||||
ok(
|
||||
!/import\s+\{\s*ProjectTree\s*\}\s+from\s+["']\.\/components\/ProjectTree["']/.test(appSource),
|
||||
"App keeps the project tree out of the first-paint bundle",
|
||||
);
|
||||
ok(
|
||||
appSource.includes('import("./components/ProjectTree")'),
|
||||
"App loads the project tree when the sidebar mounts",
|
||||
);
|
||||
ok(
|
||||
settingsEntrySource.includes('import "./CompactRatioSettings.css"') &&
|
||||
settingsEntrySource.includes('from "./SettingsPanel"') &&
|
||||
|
||||
@@ -1180,7 +1180,7 @@ console.log("\ncomposer goal toggle");
|
||||
});
|
||||
const dismissibleGuidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
|
||||
if (!dismissibleGuidanceItem) throw new Error("dismissible guidance chip did not render");
|
||||
const dismissButton = dismissibleGuidanceItem.querySelector(".composer-guidance-item__action") as HTMLButtonElement | null;
|
||||
const dismissButton = Array.from(dismissibleGuidanceItem.querySelectorAll<HTMLButtonElement>(".composer-guidance-item__action")).at(-1) ?? null;
|
||||
if (!dismissButton) throw new Error("running guidance dismiss button did not render");
|
||||
await act(async () => {
|
||||
dismissButton.click();
|
||||
|
||||
@@ -487,6 +487,161 @@ console.log("\ncomposer inbox recovery");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installBridgeApp({
|
||||
InboxSnapshot: async () => ({
|
||||
revision: 7,
|
||||
paused: true,
|
||||
recovered: false,
|
||||
recoveredCount: 0,
|
||||
items: [{
|
||||
id: "processing-guidance",
|
||||
intent: "steer",
|
||||
state: "steer_accepted",
|
||||
preview: "Already being applied",
|
||||
byteSize: 64,
|
||||
position: 1,
|
||||
}],
|
||||
itemsCount: 1,
|
||||
bytes: 64,
|
||||
maxItems: 64,
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
}),
|
||||
});
|
||||
const { root } = await renderComposer({ running: true });
|
||||
await waitFor("in-flight paused item rendered", () => document.querySelector(".composer-guidance-item") !== null);
|
||||
ok(document.querySelector(".composer-inbox-recovery") === null, "pause banner hides while guidance is already being applied");
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let updated = "";
|
||||
installBridgeApp({
|
||||
InboxSnapshot: async () => ({
|
||||
revision: 8,
|
||||
paused: false,
|
||||
recovered: false,
|
||||
recoveredCount: 0,
|
||||
items: [{
|
||||
id: "editable-guidance",
|
||||
intent: "followup",
|
||||
state: "queued",
|
||||
preview: "Original queued text",
|
||||
byteSize: 64,
|
||||
position: 1,
|
||||
}],
|
||||
itemsCount: 1,
|
||||
bytes: 64,
|
||||
maxItems: 64,
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
}),
|
||||
UpdateInboxItem: async (_tabId: string, _id: string, display: string) => {
|
||||
updated = display;
|
||||
},
|
||||
});
|
||||
const { root } = await renderComposer({ running: false });
|
||||
await waitFor("editable guidance rendered", () => document.querySelector("[aria-label=\"Edit this queued guidance\"]") !== null);
|
||||
const edit = document.querySelector("[aria-label=\"Edit this queued guidance\"]") as HTMLButtonElement;
|
||||
await act(async () => { edit.click(); await flushTimers(); });
|
||||
const editor = document.querySelector(".composer-guidance-item__editor") as HTMLInputElement;
|
||||
ok(editor?.value === "Original queued text", "edit mode loads the queued text");
|
||||
const save = document.querySelector("[aria-label=\"Save guidance edits\"]") as HTMLButtonElement;
|
||||
await act(async () => { save.click(); await flushTimers(); });
|
||||
ok(updated === "Original queued text", "edit saves through UpdateInboxItem");
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installBridgeApp({
|
||||
InboxSnapshot: async () => ({
|
||||
revision: 9,
|
||||
paused: true,
|
||||
recovered: false,
|
||||
recoveredCount: 0,
|
||||
items: [{
|
||||
id: "empty-preview",
|
||||
intent: "followup",
|
||||
state: "queued",
|
||||
preview: "",
|
||||
byteSize: 64,
|
||||
position: 1,
|
||||
}],
|
||||
itemsCount: 1,
|
||||
bytes: 64,
|
||||
maxItems: 64,
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
}),
|
||||
ReadInboxItem: async () => ({ id: "empty-preview", displayText: "Loaded body", rawText: "Loaded body", submitText: "Loaded body" }),
|
||||
});
|
||||
const { root } = await renderComposer({ running: false });
|
||||
await waitFor("empty preview hydrated", () => document.querySelector(".composer-guidance-item__text")?.textContent === "Loaded body");
|
||||
ok(document.querySelector(".composer-guidance-item__text")?.textContent === "Loaded body", "review path hydrates an empty snapshot preview");
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const snapshots: Record<string, ReturnType<typeof recoveredSnapshot>> = {
|
||||
"/sessions/a.jsonl": recoveredSnapshot(12),
|
||||
"/sessions/b.jsonl": { ...recoveredSnapshot(0), paused: false, recovered: false, recoveredCount: 0, items: [], itemsCount: 0, bytes: 0 },
|
||||
};
|
||||
installBridgeApp({
|
||||
InboxSnapshot: async () => {
|
||||
const path = currentPath;
|
||||
return { ...snapshots[path], sessionPath: path };
|
||||
},
|
||||
});
|
||||
let currentPath = "/sessions/a.jsonl";
|
||||
const { root, rerender } = await renderComposer({
|
||||
tabId: "tab-shared",
|
||||
sessionKey: "session-topic\0project\0/repo\0topic-a",
|
||||
inboxSessionPath: currentPath,
|
||||
});
|
||||
await waitFor("session A recovered queue rendered", () => document.querySelectorAll(".composer-guidance-item").length === 2);
|
||||
ok(document.querySelector(".composer-inbox-recovery")?.textContent?.includes("Recovered 12 pending instructions") === true, "session A shows its own recovered inbox");
|
||||
|
||||
currentPath = "/sessions/b.jsonl";
|
||||
await rerender({ inboxSessionPath: currentPath });
|
||||
await waitFor("session B does not inherit session A inbox", () => document.querySelector(".composer-guidance-item") === null);
|
||||
ok(document.querySelector(".composer-inbox-recovery") === null, "sibling session does not inherit the previous recovered banner");
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "sibling session does not inherit the previous queued guidance");
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let resolveStale!: (value: ReturnType<typeof recoveredSnapshot> & { sessionPath: string }) => void;
|
||||
const stalePromise = new Promise<ReturnType<typeof recoveredSnapshot> & { sessionPath: string }>((resolve) => { resolveStale = resolve; });
|
||||
installBridgeApp({
|
||||
InboxSnapshot: async (_tabId: string) => currentPath === "/sessions/a.jsonl"
|
||||
? stalePromise
|
||||
: { ...recoveredSnapshot(0), paused: false, recovered: false, recoveredCount: 0, items: [], itemsCount: 0, bytes: 0, sessionPath: currentPath },
|
||||
});
|
||||
let currentPath = "/sessions/a.jsonl";
|
||||
const { root, rerender } = await renderComposer({
|
||||
tabId: "tab-shared",
|
||||
sessionKey: "session-topic\0project\0/repo\0topic-a",
|
||||
inboxSessionPath: currentPath,
|
||||
});
|
||||
currentPath = "/sessions/b.jsonl";
|
||||
await rerender({ inboxSessionPath: currentPath });
|
||||
resolveStale({ ...recoveredSnapshot(12), sessionPath: "/sessions/a.jsonl" });
|
||||
await act(async () => { await flushTimers(); });
|
||||
ok(document.querySelector(".composer-inbox-recovery") === null, "stale recovered snapshot cannot land on another session");
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "stale queued items cannot land on another session");
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
process.stderr.write(`\n${failed} failed, ${passed} passed\n`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -8,6 +8,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const controller = readFileSync(join(root, "lib/useController.ts"), "utf8");
|
||||
const store = readFileSync(join(root, "lib/transcriptStore.ts"), "utf8");
|
||||
const app = readFileSync(join(root, "App.tsx"), "utf8");
|
||||
const transcript = readFileSync(join(root, "components/Transcript.tsx"), "utf8");
|
||||
|
||||
assert.match(controller, /deferResetUntilHistory \?\? true/, "history reset waits for successful load");
|
||||
assert.match(controller, /type: "hydrate_error"/, "history failure dispatches hydrate_error");
|
||||
@@ -15,6 +16,17 @@ assert.match(controller, /applyHydrateErrorState|hydratePlaceholderItems/, "hydr
|
||||
assert.match(readFileSync(join(root, "lib/hydrateErrorState.ts"), "utf8"), /keptItems/, "hydrateErrorState preserves items");
|
||||
assert.match(controller, /throw new Error\(t\("history\.failedLoadHistory"\)\)/, "listSessions does not swallow failures as empty");
|
||||
assert.match(controller, /retrySessionHistory/, "retry path is exported");
|
||||
assert.match(
|
||||
transcript,
|
||||
/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,
|
||||
/loadSessionDataForTab\(tabId, false, "startup", \{ preserveCachedHistory: true \}\)/,
|
||||
"failed clear keeps the visible transcript instead of a resident snapshot",
|
||||
);
|
||||
assert.match(store, /slice\.error/, "transcript store rejects slice.error as failure");
|
||||
assert.match(app, /retrySessionHistory/, "App wires history retry control");
|
||||
assert.match(app, /history-load-error/, "App surfaces hydrate error banner");
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Run: tsx src/__tests__/hydrate-history-apply.test.ts
|
||||
|
||||
import {
|
||||
duplicateLiveItemIds,
|
||||
hasCachedLiveTurn,
|
||||
hydratedHistoryApplyMode,
|
||||
sameSessionPlaceholderItems,
|
||||
shouldPreferResidentHistory,
|
||||
} from "../lib/hydrateHistoryApply";
|
||||
|
||||
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("\nhydrate history apply");
|
||||
|
||||
const mode = hydratedHistoryApplyMode;
|
||||
|
||||
ok(mode(true, true, false, { items: [] }) === "skip", "skipHistory blocks apply");
|
||||
ok(mode(false, false, false, { items: [] }) === "skip", "missing projection blocks apply");
|
||||
ok(mode(false, true, false, { items: [] }) === "replace", "idle empty surface applies history");
|
||||
ok(mode(false, true, true, { running: true, items: [] }) === "replace", "running empty surface applies history");
|
||||
ok(
|
||||
mode(false, true, true, { running: true, live: { text: "partial" }, items: [] }) === "replace",
|
||||
"a mid-stream surface with no rows yet applies history",
|
||||
);
|
||||
ok(
|
||||
mode(false, true, true, { running: true, items: [{ kind: "user" }] }) === "prepend",
|
||||
"a running turn with no history page behind it gets one prepended",
|
||||
);
|
||||
ok(
|
||||
mode(false, true, true, { running: true, historyTotalTurns: 3, items: [{ kind: "user" }] }) === "skip",
|
||||
"an already-hydrated running transcript is left alone",
|
||||
);
|
||||
ok(
|
||||
hasCachedLiveTurn({
|
||||
running: true,
|
||||
historyTotalTurns: 2,
|
||||
items: [{ kind: "assistant", streaming: true }],
|
||||
}),
|
||||
"streaming assistant counts as a cached live turn",
|
||||
);
|
||||
ok(
|
||||
!hasCachedLiveTurn({ running: true, items: [{ kind: "assistant", streaming: true }] }),
|
||||
"a live turn with no history page behind it is not cached",
|
||||
);
|
||||
ok(
|
||||
duplicateLiveItemIds(
|
||||
[{ kind: "user", id: "h1", text: "ask" }],
|
||||
[{ kind: "user", id: "l1", text: "ask" }, { kind: "assistant", id: "l2", text: "" }],
|
||||
).join(",") === "l1",
|
||||
"a live row the page already carries is dropped",
|
||||
);
|
||||
ok(
|
||||
duplicateLiveItemIds(
|
||||
[{ kind: "user", id: "h1", text: "ask" }],
|
||||
[{ kind: "assistant", id: "l2", text: "" }],
|
||||
).length === 0,
|
||||
"a live tail the page does not carry is kept",
|
||||
);
|
||||
ok(
|
||||
sameSessionPlaceholderItems("a.jsonl", { meta: { sessionPath: "b.jsonl" }, items: [{ kind: "user" }] }) === undefined,
|
||||
"foreign session items are not placeholders",
|
||||
);
|
||||
ok(
|
||||
(sameSessionPlaceholderItems("a.jsonl", { meta: { sessionPath: "a.jsonl" }, items: [{ kind: "user" }] }) ?? []).length === 1,
|
||||
"same-session items stay placeholders",
|
||||
);
|
||||
|
||||
ok(
|
||||
!shouldPreferResidentHistory(false, false),
|
||||
"retry / explicit no-cache hydrates must not serve the resident snapshot",
|
||||
);
|
||||
ok(shouldPreferResidentHistory(false, true), "preserveCachedHistory still allows a resident hit");
|
||||
ok(shouldPreferResidentHistory(false, undefined), "unspecified preserveCachedHistory still allows a resident hit");
|
||||
ok(!shouldPreferResidentHistory(true, true), "reset hydrates never prefer the resident snapshot");
|
||||
|
||||
const liveIdle = {
|
||||
items: [{ kind: "user" }, { kind: "assistant" }, { kind: "user" }],
|
||||
historyRevision: 10,
|
||||
historyDigest: "rev-10",
|
||||
};
|
||||
ok(
|
||||
mode(false, true, false, liveIdle, {
|
||||
items: [{ kind: "user" }],
|
||||
revision: 10,
|
||||
digest: "rev-10",
|
||||
}) === "skip",
|
||||
"idle transcript is not replaced by a shorter same-fingerprint resident snapshot",
|
||||
);
|
||||
ok(
|
||||
mode(false, true, false, liveIdle, {
|
||||
items: [{ kind: "user" }, { kind: "assistant" }, { kind: "user" }, { kind: "assistant" }],
|
||||
revision: 11,
|
||||
digest: "rev-11",
|
||||
}) === "replace",
|
||||
"a newer backend page still replaces the idle transcript",
|
||||
);
|
||||
ok(
|
||||
mode(false, true, false, { items: [] }, {
|
||||
items: [{ kind: "user" }],
|
||||
revision: 10,
|
||||
digest: "rev-10",
|
||||
}) === "replace",
|
||||
"empty idle surface still applies history",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
beginKeyedResourceRequest,
|
||||
emptyKeyedResource,
|
||||
rejectKeyedResourceRequest,
|
||||
resolveKeyedResourceRequest,
|
||||
} from "../lib/keyedResource";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function check(value: boolean, label: string) {
|
||||
if (value) { passed += 1; process.stdout.write(` PASS ${label}\n`); }
|
||||
else { failed += 1; process.stdout.write(` FAIL ${label}\n`); }
|
||||
}
|
||||
|
||||
console.log("\nkeyed stale-while-revalidate resource");
|
||||
let state = beginKeyedResourceRequest(emptyKeyedResource<string>(), "a", 1, 1);
|
||||
state = resolveKeyedResourceRequest(state, "a", 1, "first", 1);
|
||||
state = beginKeyedResourceRequest(state, "a", 2, 2);
|
||||
check(state.data === "first" && state.status === "refreshing", "same-key refresh keeps painted data");
|
||||
const stale = resolveKeyedResourceRequest(state, "a", 1, "stale", 1);
|
||||
check(stale === state, "stale completion is ignored by sequence");
|
||||
state = rejectKeyedResourceRequest(state, "a", 2, "offline");
|
||||
check(state.data === "first" && state.status === "error", "refresh error preserves painted data");
|
||||
state = beginKeyedResourceRequest(state, "b", 3, 3);
|
||||
check(state.data === null && state.status === "refreshing", "new key starts without unrelated data");
|
||||
check(failed === 0, `${passed} resource assertions passed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -2,8 +2,8 @@
|
||||
//
|
||||
// MarkdownHistory (worker-driven history rendering): transcript markdown cache
|
||||
// hits avoid re-parsing, revision (content) changes re-parse, and huge
|
||||
// documents mount their blocks progressively via idle callbacks. Uses the
|
||||
// in-process fallback client with a spy — jsdom has no Worker.
|
||||
// documents keep a viewport-driven tail window. Uses the in-process fallback
|
||||
// client with a spy — jsdom has no Worker.
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
@@ -48,23 +48,54 @@ globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
let pendingIdle: Array<() => void> = [];
|
||||
Object.defineProperty(dom.window, "requestIdleCallback", {
|
||||
configurable: true,
|
||||
value: (callback: () => void) => {
|
||||
pendingIdle.push(callback);
|
||||
return pendingIdle.length;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window, "cancelIdleCallback", {
|
||||
configurable: true,
|
||||
value: () => undefined,
|
||||
});
|
||||
const intersectionCallbacks = new Map<Element, IntersectionObserverCallback>();
|
||||
class TestIntersectionObserver {
|
||||
readonly callback: IntersectionObserverCallback;
|
||||
readonly targets = new Set<Element>();
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
observe(target: Element) {
|
||||
this.targets.add(target);
|
||||
intersectionCallbacks.set(target, this.callback);
|
||||
}
|
||||
unobserve(target: Element) {
|
||||
this.targets.delete(target);
|
||||
intersectionCallbacks.delete(target);
|
||||
}
|
||||
disconnect() {
|
||||
for (const target of this.targets) intersectionCallbacks.delete(target);
|
||||
this.targets.clear();
|
||||
}
|
||||
takeRecords(): IntersectionObserverEntry[] { return []; }
|
||||
readonly root = null;
|
||||
readonly rootMargin = "0px";
|
||||
readonly thresholds = [0];
|
||||
}
|
||||
globalThis.IntersectionObserver = TestIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||
dom.window.IntersectionObserver = TestIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||
|
||||
function runIdle() {
|
||||
const callbacks = pendingIdle;
|
||||
pendingIdle = [];
|
||||
for (const callback of callbacks) callback();
|
||||
async function intersectSentinel(selector: string, isIntersecting: boolean) {
|
||||
const sentinel = rootEl?.querySelector(selector);
|
||||
if (!sentinel) throw new Error(`missing sentinel ${selector}`);
|
||||
await act(async () => {
|
||||
intersectionCallbacks.get(sentinel)?.([{ isIntersecting, target: sentinel } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function intersectSentinels(selectors: string[], isIntersecting: boolean) {
|
||||
const sentinels = selectors.map((selector) => {
|
||||
const sentinel = rootEl?.querySelector(selector);
|
||||
if (!sentinel) throw new Error(`missing sentinel ${selector}`);
|
||||
return sentinel;
|
||||
});
|
||||
await act(async () => {
|
||||
for (const sentinel of sentinels) {
|
||||
intersectionCallbacks.get(sentinel)?.([{ isIntersecting, target: sentinel } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
const flush = () => act(async () => {
|
||||
@@ -172,7 +203,21 @@ console.log("\nmarkdown history rendering");
|
||||
|
||||
// ── progressive mounting for huge documents ──────────────────────────────────
|
||||
{
|
||||
const text = Array.from({ length: 60 }, (_, i) => `Paragraph ${i} with some *content*.`).join("\n\n");
|
||||
rootEl.className = "transcript";
|
||||
rootEl.scrollTop = 1_000;
|
||||
const originalRect = dom.window.HTMLElement.prototype.getBoundingClientRect;
|
||||
dom.window.HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
|
||||
const top = this.hasAttribute("data-markdown-older-sentinel")
|
||||
? 100
|
||||
: this.hasAttribute("data-markdown-newer-sentinel")
|
||||
? 700
|
||||
: this.hasAttribute("data-markdown-scroll-anchor")
|
||||
? Number(this.getAttribute("data-markdown-scroll-anchor")) < 300 ? 500 : 300
|
||||
: 0;
|
||||
const height = this.hasAttribute("data-markdown-older-sentinel") ? 1 : 0;
|
||||
return { top, bottom: top + height, left: 0, right: 0, width: 0, height, x: 0, y: top, toJSON: () => ({}) };
|
||||
};
|
||||
const text = Array.from({ length: 420 }, (_, i) => `Paragraph ${i} with some *content*.`).join("\n\n");
|
||||
const root5 = createRoot(rootEl);
|
||||
await act(async () => {
|
||||
root5.render(<MarkdownHistory text={text} entryId="md-history-huge" fallback={null} />);
|
||||
@@ -180,20 +225,59 @@ console.log("\nmarkdown history rendering");
|
||||
await flush();
|
||||
const container = rootEl.querySelector(".md[data-markdown-blocks]");
|
||||
ok(container, "huge document renders through blocks");
|
||||
eq(container?.getAttribute("data-markdown-blocks"), "60", "all 60 blocks are in the render model");
|
||||
eq(container?.getAttribute("data-markdown-visible-blocks"), "24", "visible block count exposes the initial idle chunk");
|
||||
eq(container?.getAttribute("data-markdown-blocks"), "420", "all 420 blocks are in the render model");
|
||||
eq(container?.getAttribute("data-markdown-visible-blocks"), "24", "visible block count exposes the initial tail window");
|
||||
const initialCount = container?.children.length ?? 0;
|
||||
eq(initialCount, 24, "first commit mounts only the initial block chunk");
|
||||
for (let i = 0; i < 5 && (rootEl.querySelector(".md[data-markdown-blocks]")?.children.length ?? 0) < 60; i += 1) {
|
||||
await act(async () => {
|
||||
runIdle();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.children.length, 60, "idle callbacks mount the remaining blocks");
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-visible-blocks"), "60", "visible block count reaches the render model");
|
||||
ok(rootEl.textContent?.includes("Paragraph 59"), "the final block's content mounts");
|
||||
eq(initialCount, 25, "first commit mounts the tail plus one inert viewport sentinel");
|
||||
ok(!rootEl.textContent?.includes("Paragraph 0"), "the cold prefix stays out of the DOM");
|
||||
ok(rootEl.textContent?.includes("Paragraph 419"), "the newest block mounts immediately");
|
||||
|
||||
await intersectSentinel("[data-markdown-older-sentinel]", true);
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-visible-blocks"), "120", "entering the older edge prepends one bounded page");
|
||||
eq(rootEl.scrollTop, 1_199, "prepending compensates for the old leading block boundary's measured movement");
|
||||
await intersectSentinel("[data-markdown-older-sentinel]", true);
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-visible-blocks"), "120", "a stationary sentinel cannot start a render loop");
|
||||
await intersectSentinel("[data-markdown-older-sentinel]", false);
|
||||
await intersectSentinel("[data-markdown-older-sentinel]", true);
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-visible-blocks"), "216", "leaving and re-entering requests the next page");
|
||||
await intersectSentinel("[data-markdown-older-sentinel]", false);
|
||||
await intersectSentinel("[data-markdown-older-sentinel]", true);
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-visible-blocks"), "216", "the resident block window stays bounded while paging toward the start");
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-window-start"), "108", "older paging advances the bounded window start");
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-window-end"), "324", "older paging trims cold blocks from the newer edge");
|
||||
ok(rootEl.textContent?.includes("Paragraph 108"), "the requested older page is mounted");
|
||||
ok(!rootEl.textContent?.includes("Paragraph 419"), "the distant tail is evicted after the window reaches its cap");
|
||||
|
||||
await intersectSentinel("[data-markdown-older-sentinel]", false);
|
||||
await intersectSentinels(["[data-markdown-older-sentinel]", "[data-markdown-newer-sentinel]"], true);
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-window-start"), "12", "simultaneous edge callbacks apply only the first window move");
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-window-end"), "228", "an opposite-edge callback cannot overwrite the in-flight window move");
|
||||
|
||||
await intersectSentinel("[data-markdown-newer-sentinel]", false);
|
||||
const beforeNewerPage = rootEl.scrollTop;
|
||||
await intersectSentinel("[data-markdown-newer-sentinel]", true);
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-window-start"), "108", "newer paging trims cold blocks from the older edge");
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-window-end"), "324", "newer paging advances toward the document tail");
|
||||
eq(rootEl.scrollTop, beforeNewerPage - 200, "trimming above compensates for the old trailing boundary's measured movement");
|
||||
await intersectSentinel("[data-markdown-newer-sentinel]", false);
|
||||
await intersectSentinel("[data-markdown-newer-sentinel]", true);
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-window-start"), "204", "a second newer page keeps the resident window bounded");
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-window-end"), "420", "newer paging can return to the document tail");
|
||||
ok(rootEl.textContent?.includes("Paragraph 419"), "newer paging restores the document tail");
|
||||
|
||||
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} />);
|
||||
});
|
||||
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");
|
||||
ok(!rootEl.textContent?.includes("Replacement 0"), "the replacement cannot inherit the previous document's expanded prefix");
|
||||
ok(rootEl.textContent?.includes("Replacement 419"), "the replacement's newest block mounts immediately");
|
||||
await intersectSentinel("[data-markdown-older-sentinel]", true);
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-visible-blocks"), "120", "a replacement document re-arms viewport paging");
|
||||
await act(async () => root5.unmount());
|
||||
dom.window.HTMLElement.prototype.getBoundingClientRect = originalRect;
|
||||
rootEl.className = "";
|
||||
}
|
||||
|
||||
// ── worker failure falls back through onError ────────────────────────────────
|
||||
|
||||
@@ -37,7 +37,7 @@ eq(markdownImageSource("https://cdn.example.com/a.png", false), "https://cdn.exa
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const componentsSource = readFileSync(resolve(testDir, "../components/markdownComponents.tsx"), "utf8");
|
||||
eq(componentsSource.includes("img: ({ src, alt, title })"), true, "the shared components map owns the image element");
|
||||
eq(componentsSource.includes("markdownImageSource(src)"), true, "the shared components map applies backend routing");
|
||||
eq(componentsSource.includes("<MarkdownImage src={src}"), true, "both streaming and worker paths use the async image resolver");
|
||||
|
||||
if (failed > 0) {
|
||||
process.stderr.write(`\n${failed} markdown image proxy assertion(s) failed\n`);
|
||||
|
||||
@@ -54,7 +54,9 @@ class FakeWorker implements MarkdownWorkerLike {
|
||||
this.onmessage?.({ data: { id, error } } as MessageEvent<MarkdownParseResponse>);
|
||||
}
|
||||
crash(): void {
|
||||
this.onerror?.(new ErrorEvent("error"));
|
||||
// Node 24 does not expose the browser ErrorEvent constructor. The client
|
||||
// intentionally treats worker.onerror as a signal and does not inspect it.
|
||||
this.onerror?.({ type: "error" } as ErrorEvent);
|
||||
}
|
||||
terminate(): void {
|
||||
this.terminated += 1;
|
||||
|
||||
@@ -205,6 +205,71 @@ console.log("\nmermaid rendering");
|
||||
eq(streamingCommitTarget("t\n\n$$\nx\n$$\nafter"), "t\n\n$$\nx\n$$\n", "closed display math promotes immediately");
|
||||
eq(streamingCommitTarget("para\n## Next sec"), "para\n", "a partial heading line completes the paragraph before it");
|
||||
eq(streamingCommitTarget("para\n## Done\ntail"), "para\n## Done\n", "a terminated heading promotes itself as a complete block");
|
||||
|
||||
const searchItem = (title: string, url: string) => `- **${title}**\n <${url}>`;
|
||||
const searchDump = [
|
||||
searchItem("新闻本文", "https://example.com/a"),
|
||||
searchItem("Bitcoin (BTC) Price", "https://example.com/b?utm_source=x"),
|
||||
searchItem("KuCoin", "https://example.com/c"),
|
||||
].join("\n");
|
||||
eq(
|
||||
streamingCommitTarget(searchDump),
|
||||
`${searchItem("新闻本文", "https://example.com/a")}\n${searchItem("Bitcoin (BTC) Price", "https://example.com/b?utm_source=x")}\n`,
|
||||
"a later list marker commits prior tight list items, including indented URL continuations",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget(`${searchItem("新闻本文", "https://example.com/a")}\n- **Bit`),
|
||||
`${searchItem("新闻本文", "https://example.com/a")}\n`,
|
||||
"an in-progress list marker still commits the previous completed item",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget(searchItem("新闻本文", "https://example.com/a")),
|
||||
"",
|
||||
"a single list item stays in the tail until the next item or a blank line",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget("1. alpha\n2. beta\n3. gamma"),
|
||||
"1. alpha\n2. beta\n",
|
||||
"ordered list markers complete prior items the same way",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget("- [ ] one\n- [x] two\n- [ ] three"),
|
||||
"- [ ] one\n- [x] two\n",
|
||||
"task-list markers complete prior items the same way",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget("intro paragraph\n- first item\n continued"),
|
||||
"intro paragraph\n",
|
||||
"a list marker completes the paragraph before it without taking the new item",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget("- parent\n - child still typing"),
|
||||
"- parent\n",
|
||||
"a nested list marker completes the parent item and leaves the child in the tail",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget("not a heading\n---\nstill setext"),
|
||||
"",
|
||||
"a setext underline is not treated as a list marker",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget("*not-a-list*\nstill paragraph"),
|
||||
"",
|
||||
"emphasis without a marker space is not a list item",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget("t\n\n```\n- not a list\n- also not\n"),
|
||||
"t\n\n```\n- not a list\n- also not\n",
|
||||
"list markers inside an open fence do not create commit boundaries",
|
||||
);
|
||||
eq(
|
||||
streamingCommitTarget("- one\n- two\n\n"),
|
||||
"- one\n- two\n\n",
|
||||
"a blank line after a list still commits the whole list",
|
||||
);
|
||||
const searchHtml = renderToStaticMarkup(<ReactMarkdown remarkPlugins={[]}>{searchDump}</ReactMarkdown>);
|
||||
ok(searchHtml.includes("<ul>") && searchHtml.includes("<li>") && searchHtml.includes("新闻本文"),
|
||||
"the search-list source still renders as a list in the final Markdown tree");
|
||||
}
|
||||
|
||||
{
|
||||
@@ -269,6 +334,25 @@ console.log("\nmermaid rendering");
|
||||
});
|
||||
eq(rootEl.textContent, "complete", "short stream finalization still commits immediately");
|
||||
|
||||
pendingFrame = undefined;
|
||||
const firstSearch = "- **新闻本文**\n <https://example.com/a>\n";
|
||||
const secondSearch = `${firstSearch}- **Bitcoin**\n <https://example.com/b>`;
|
||||
await act(async () => {
|
||||
root.render(<MarkdownTextProbe text={firstSearch} streaming />);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(rootEl.textContent, "", "a single tight list item stays off the parsed prefix");
|
||||
await act(async () => {
|
||||
root.render(<MarkdownTextProbe text={secondSearch} streaming />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
});
|
||||
const listFrame = pendingFrame;
|
||||
await act(async () => {
|
||||
listFrame?.(performance.now());
|
||||
await flushTimers();
|
||||
});
|
||||
eq(rootEl.textContent, firstSearch, "a later list item commits the previous tight item into the parsed prefix");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
@@ -249,6 +249,22 @@ await render({
|
||||
});
|
||||
ok(Boolean(document.querySelector(".reasoning__body")), "manual reasoning expansion survives auto completion");
|
||||
|
||||
await act(async () => {
|
||||
applyReasoningDisplayMode("expanded");
|
||||
});
|
||||
await render({
|
||||
kind: "assistant",
|
||||
id: "a-expanded",
|
||||
text: "",
|
||||
reasoning: "kept thought",
|
||||
streaming: false,
|
||||
reasoningComplete: true,
|
||||
});
|
||||
ok(Boolean(document.querySelector(".reasoning__body")), "expanded mode keeps completed reasoning open");
|
||||
ok(!document.querySelector(".reasoning-summary"), "expanded mode never falls back to a summary");
|
||||
await click(document.querySelector(".reasoning__head"));
|
||||
ok(!document.querySelector(".reasoning__body"), "a manual collapse still wins inside expanded mode");
|
||||
|
||||
await act(async () => {
|
||||
applyReasoningDisplayMode("hidden");
|
||||
});
|
||||
|
||||
@@ -146,6 +146,12 @@ let backendCanonicalTodos = [{ content: "Old task", status: "in_progress" }];
|
||||
let holdNextMeta = false;
|
||||
let staleMetaStarted = false;
|
||||
const eventHandlers: Array<(event: WireEvent) => void> = [];
|
||||
const rebuiltHandlers: Array<(tabId?: string, runtimeEpoch?: string) => void> = [];
|
||||
let backendRuntimeEpoch = "runtime-old";
|
||||
let backendPendingPrompt = false;
|
||||
let promptReplayCalls = 0;
|
||||
const resumeRPCGate = deferred<void>();
|
||||
const channelRPCGate = deferred<void>();
|
||||
const context: ContextInfo = { used: 12, window: 100, sessionTokens: 12 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
const balance: BalanceInfo = { available: false, display: "" };
|
||||
@@ -155,6 +161,7 @@ const checkpoints: CheckpointMeta[] = [];
|
||||
window.runtime = {
|
||||
EventsOn: (name: string, cb: (...data: unknown[]) => void) => {
|
||||
if (name === "agent:event") eventHandlers.push(cb as (event: WireEvent) => void);
|
||||
if (name === "runtime:rebuilt") rebuiltHandlers.push(cb as (tabId?: string, runtimeEpoch?: string) => void);
|
||||
return () => {};
|
||||
},
|
||||
BrowserOpenURL: () => {},
|
||||
@@ -162,14 +169,21 @@ window.runtime = {
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => [tabMeta()],
|
||||
ListTabs: async () => {
|
||||
return [tabMeta({
|
||||
runtime: { phase: "ready", epoch: backendRuntimeEpoch },
|
||||
running: backendPendingPrompt,
|
||||
pendingPrompt: backendPendingPrompt,
|
||||
cancellable: backendPendingPrompt,
|
||||
})];
|
||||
},
|
||||
MetaForTab: async () => {
|
||||
if (holdNextMeta) {
|
||||
holdNextMeta = false;
|
||||
staleMetaStarted = true;
|
||||
return staleSessionMeta.promise;
|
||||
}
|
||||
return meta({ canonicalTodos: backendCanonicalTodos });
|
||||
return meta({ canonicalTodos: backendCanonicalTodos, runtime: { phase: "ready", epoch: backendRuntimeEpoch } });
|
||||
},
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
@@ -185,6 +199,18 @@ window.go = {
|
||||
historySliceFromMessages(tabID, await staleHistory.promise, req),
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
ReplayPendingPromptsForTab: async (tabID: string) => {
|
||||
promptReplayCalls += 1;
|
||||
if (tabID !== "tab-a" || !backendPendingPrompt) return;
|
||||
for (const handler of eventHandlers) {
|
||||
handler({
|
||||
kind: "ask_request",
|
||||
tabId: tabID,
|
||||
runtimeEpoch: backendRuntimeEpoch,
|
||||
ask: { id: `replayed-${backendRuntimeEpoch}`, questions: [{ id: "choice", prompt: "Recovered after hydration", options: [] }] },
|
||||
});
|
||||
}
|
||||
},
|
||||
NewSession: async () => {
|
||||
newSessionCalls += 1;
|
||||
backendCanonicalTodos = [];
|
||||
@@ -196,6 +222,25 @@ window.go = {
|
||||
},
|
||||
ResumeSessionPageForTab: async () => {
|
||||
backendCanonicalTodos = [{ content: "Restored task", status: "completed" }];
|
||||
const oldEpoch = backendRuntimeEpoch;
|
||||
backendRuntimeEpoch = "runtime-resumed";
|
||||
backendPendingPrompt = true;
|
||||
for (const handler of rebuiltHandlers) handler("tab-a", backendRuntimeEpoch);
|
||||
for (const handler of eventHandlers) {
|
||||
handler({
|
||||
kind: "ask_request",
|
||||
tabId: "tab-a",
|
||||
runtimeEpoch: oldEpoch,
|
||||
ask: { id: "stale-old-epoch", questions: [{ id: "choice", prompt: "Stale", options: [] }] },
|
||||
});
|
||||
handler({
|
||||
kind: "ask_request",
|
||||
tabId: "tab-a",
|
||||
runtimeEpoch: backendRuntimeEpoch,
|
||||
ask: { id: "pre-response-resume", questions: [{ id: "choice", prompt: "Before Resume RPC returns", options: [] }] },
|
||||
});
|
||||
}
|
||||
await resumeRPCGate.promise;
|
||||
return {
|
||||
messages: [{ role: "user", content: "restore" }, { role: "assistant", content: "done" }],
|
||||
startTurn: 0,
|
||||
@@ -204,6 +249,34 @@ window.go = {
|
||||
hasOlder: false,
|
||||
};
|
||||
},
|
||||
OpenChannelSessionPageForTab: async () => {
|
||||
const oldEpoch = backendRuntimeEpoch;
|
||||
backendRuntimeEpoch = "runtime-channel";
|
||||
backendPendingPrompt = true;
|
||||
for (const handler of rebuiltHandlers) handler("tab-a", backendRuntimeEpoch);
|
||||
for (const handler of eventHandlers) {
|
||||
handler({
|
||||
kind: "ask_request",
|
||||
tabId: "tab-a",
|
||||
runtimeEpoch: oldEpoch,
|
||||
ask: { id: "stale-channel-old-epoch", questions: [{ id: "choice", prompt: "Stale channel", options: [] }] },
|
||||
});
|
||||
handler({
|
||||
kind: "ask_request",
|
||||
tabId: "tab-a",
|
||||
runtimeEpoch: backendRuntimeEpoch,
|
||||
ask: { id: "pre-response-channel", questions: [{ id: "choice", prompt: "Before channel RPC returns", options: [] }] },
|
||||
});
|
||||
}
|
||||
await channelRPCGate.promise;
|
||||
return {
|
||||
messages: [{ role: "user", content: "channel" }, { role: "assistant", content: "waiting" }],
|
||||
startTurn: 0,
|
||||
endTurn: 1,
|
||||
totalTurns: 1,
|
||||
hasOlder: false,
|
||||
};
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
@@ -262,11 +335,40 @@ await act(async () => {
|
||||
|
||||
eq(controller?.state.items.length, 0, "stale history load cannot repopulate a new blank session");
|
||||
|
||||
let resumePromise: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
await controller?.resumeSession("/sessions/restored.jsonl", "tab-a");
|
||||
resumePromise = controller?.resumeSession("/sessions/restored.jsonl", "tab-a");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.ask?.id, "pre-response-resume", "Resume accepts the new-epoch ask and rejects the interleaved old-epoch ask before returning");
|
||||
await act(async () => {
|
||||
resumeRPCGate.resolve();
|
||||
await resumePromise;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.meta?.canonicalTodos?.[0]?.status, "completed", "resuming a session refreshes its authoritative canonical todo state");
|
||||
eq(controller?.state.ask?.id, "replayed-runtime-resumed", "Resume RPC ask emitted before return is restored after reset/history hydration");
|
||||
ok(promptReplayCalls > 0, "Resume completion performs a tab-scoped pending-prompt replay");
|
||||
|
||||
backendPendingPrompt = false;
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler({ kind: "turn_done", tabId: "tab-a", runtimeEpoch: backendRuntimeEpoch });
|
||||
await flushPromises();
|
||||
});
|
||||
const replayCallsBeforeChannelOpen = promptReplayCalls;
|
||||
let channelPromise: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
channelPromise = controller?.openChannelSession("/sessions/channel.jsonl", "tab-a");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.ask?.id, "pre-response-channel", "channel-open accepts the new-epoch ask and rejects the interleaved old-epoch ask before returning");
|
||||
await act(async () => {
|
||||
channelRPCGate.resolve();
|
||||
await channelPromise;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.ask?.id, "replayed-runtime-channel", "channel-open ask emitted before return is restored after reset/history hydration");
|
||||
ok(promptReplayCalls > replayCallsBeforeChannelOpen, "channel-open completion performs a tab-scoped pending-prompt replay");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
||||
@@ -274,6 +274,18 @@ eq(replayed.promptArrivedId, "plan-1", "same-id replay keeps the anchor id");
|
||||
eq(activated.promptArrivedAt, undefined, "activation drops the prompt arrival time");
|
||||
}
|
||||
|
||||
// A tab-tagged ask may arrive while its session is in the background. When
|
||||
// the tab metadata also reports pendingPrompt, activation must not erase the
|
||||
// only actionable copy while a scoped backend replay is still in flight.
|
||||
{
|
||||
const backgroundAsk = reducer({ ...initialState }, { type: "event", e: askEvent });
|
||||
const activated = reducer(backgroundAsk, { type: "backend_activation_start", backendPendingPrompt: true });
|
||||
eq(activated.ask?.id, "ask-1", "confirmed background ask survives activation start");
|
||||
eq(activated.pendingPrompt, true, "confirmed background ask keeps the prompt gate");
|
||||
eq(activated.running, true, "confirmed background ask keeps the turn running");
|
||||
eq(activated.promptArrivedId, "ask-1", "confirmed background ask keeps its freshness anchor");
|
||||
}
|
||||
|
||||
// A new user turn drops the anchor so the next turn's prompts re-anchor fresh.
|
||||
{
|
||||
const armed = reducer({ ...initialState }, { type: "event", e: planApprovalEvent });
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
enqueueProjectTreeArchive,
|
||||
projectTreeTrashingTopics,
|
||||
runProjectTreeArchiveJob,
|
||||
} from "../lib/projectTreeArchive";
|
||||
import {
|
||||
invalidateProjectTreeTopicLoads,
|
||||
projectTreeWithoutTopics,
|
||||
} from "../lib/projectTreeTopic";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function testLatePreArchivePageCannotReinsertTopic() {
|
||||
const sequences: Record<string, number> = { project: 1 };
|
||||
const capturedSequence = sequences.project;
|
||||
const latePage = deferred<ProjectNode[]>();
|
||||
const applied = latePage.promise.then((items) =>
|
||||
sequences.project === capturedSequence ? items : [],
|
||||
);
|
||||
|
||||
invalidateProjectTreeTopicLoads(sequences, ["project"]);
|
||||
latePage.resolve([{ key: "topic-a", kind: "topic", label: "A", topicId: "topic-a" }]);
|
||||
assert.deepEqual(await applied, []);
|
||||
}
|
||||
|
||||
async function testPendingTombstonesFilterEveryIncomingPage() {
|
||||
const incoming: ProjectNode[] = [
|
||||
{ key: "topic-a", kind: "topic", label: "A", topicId: "topic-a" },
|
||||
{ key: "topic-b", kind: "topic", label: "B", topicId: "topic-b" },
|
||||
{ key: "topic-c", kind: "topic", label: "C", topicId: "topic-c" },
|
||||
];
|
||||
assert.deepEqual(
|
||||
projectTreeWithoutTopics(incoming, new Set(["topic-a", "topic-b"])).map((node) => node.topicId),
|
||||
["topic-c"],
|
||||
);
|
||||
}
|
||||
|
||||
async function testPostCommitRestoreCanWinWhilePendingIndicatorFinishes() {
|
||||
const pending = projectTreeTrashingTopics(new Set(), "topic-a", true);
|
||||
const tombstones = projectTreeTrashingTopics(new Set(), "topic-a", true);
|
||||
const sequences: Record<string, number> = { project: 2 };
|
||||
|
||||
// Starting the post-commit canonical load fences every pre-commit response,
|
||||
// then releases only the tombstone. The visible pending state may remain
|
||||
// until that load finishes without hiding a legitimate later restore.
|
||||
invalidateProjectTreeTopicLoads(sequences, ["project"]);
|
||||
const releasedTombstones = projectTreeTrashingTopics(tombstones, "topic-a", false);
|
||||
const restored = projectTreeWithoutTopics(
|
||||
[{ key: "topic-a", kind: "topic", label: "Restored A", topicId: "topic-a" }],
|
||||
releasedTombstones,
|
||||
);
|
||||
assert.equal(pending.has("topic-a"), true);
|
||||
assert.equal(restored[0]?.topicId, "topic-a");
|
||||
}
|
||||
|
||||
async function testConcurrentArchivesReachBackendSerially() {
|
||||
const firstGate = deferred<void>();
|
||||
const secondGate = deferred<void>();
|
||||
const firstStarted = deferred<void>();
|
||||
const secondStarted = deferred<void>();
|
||||
const calls: string[] = [];
|
||||
let tail = Promise.resolve();
|
||||
|
||||
const first = enqueueProjectTreeArchive(tail, async () => {
|
||||
calls.push("a:start");
|
||||
firstStarted.resolve();
|
||||
await firstGate.promise;
|
||||
calls.push("a:end");
|
||||
});
|
||||
tail = first;
|
||||
const second = enqueueProjectTreeArchive(tail, async () => {
|
||||
calls.push("b:start");
|
||||
secondStarted.resolve();
|
||||
await secondGate.promise;
|
||||
calls.push("b:end");
|
||||
});
|
||||
tail = second;
|
||||
|
||||
await firstStarted.promise;
|
||||
assert.deepEqual(calls, ["a:start"]);
|
||||
firstGate.resolve();
|
||||
await first;
|
||||
await secondStarted.promise;
|
||||
assert.deepEqual(calls, ["a:start", "a:end", "b:start"]);
|
||||
secondGate.resolve();
|
||||
await tail;
|
||||
assert.deepEqual(calls, ["a:start", "a:end", "b:start", "b:end"]);
|
||||
}
|
||||
|
||||
async function testPendingEndsOnlyAfterCanonicalReload() {
|
||||
const reloadGate = deferred<void>();
|
||||
let pending = true;
|
||||
const job = runProjectTreeArchiveJob({
|
||||
archive: async () => {},
|
||||
reload: () => reloadGate.promise,
|
||||
finishPending: () => { pending = false; },
|
||||
recover: async () => {},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(pending, true);
|
||||
reloadGate.resolve();
|
||||
assert.equal(await job, true);
|
||||
assert.equal(pending, false);
|
||||
}
|
||||
|
||||
async function testFailedArchiveRestoresVisibilityBeforeRecoveryReload() {
|
||||
let pending = true;
|
||||
let recoveryObservedPending: boolean | null = null;
|
||||
const job = runProjectTreeArchiveJob({
|
||||
archive: async () => { throw new Error("busy"); },
|
||||
reload: async () => {},
|
||||
finishPending: () => { pending = false; },
|
||||
recover: async () => { recoveryObservedPending = pending; },
|
||||
});
|
||||
|
||||
assert.equal(await job, false);
|
||||
assert.equal(recoveryObservedPending, false);
|
||||
}
|
||||
|
||||
function testProjectTreeWiresEveryRaceGuard() {
|
||||
const source = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), "../components/ProjectTree.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const archiveSource = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), "../lib/projectTreeArchive.ts"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(archiveSource, /invalidateProjectTreeTopicLoads[\s\S]*optimisticallyRemoveTopic\(topicId\)/);
|
||||
assert.match(source, /projectTreeWithoutTopics\(asArray\(page\.items\), currentArchiveTombstones\(\)\)/);
|
||||
assert.match(archiveSource, /archiveQueueRef\.current\.catch[\s\S]*\.then\(async \(\) =>/);
|
||||
assert.match(archiveSource, /pendingLoads = targets\.map[\s\S]*onReloadStarted[\s\S]*await Promise\.all\(pendingLoads\)/);
|
||||
assert.match(archiveSource, /onReloadStarted: \(\) => releaseArchiveTombstone\(topicId\)/);
|
||||
assert.match(archiveSource, /await refreshRef\.current\(reloadOptions\)[\s\S]*finally \{[\s\S]*endTrashingTopic\(topicId\)/);
|
||||
}
|
||||
|
||||
await testLatePreArchivePageCannotReinsertTopic();
|
||||
await testPendingTombstonesFilterEveryIncomingPage();
|
||||
await testPostCommitRestoreCanWinWhilePendingIndicatorFinishes();
|
||||
await testConcurrentArchivesReachBackendSerially();
|
||||
await testPendingEndsOnlyAfterCanonicalReload();
|
||||
await testFailedArchiveRestoresVisibilityBeforeRecoveryReload();
|
||||
testProjectTreeWiresEveryRaceGuard();
|
||||
console.log("project tree archive race: 7 passed");
|
||||
@@ -0,0 +1,48 @@
|
||||
import { mergeProjectTopicPage, projectTreeShellChildren } from "../components/ProjectTree";
|
||||
|
||||
type Equal = (actual: unknown, expected: unknown, label: string) => void;
|
||||
|
||||
export function runProjectTreePinnedShellRuntimeTests(eq: Equal, projectTreeSource: string) {
|
||||
eq(
|
||||
mergeProjectTopicPage(
|
||||
[
|
||||
{ key: "topic-off-page-pin", kind: "topic", label: "Pinned", topicId: "pin", pinned: true },
|
||||
{ key: "topic-stale", kind: "topic", label: "Stale", topicId: "stale" },
|
||||
],
|
||||
[{ key: "topic-page", kind: "topic", label: "First page", topicId: "page" }],
|
||||
false,
|
||||
).map((node) => node.key),
|
||||
["topic-page", "topic-off-page-pin"],
|
||||
"a bounded first page keeps pinned shells that fall beyond the page limit",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShellChildren(undefined, [
|
||||
{ key: "topic_pin", kind: "topic", label: "Pinned", topicId: "pin", pinned: true },
|
||||
]).map((node) => node.topicId),
|
||||
["pin"],
|
||||
"a cold collapsed tree paints pinned topic shells before any folder page loads",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShellChildren(
|
||||
[
|
||||
{ key: "topic_old", kind: "topic", label: "Old pin", topicId: "old", pinned: true },
|
||||
{ key: "topic_regular", kind: "topic", label: "Regular", topicId: "regular" },
|
||||
],
|
||||
[{ key: "topic_new", kind: "topic", label: "New pin", topicId: "new", pinned: true }],
|
||||
).map((node) => ({ topicId: node.topicId, pinned: Boolean(node.pinned) })),
|
||||
[
|
||||
{ topicId: "old", pinned: false },
|
||||
{ topicId: "regular", pinned: false },
|
||||
{ topicId: "new", pinned: true },
|
||||
],
|
||||
"shell refresh removes stale pins and adds newly pinned collapsed topics",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeSource.includes("children: projectTreeShellChildren(previous?.children, project.children)"),
|
||||
true,
|
||||
"shell refresh preserves loaded folders while reconciling pinned topic shells",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { projectTreeApplyRuntimeTopics } from "../lib/projectTreeRuntime";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
|
||||
type RuntimeNode = ProjectNode & { runtimeOnly?: boolean };
|
||||
const catalog: ProjectNode[] = [
|
||||
{ key: "project-a", kind: "project", label: "A", root: "/a", children: [
|
||||
{ key: "known", kind: "topic", label: "Known", root: "/a", topicId: "known", children: [] },
|
||||
] },
|
||||
{ key: "project-b", kind: "project", label: "B", root: "/b", children: [] },
|
||||
];
|
||||
const overlaid = projectTreeApplyRuntimeTopics(catalog, [
|
||||
{ scope: "project", workspaceRoot: "/a", node: {
|
||||
key: "known", kind: "topic", label: "Known live", root: "/a", topicId: "known", running: true, status: "thinking", children: [
|
||||
{ key: "known-a", kind: "session", label: "Session A", topicId: "known", sessionPath: "/a/a.jsonl" },
|
||||
{ key: "known-b", kind: "session", label: "Session B", topicId: "known", sessionPath: "/a/b.jsonl" },
|
||||
],
|
||||
} },
|
||||
{ scope: "project", workspaceRoot: "/b", node: {
|
||||
key: "new", kind: "topic", label: "New live", root: "/b", topicId: "new", running: true, status: "streaming", children: [],
|
||||
} },
|
||||
]);
|
||||
const shape = (tree: ProjectNode[]) => tree.map((project) => project.children?.map((topic) => [
|
||||
topic.topicId, topic.running, (topic as RuntimeNode).runtimeOnly,
|
||||
]));
|
||||
assert.deepEqual(shape(overlaid), [[['known', true, undefined]], [['new', true, true]]]);
|
||||
assert.equal(overlaid[0]?.children?.[0]?.children?.length, 2);
|
||||
assert.deepEqual(shape(projectTreeApplyRuntimeTopics(overlaid, [])), [[['known', undefined, undefined]], []]);
|
||||
assert.equal(projectTreeApplyRuntimeTopics(overlaid, [
|
||||
{ scope: "project", workspaceRoot: "/a", node: {
|
||||
key: "known", kind: "topic", label: "Known live", root: "/a", topicId: "known", running: true, children: [],
|
||||
} },
|
||||
], new Set(["known"]))[0]?.children?.length, 0);
|
||||
assert.equal(projectTreeApplyRuntimeTopics(overlaid, [])[0]?.children?.[0]?.children?.length, 0);
|
||||
|
||||
const projects: ProjectNode[] = Array.from({ length: 100 }, (_, index) => ({
|
||||
key: `p-${index}`, kind: "project", label: `P ${index}`, root: `/p/${index}`, children: [],
|
||||
}));
|
||||
const topics = projects.map((project, index) => ({
|
||||
scope: "project", workspaceRoot: project.root, node: {
|
||||
key: `t-${index}`, kind: "topic" as const, label: `T ${index}`, root: project.root,
|
||||
topicId: `t-${index}`, running: true, status: "thinking" as const, children: [],
|
||||
},
|
||||
}));
|
||||
const hundred = projectTreeApplyRuntimeTopics(projects, topics);
|
||||
assert.equal(hundred.reduce((count, project) => count + (project.children?.filter((topic) => topic.running).length ?? 0), 0), 100);
|
||||
console.log(" PASS project tree runtime projection");
|
||||
@@ -3,8 +3,11 @@
|
||||
import {
|
||||
projectTreeFolderDisclosure,
|
||||
mergeProjectTopicPage,
|
||||
projectTreeWithoutTopic,
|
||||
projectTreeShellChildren,
|
||||
projectTreeEventAffectsFolder,
|
||||
projectTreeRevisionIsFresh,
|
||||
projectTreeTopicPageIsFresh,
|
||||
projectTreeShouldApplyShellSnapshot,
|
||||
defaultExpandedProjectTreeKeys,
|
||||
activeSessionAncestorKeys,
|
||||
@@ -12,6 +15,8 @@ import {
|
||||
projectTreeShouldSuppressOpenForRename,
|
||||
projectTreeReadActivityKey,
|
||||
projectTreeTopicHasUnreadActivity,
|
||||
topicIsActive,
|
||||
topicStatusLabel,
|
||||
projectTreeTopicArchiveBlocked,
|
||||
projectTreeShouldRenderTopicActions,
|
||||
projectTreeTopicMetaLine,
|
||||
@@ -21,9 +26,16 @@ import {
|
||||
projectTreeTopicHoverCardModel,
|
||||
projectTreeTopicMenuOffersPin,
|
||||
projectTreeDedupedExactTime,
|
||||
projectTreeShellSignature,
|
||||
} from "../components/ProjectTree";
|
||||
import { projectTreeTrashingTopics } from "../lib/projectTreeArchive";
|
||||
import { normalizeProjectTreeRuntimeSnapshot } from "../lib/projectTreeRuntime";
|
||||
import { runProjectTreeSortRuntimeTests } from "./project-tree-sort-runtime.test";
|
||||
import { runProjectTreePinnedShellRuntimeTests } from "./project-tree-pinned-shell-runtime.test";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -40,6 +52,12 @@ function eq(a: unknown, b: unknown, label: string) {
|
||||
|
||||
console.log("\nproject tree runtime sessions");
|
||||
|
||||
eq(
|
||||
normalizeProjectTreeRuntimeSnapshot({ revision: 0, topics: null }),
|
||||
{ revision: 0, topics: [] },
|
||||
"runtime bridge normalizes a legacy/null Wails topic array",
|
||||
);
|
||||
|
||||
const noTrashingTopics = new Set<string>();
|
||||
const topicATrashing = projectTreeTrashingTopics(noTrashingTopics, "topic-a", true);
|
||||
const twoTopicsTrashing = projectTreeTrashingTopics(topicATrashing, "topic-b", true);
|
||||
@@ -211,6 +229,34 @@ eq(
|
||||
"running topic keeps runtime status instead of completed-unread attention",
|
||||
);
|
||||
|
||||
const relocatedTopic = { ...completedTopic, sessionPath: "/s/b.jsonl" };
|
||||
const relocatedKey = projectTreeReadActivityKey({ ...completedTopic, sessionPath: "/s/a.jsonl" }) ?? "";
|
||||
eq(
|
||||
projectTreeReadActivityKey(relocatedTopic),
|
||||
relocatedKey,
|
||||
"unread key stays on the logical topic when the representative path changes",
|
||||
);
|
||||
eq(
|
||||
projectTreeTopicHasUnreadActivity(relocatedTopic, { [relocatedKey]: 2000 }, "project", "/repo", "other-topic"),
|
||||
false,
|
||||
"marking a topic read survives a later representative-path refresh",
|
||||
);
|
||||
eq(
|
||||
projectTreeTopicHasUnreadActivity(completedTopic, {}, "project", "/repo", "other-topic", undefined, 2000),
|
||||
false,
|
||||
"activity at or before the first-seen baseline is not unread",
|
||||
);
|
||||
eq(
|
||||
projectTreeTopicHasUnreadActivity(completedTopic, {}, "project", "/repo", "other-topic", undefined, 1999),
|
||||
true,
|
||||
"activity newer than the first-seen baseline is unread",
|
||||
);
|
||||
eq(
|
||||
topicIsActive({ ...completedTopic, sessionPath: "/s/a.jsonl" }, "project", "/repo", "topic-complete", "/s/other.jsonl"),
|
||||
true,
|
||||
"logical topic stays active when the representative path is not the open file",
|
||||
);
|
||||
|
||||
for (const status of ["thinking", "streaming", "waiting_confirmation", "background_job"] as const) {
|
||||
eq(
|
||||
projectTreeTopicArchiveBlocked({ ...completedTopic, status, running: true }),
|
||||
@@ -219,7 +265,7 @@ for (const status of ["thinking", "streaming", "waiting_confirmation", "backgrou
|
||||
);
|
||||
}
|
||||
|
||||
for (const status of ["paused", "error"] as const) {
|
||||
for (const status of ["paused", "error", "awaiting_delivery"] as const) {
|
||||
eq(
|
||||
projectTreeTopicArchiveBlocked({ ...completedTopic, status, running: true }),
|
||||
false,
|
||||
@@ -227,6 +273,17 @@ for (const status of ["paused", "error"] as const) {
|
||||
);
|
||||
}
|
||||
|
||||
eq(
|
||||
topicStatusLabel({ ...completedTopic, status: "awaiting_delivery" }, testT),
|
||||
"projectTree.status.awaitingDelivery",
|
||||
"delivery-check pause uses its own sidebar label, not paused",
|
||||
);
|
||||
eq(
|
||||
topicStatusLabel({ ...completedTopic, status: "paused" }, testT),
|
||||
"projectTree.status.paused",
|
||||
"recovery pause keeps the paused sidebar label",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeTopicArchiveBlocked({ ...completedTopic, running: true }),
|
||||
true,
|
||||
@@ -631,6 +688,15 @@ eq(
|
||||
"project tree ignores stale snapshots and pages while accepting the current revision",
|
||||
);
|
||||
|
||||
eq(
|
||||
[
|
||||
projectTreeTopicPageIsFresh({ "project-a": 11, "project-b": 9 }, "project-a", 10),
|
||||
projectTreeTopicPageIsFresh({ "project-a": 11, "project-b": 9 }, "project-b", 10),
|
||||
],
|
||||
[false, true],
|
||||
"a newer revision in one project does not discard another project's slower page",
|
||||
);
|
||||
|
||||
eq(
|
||||
[
|
||||
projectTreeShouldApplyShellSnapshot({ currentRevision: 1, incomingRevision: 0, treeEmpty: true }),
|
||||
@@ -657,6 +723,44 @@ eq(
|
||||
"overlapping keyset pages replace duplicates without changing stable order",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeWithoutTopic(
|
||||
[
|
||||
{
|
||||
key: "p",
|
||||
kind: "project",
|
||||
label: "P",
|
||||
children: [
|
||||
{ key: "topic_archive", kind: "topic", label: "Archive me", topicId: "topic_archive" },
|
||||
{ key: "topic_keep", kind: "topic", label: "Keep me", topicId: "topic_keep" },
|
||||
],
|
||||
},
|
||||
],
|
||||
"topic_archive",
|
||||
).map((node) => (node.children ?? []).map((child) => child.topicId)),
|
||||
[["topic_keep"]],
|
||||
"archiving a topic removes it from loaded children before the catalog reloads",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShellChildren(
|
||||
[{ key: "topic_keep", kind: "topic", label: "Keep me", topicId: "topic_keep" }],
|
||||
),
|
||||
[{ key: "topic_keep", kind: "topic", label: "Keep me", topicId: "topic_keep" }],
|
||||
"a mutation refresh keeps sibling conversations visible until the replacement page arrives",
|
||||
);
|
||||
|
||||
const projectTreeSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../components/ProjectTree.tsx"), "utf8");
|
||||
const projectTreeArchiveSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../lib/projectTreeArchive.ts"), "utf8");
|
||||
eq(projectTreeSource.includes("projectTreeWithoutTopic("), true, "TrashTopic removes the archived row before the shell refresh");
|
||||
eq(
|
||||
projectTreeArchiveSource.includes("reloadTopicKeys"),
|
||||
true,
|
||||
"archive refresh reloads only the affected topic folder after preserving the painted siblings",
|
||||
);
|
||||
runProjectTreePinnedShellRuntimeTests(eq, projectTreeSource);
|
||||
await runProjectTreeSortRuntimeTests(eq, projectTreeSource);
|
||||
|
||||
eq(
|
||||
[
|
||||
projectTreeEventAffectsFolder({ key: "global", kind: "global_folder", label: "Global" }, [""]),
|
||||
@@ -667,5 +771,22 @@ eq(
|
||||
"revision events refresh only affected expanded roots, with an empty roots list as broadcast",
|
||||
);
|
||||
|
||||
const shellWithTopics = (label: string): ProjectNode => ({
|
||||
key: "p1",
|
||||
kind: "project",
|
||||
label,
|
||||
root: "/repo",
|
||||
children: [{ key: "t1", kind: "topic", label: "T1", topicId: "t1" }],
|
||||
});
|
||||
eq(
|
||||
[
|
||||
projectTreeShellSignature([{ key: "p1", kind: "project", label: "P" }]),
|
||||
projectTreeShellSignature([shellWithTopics("P")]),
|
||||
projectTreeShellSignature([{ key: "p1", kind: "project", label: "P" }, { key: "p2", kind: "project", label: "P2" }]),
|
||||
],
|
||||
["p1", "p1", "p1\u001fp2"],
|
||||
"shell signature tracks project arrivals only, so topic page loads cannot re-arm the reload effect",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -6,6 +6,9 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const topic = readFileSync(join(root, "lib/projectTreeTopic.ts"), "utf8");
|
||||
const runtime = readFileSync(join(root, "lib/projectTreeRuntime.ts"), "utf8");
|
||||
const runtimeHook = readFileSync(join(root, "lib/useProjectTreeRuntimeProjection.ts"), "utf8");
|
||||
const bridge = readFileSync(join(root, "lib/bridge.ts"), "utf8");
|
||||
const panel = readFileSync(join(root, "components/ProjectTree.tsx"), "utf8");
|
||||
|
||||
assert.match(topic, /projectTreeShouldApplyShellSnapshot/, "shell race helper exported");
|
||||
@@ -13,15 +16,46 @@ assert.match(topic, /treeEmpty/, "empty-tree shells bypass catalog revision wate
|
||||
assert.match(panel, /projectTreeShouldApplyShellSnapshot/, "ProjectTree uses shell race helper");
|
||||
assert.match(panel, /treeRef\.current\.length === 0/, "v2 event re-fetches shell when tree is empty");
|
||||
assert.match(panel, /void refresh\(\)/, "empty-tree event path calls refresh");
|
||||
assert.match(
|
||||
panel,
|
||||
/projectTreeTopicPageIsFresh\(topicRevisionRef\.current, key, page\.revision\)/,
|
||||
"topic pages compare revisions within their own project",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
panel,
|
||||
/projectTreeRevisionIsFresh\(latestRevisionRef\.current, page\.revision\)/,
|
||||
"an unrelated project's revision cannot discard a valid slower topic page",
|
||||
);
|
||||
assert.match(
|
||||
panel,
|
||||
/onProjectTreeChangedV2[\s\S]*projectTreeRevisionIsFresh\(latestRevisionRef\.current, event\.revision\)/,
|
||||
"equal-revision overlay events use the shared freshness contract",
|
||||
"equal-revision catalog events use the shared freshness contract",
|
||||
);
|
||||
assert.match(runtime, /onProjectTreeRuntimeChanged/, "runtime projection has a dedicated Wails subscription");
|
||||
assert.match(runtimeHook, /bindProjectTreeRuntime/, "ProjectTree binds the runtime projection after mount");
|
||||
assert.match(runtimeHook, /GetProjectTreeRuntimeSnapshot/, "runtime subscription reconciles with a post-subscribe snapshot");
|
||||
assert.match(bridge, /\?\.reason !== "runtime"/, "current frontend ignores tagged legacy runtime invalidations");
|
||||
assert.doesNotMatch(
|
||||
runtime,
|
||||
/ListProjectTopics/,
|
||||
"runtime events never reload catalog topic pages",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
panel,
|
||||
/event\.revision\s*<=\s*latestRevisionRef\.current/,
|
||||
"equal-revision tombstone overlays are not discarded",
|
||||
);
|
||||
assert.doesNotMatch(panel, /setIndexingDone\(/, "topic refresh no longer waits for a global all-directories gate");
|
||||
assert.match(panel, /projectTreeEventAffectsFolder\(project, affected\)/, "directory revisions refresh only affected expanded projects");
|
||||
assert.match(
|
||||
panel,
|
||||
/projectTreeShellSignature/,
|
||||
"debounced reload observes project arrivals through a shell signature",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
panel,
|
||||
/\[\s*expanded,\s*loadProjectTopics,\s*query,\s*timeFilter,\s*tree\s*\]/,
|
||||
"debounced reload depends on the shell signature, not tree (topic loads would re-arm it forever)",
|
||||
);
|
||||
|
||||
console.log(" PASS project tree shell race contract");
|
||||
@@ -0,0 +1,34 @@
|
||||
type Equal = (actual: unknown, expected: unknown, label: string) => void;
|
||||
|
||||
export async function runProjectTreeSortRuntimeTests(eq: Equal, projectTreeSource: string) {
|
||||
eq(
|
||||
projectTreeSource.includes("sortMode,")
|
||||
&& projectTreeSource.includes("workbenchSortModeRef.current"),
|
||||
true,
|
||||
"topic page requests carry the selected conversation sort mode",
|
||||
);
|
||||
eq(
|
||||
projectTreeSource.includes("topicLoadSeqRef.current[key] += 1")
|
||||
&& projectTreeSource.includes("void loadProjectTopics(project)"),
|
||||
true,
|
||||
"changing the conversation sort mode invalidates old pages and reloads immediately",
|
||||
);
|
||||
|
||||
const loadSequences: Record<string, number> = { "project-a": 1 };
|
||||
const staleGeneration = loadSequences["project-a"];
|
||||
let resolveStaleLoad: (() => void) | undefined;
|
||||
let staleLoadApplied = false;
|
||||
const staleLoad = new Promise<void>((resolve) => {
|
||||
resolveStaleLoad = resolve;
|
||||
}).then(() => {
|
||||
if (loadSequences["project-a"] === staleGeneration) staleLoadApplied = true;
|
||||
});
|
||||
for (const key in loadSequences) loadSequences[key] += 1;
|
||||
resolveStaleLoad?.();
|
||||
await staleLoad;
|
||||
eq(staleLoadApplied, false, "a delayed old-sort response cannot apply after sort invalidation");
|
||||
|
||||
const currentGeneration = ++loadSequences["project-a"];
|
||||
eq(loadSequences["project-a"] === currentGeneration, true, "the first request for the new sort remains current");
|
||||
eq(projectTreeSource.includes("topicPageStateRef.current = {}"), true, "sort invalidation clears pagination cursors before the new first page loads");
|
||||
}
|
||||
@@ -53,6 +53,9 @@ ok(getReasoningDisplayMode() === "hidden", "explicit new mode wins over every le
|
||||
hydrateReasoningDisplayMode("summary", true);
|
||||
ok(getReasoningDisplayMode() === "summary", "an explicit summary selection remains persisted");
|
||||
|
||||
hydrateReasoningDisplayMode("expanded", true);
|
||||
ok(getReasoningDisplayMode() === "expanded", "an explicit expanded selection remains persisted");
|
||||
|
||||
storage.set("reasonix-reasoning-summary", "0");
|
||||
ok(resolveReasoningDisplayMode("auto", false) === "legacy-collapsed", "resolver applies legacy precedence without hydration");
|
||||
hydrateReasoningDisplayMode("auto", false);
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
// Run: tsx src/__tests__/running-tab-history-hydration.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { useController } from "../lib/useController";
|
||||
import { historySliceFromMessages } from "./mockHistorySlice";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, JobView, Meta, TabMeta, WireEvent } 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 flushPromises(): 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 flushPromises();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function tabMeta(id: string, overrides: Partial<TabMeta> = {}): TabMeta {
|
||||
const workspaceRoot = `/repo/${id}`;
|
||||
return {
|
||||
id,
|
||||
scope: "project",
|
||||
workspaceRoot,
|
||||
workspaceName: id,
|
||||
workspacePath: workspaceRoot,
|
||||
gitBranch: "main",
|
||||
topicId: `topic-${id}`,
|
||||
topicTitle: id,
|
||||
sessionPath: `${workspaceRoot}/sessions/${id}.jsonl`,
|
||||
label: `model-${id}`,
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: false,
|
||||
cwd: workspaceRoot,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function metaFor(tab: TabMeta): Meta {
|
||||
return {
|
||||
label: tab.label,
|
||||
ready: tab.ready,
|
||||
startupErr: tab.startupErr,
|
||||
eventChannel: "agent:event",
|
||||
cwd: tab.cwd || tab.workspaceRoot,
|
||||
workspaceRoot: tab.workspaceRoot,
|
||||
workspaceName: tab.workspaceName,
|
||||
workspacePath: tab.workspacePath,
|
||||
sessionPath: tab.sessionPath,
|
||||
sessionRevision: tab.sessionRevision,
|
||||
sessionDigest: tab.sessionDigest,
|
||||
gitBranch: tab.gitBranch,
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: tab.collaborationMode ?? "normal",
|
||||
toolApprovalMode: tab.toolApprovalMode ?? "ask",
|
||||
tokenMode: tab.tokenMode ?? "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
};
|
||||
}
|
||||
|
||||
function userMessage(content: string): HistoryMessage {
|
||||
return { role: "user", content };
|
||||
}
|
||||
|
||||
console.log("\nrunning tab history hydration");
|
||||
|
||||
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.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const context: ContextInfo = { used: 12, window: 100, sessionTokens: 12 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
const balance: BalanceInfo = { available: false, display: "" };
|
||||
const jobs: JobView[] = [];
|
||||
const checkpoints: CheckpointMeta[] = [];
|
||||
|
||||
const tabA = tabMeta("tab-a", { active: true });
|
||||
// The session the user clicks into: it has been running for a while, so the
|
||||
// backend reports it running before hydration finishes.
|
||||
const tabR = tabMeta("tab-r");
|
||||
const tabsById = new Map([tabA, tabR].map((tab) => [tab.id, tab]));
|
||||
const runningTabs = new Set<string>(["tab-r"]);
|
||||
let backendActiveId = "tab-a";
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
const readyHandlers: Array<(tabId?: string) => void> = [];
|
||||
|
||||
function currentTabs(): TabMeta[] {
|
||||
return Array.from(tabsById.values()).map((tab) => {
|
||||
const running = runningTabs.has(tab.id);
|
||||
return { ...tab, active: tab.id === backendActiveId, running, cancellable: running };
|
||||
});
|
||||
}
|
||||
|
||||
function historyFor(tabID: string): HistoryMessage[] {
|
||||
if (tabID === "tab-r") return [userMessage("history R 1"), userMessage("history R 2")];
|
||||
if (tabID === "tab-s") return [userMessage("history S")];
|
||||
return [userMessage("cached A")];
|
||||
}
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: (name: string, cb: (...data: unknown[]) => void) => {
|
||||
if (name === "agent:event") eventHandlers.push(cb as (e: WireEvent) => void);
|
||||
if (name === "agent:ready") readyHandlers.push(cb as (tabId?: string) => void);
|
||||
return () => {};
|
||||
},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => currentTabs(),
|
||||
MetaForTab: async (tabID: string) => metaFor(tabsById.get(tabID) ?? tabA),
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async (tabID: string) => historyFor(tabID),
|
||||
HistoryPageForTab: async (tabID: string) => {
|
||||
const messages = historyFor(tabID);
|
||||
const turns = messages.filter((message) => message.role === "user").length;
|
||||
return { messages, startTurn: 0, endTurn: turns, totalTurns: turns, hasOlder: false };
|
||||
},
|
||||
HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) =>
|
||||
historySliceFromMessages(tabID, historyFor(tabID), req),
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
SetActiveTab: async (tabID: string) => {
|
||||
backendActiveId = tabID;
|
||||
},
|
||||
CancelTab: async (tabID: string) => {
|
||||
runningTabs.delete(tabID);
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
"initial active tab hydrated",
|
||||
() => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"),
|
||||
);
|
||||
|
||||
// Clicking a session that the backend already reports as running: the tab has
|
||||
// no cached transcript, so hydration is the only thing that can put the
|
||||
// conversation on screen.
|
||||
await act(async () => {
|
||||
void controller?.switchTab("tab-r", { ...tabR, running: true, cancellable: true });
|
||||
await flushPromises();
|
||||
});
|
||||
await settle();
|
||||
|
||||
eq(controller?.activeTabId, "tab-r", "switching to the running session activates its tab");
|
||||
eq(controller?.state.running, true, "the running session keeps its live status");
|
||||
ok(
|
||||
controller?.state.items.some((item) => item.kind === "user" && item.text === "history R 1") ?? false,
|
||||
"an uncached running session still hydrates its persisted history",
|
||||
);
|
||||
ok(
|
||||
controller?.state.items.some((item) => item.kind === "user" && item.text === "history R 2") ?? false,
|
||||
"the whole history page lands, not just the newest turn",
|
||||
);
|
||||
|
||||
// Second door: a session that is mid-stream when it is opened. Its live text
|
||||
// makes the transcript look cached, which used to skip the history fetch
|
||||
// outright and leave the streaming turn floating over an empty transcript.
|
||||
tabsById.set("tab-s", tabMeta("tab-s"));
|
||||
runningTabs.add("tab-s");
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) {
|
||||
handler({ kind: "turn_started", tabId: "tab-s" } as WireEvent);
|
||||
handler({ kind: "text", tabId: "tab-s", text: "streaming S" } as WireEvent);
|
||||
}
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
void controller?.switchTab("tab-s", { ...tabsById.get("tab-s")!, running: true, cancellable: true });
|
||||
await flushPromises();
|
||||
});
|
||||
await settle();
|
||||
|
||||
eq(controller?.activeTabId, "tab-s", "switching to the streaming session activates its tab");
|
||||
ok(
|
||||
controller?.state.items.some((item) => item.kind === "user" && item.text === "history S") ?? false,
|
||||
"a mid-stream session still fetches and installs its history",
|
||||
);
|
||||
ok(controller?.state.live !== undefined, "installing history leaves the live stream alone");
|
||||
eq(controller?.state.items[0]?.kind, "user", "the persisted page lands in front of the streaming turn");
|
||||
eq(controller?.state.items[controller.state.items.length - 1]?.kind, "assistant", "the streaming turn stays at the tail");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,88 @@
|
||||
// Run: tsx src/__tests__/search-footnotes-render.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import { registerHooks } from "node:module";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { AssistantMessage } from "../components/Message";
|
||||
import { hydrateReasoningDisplayMode } from "../lib/reasoningDisplayPreference";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nsearch footnote render");
|
||||
|
||||
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, language: "en-US" } });
|
||||
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.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
hydrateReasoningDisplayMode("hidden", true);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<AssistantMessage
|
||||
item={{
|
||||
kind: "assistant",
|
||||
id: "a1",
|
||||
text: "answer only",
|
||||
reasoning: "",
|
||||
streaming: false,
|
||||
searchSources: [{ title: "新闻本文", url: "https://example.com/a" }],
|
||||
}}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const body = document.querySelector(".msg__body");
|
||||
const footnotes = document.querySelector(".msg-search-sources");
|
||||
const title = footnotes?.querySelector("strong");
|
||||
const link = footnotes?.querySelector("a");
|
||||
ok(Boolean(body?.textContent?.includes("answer only")), "answer body keeps the model text");
|
||||
ok(Boolean(footnotes), "footnotes render under the answer");
|
||||
ok(title?.textContent === "新闻本文", "footnote shows the result title");
|
||||
ok(Boolean(link), "footnote title list keeps a rendered link");
|
||||
ok(!(document.querySelector(".msg__body > .md")?.textContent ?? "").includes("新闻本文"), "answer markdown does not include the search title");
|
||||
|
||||
if (failed) {
|
||||
process.stdout.write(`\n${failed} failed, ${passed} passed\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(`\n${passed} passed\n`);
|
||||
@@ -0,0 +1,70 @@
|
||||
// Run: tsx src/__tests__/search-sources.test.ts
|
||||
|
||||
import {
|
||||
formatSearchFootnotesMarkdown,
|
||||
mergeSearchSources,
|
||||
parseSearchSources,
|
||||
searchSourcesFromHistory,
|
||||
} from "../lib/searchSources";
|
||||
import { historyMessagesToItems, initialState, reducer } from "../lib/useController";
|
||||
import type { HistoryMessage, WireEvent } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nsearch footnotes");
|
||||
|
||||
eq(
|
||||
formatSearchFootnotesMarkdown([{ title: "Change Log", url: "https://api-docs.deepseek.com/updates/" }, { title: "No URL" }]),
|
||||
"\n- **Change Log**\n <https://api-docs.deepseek.com/updates/>\n- **No URL**\n",
|
||||
"footnotes reuse the title-and-autolink list",
|
||||
);
|
||||
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(mergeSearchSources([{ title: "A", url: "https://a.example" }], [{ title: "A", url: "https://a.example" }]).length, 1, "duplicate hits collapse");
|
||||
|
||||
const history = historyMessagesToItems([{
|
||||
role: "assistant",
|
||||
content: "answer only",
|
||||
serverSearch: [{
|
||||
id: "s1",
|
||||
query: "bitcoin",
|
||||
results: [{ title: "新闻本文", url: "https://example.com/a" }],
|
||||
}],
|
||||
}] as HistoryMessage[], "h-").items;
|
||||
const answer = history.find((item) => item.kind === "assistant");
|
||||
eq(answer?.kind === "assistant" ? answer.text : "", "answer only", "answer text stays model-only");
|
||||
eq(answer?.kind === "assistant" ? answer.searchSources?.[0]?.title : "", "新闻本文", "history hydrates footnotes on the answer");
|
||||
|
||||
function ev(s: typeof initialState, e: WireEvent) {
|
||||
return reducer(s, { type: "event", e });
|
||||
}
|
||||
|
||||
let live = ev(initialState, { kind: "turn_started" });
|
||||
live = ev(live, {
|
||||
kind: "tool_result",
|
||||
tool: { id: "s1", name: "web_search", readOnly: true, output: "新闻本文\nhttps://example.com/a" },
|
||||
} as WireEvent);
|
||||
live = ev(live, { kind: "text", text: "answer only" });
|
||||
live = ev(live, { kind: "message", text: "answer only" });
|
||||
const liveAnswer = live.items.find((item) => item.kind === "assistant");
|
||||
eq(liveAnswer?.kind === "assistant" ? liveAnswer.text : "", "answer only", "live answer stays model-only");
|
||||
eq(liveAnswer?.kind === "assistant" ? liveAnswer.searchSources?.[0]?.title : "", "新闻本文", "live tool result attaches footnotes to the answer");
|
||||
eq(searchSourcesFromHistory([{ results: [{ title: "A" }] }])[0]?.title, "A", "history helper reads structured hits");
|
||||
|
||||
if (failed) {
|
||||
process.stdout.write(`\n${failed} failed, ${passed} passed\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(`\n${passed} passed\n`);
|
||||
@@ -3,7 +3,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { acceptsRuntimeEventEpoch, initialState, normalizeTurnSubmit, reducer, replayPendingPromptsForActiveTab, runtimeReadyForSubmit } from "../lib/useController";
|
||||
import { acceptsRuntimeEventEpoch, historyMessagesToItems, initialState, normalizeTurnSubmit, reducer, replayPendingPromptsForActiveTab, runtimeReadyForSubmit } from "../lib/useController";
|
||||
import { continueDelivery } from "../lib/deliveryContinue";
|
||||
import {
|
||||
activateGoalAndSubmit,
|
||||
@@ -181,7 +181,7 @@ const readinessNotice = readinessState.items[readinessState.items.length - 1];
|
||||
eq(readinessNotice.kind, "notice", "final readiness appends a notice");
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.level, "info", "final readiness uses informational severity");
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.variant, "delivery", "final readiness uses the delivery status treatment");
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.title, "Delivery checks are not complete", "final readiness uses localized product copy");
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.title, "Task completion checks are not complete", "final readiness uses localized product copy");
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.detail, "Still needed: verification, change review", "structured requirements produce localized detail");
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.action, "continue_delivery", "final readiness offers a recovery action");
|
||||
const readinessUser = readinessState.items.find((it) => it.kind === "user");
|
||||
@@ -189,6 +189,17 @@ eq(readinessUser?.kind === "user" && Boolean(readinessUser.failed), false, "fina
|
||||
eq(readinessState.running, false, "an unclicked continue-check action does not keep the turn running");
|
||||
eq(readinessState.pendingPrompt, false, "an unclicked continue-check action does not create a pending prompt");
|
||||
|
||||
const reloadedReadiness = historyMessagesToItems([{
|
||||
role: "notice",
|
||||
content: "Task status needs one more check; continue the remaining work.",
|
||||
code: "final_readiness",
|
||||
level: "info",
|
||||
pending: true,
|
||||
readiness: { attempts: 1, missing: ["verification"] },
|
||||
}], "h").items[0];
|
||||
eq(reloadedReadiness.kind === "notice" && reloadedReadiness.action, "continue_delivery", "reloaded readiness metadata restores the explicit action");
|
||||
eq(reloadedReadiness.kind === "notice" && reloadedReadiness.detail, "Still needed: verification", "reloaded readiness metadata restores structured detail");
|
||||
|
||||
const recovering = reducer(readinessState, { type: "user", text: "Continue checks", seq: readinessState.seq, submissionId: "recovery-submit", deliveryRecovery: true });
|
||||
const recovered = reducer(recovering, { type: "event", e: { kind: "turn_done", submissionId: "recovery-submit" } as WireEvent });
|
||||
eq(recovered.items.some((it) => it.kind === "notice" && it.variant === "delivery"), false, "successful explicit recovery removes the stale delivery card");
|
||||
|
||||
@@ -133,7 +133,7 @@ ok(
|
||||
settingsSource.includes('settings.displayMode') &&
|
||||
settingsSource.includes('["standard", "compact"]') &&
|
||||
settingsSource.includes('settings.reasoningDisplay') &&
|
||||
settingsSource.includes('["hidden", "summary", "auto"]') &&
|
||||
settingsSource.includes('["hidden", "summary", "auto", "expanded"]') &&
|
||||
settingsSource.includes('settings.processFold') &&
|
||||
settingsSource.includes('["auto", "expanded"]') &&
|
||||
settingsSource.includes('setProcessFoldPreference(pref)') &&
|
||||
|
||||
@@ -116,6 +116,50 @@ console.log("\nsubagent progress card");
|
||||
hydrateReasoningDisplayMode("summary", true);
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
hydrateReasoningDisplayMode("expanded", true);
|
||||
const running = makeItem("reasoning");
|
||||
await act(async () => {
|
||||
root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { item: running })));
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
await flushTimers();
|
||||
if (document.querySelector(".tool__subagent-preview .md")) break;
|
||||
}
|
||||
});
|
||||
ok(!!document.querySelector(".tool__subagent-preview .md"), "expanded mode opens live sub-agent reasoning");
|
||||
|
||||
const completed = makeItem("completed", { durationMs: 42_000 });
|
||||
completed.id = running.id;
|
||||
await act(async () => {
|
||||
root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { item: completed })));
|
||||
await flushTimers();
|
||||
});
|
||||
ok(!!document.querySelector(".tool__subagent-preview .md"), "expanded mode keeps completed sub-agent reasoning visible");
|
||||
|
||||
await act(async () => {
|
||||
document.querySelector<HTMLButtonElement>(".tool__head")?.click();
|
||||
await flushTimers();
|
||||
});
|
||||
ok(!document.querySelector(".tool__subagent-preview"), "manual card collapse still wins in expanded mode");
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { key: "completed-history", item: completed })));
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
await flushTimers();
|
||||
if (document.querySelector(".tool__subagent-preview .md")) break;
|
||||
}
|
||||
});
|
||||
ok(!!document.querySelector(".tool__subagent-preview .md"), "expanded mode opens completed sub-agent reasoning restored from history");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
hydrateReasoningDisplayMode("summary", true);
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const rootEl = document.getElementById("root");
|
||||
|
||||
@@ -628,7 +628,7 @@ ok(controller?.state.items.some((item) => item.kind === "user" && item.text ===
|
||||
const tabCUser = controller?.state.items.find((item) => item.kind === "user" && item.text === "streaming C");
|
||||
eq(tabCUser?.kind === "user" && tabCUser.submissionId, tabCSubmissionId, "Wails receives the same opaque correlation stored on the optimistic user");
|
||||
ok(Boolean(tabCSubmissionId) && tabCSubmissionId !== tabCUser?.id, "opaque submission correlation is distinct from the render item id");
|
||||
ok(!historyCalls.includes("tab-c"), "cached running tab skips history hydration");
|
||||
ok(historyCalls.includes("tab-c"), "a running tab with no history page of its own still hydrates one");
|
||||
await act(async () => {
|
||||
submitTabCGate.resolve();
|
||||
await submitTabCGate.promise;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { subjectOf } from "../lib/tools";
|
||||
import { toolGroupKind } from "../components/ToolGroup";
|
||||
import { historyMessagesToItems, isReadOnlyTool } from "../lib/useController";
|
||||
import { historyMessagesToItems, isBatchedReadOnlyTool, isReadOnlyTool } from "../lib/useController";
|
||||
import type { HistoryMessage } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
@@ -40,6 +40,11 @@ eq(
|
||||
"modify",
|
||||
"resolved writer MCP stays in the modify group",
|
||||
);
|
||||
eq(
|
||||
toolGroupKind({ kind: "tool", id: "s1", name: "web_search", args: `{"query":"bitcoin"}`, readOnly: true, status: "done" }),
|
||||
null,
|
||||
"provider web search is not grouped with explore tools",
|
||||
);
|
||||
|
||||
const history = historyMessagesToItems([{
|
||||
role: "assistant",
|
||||
@@ -73,10 +78,29 @@ eq(history[1]?.kind === "tool" ? history[1].readOnly : false, true, "resolved re
|
||||
eq(history[2]?.kind === "tool" ? history[2].readOnly : true, false, "resolved writer history restores writer classification");
|
||||
eq(history[2]?.kind === "tool" ? history[2].resolvedName : "", "mcp__db__write", "history keeps resolved MCP target");
|
||||
eq(isReadOnlyTool("read_file"), true, "legacy built-in reader remains read-only");
|
||||
eq(isReadOnlyTool("web_search"), true, "provider web search cards are read-only");
|
||||
eq(isBatchedReadOnlyTool("web_search", true), false, "provider web search stays a standalone card");
|
||||
eq(isBatchedReadOnlyTool("read_file", true), true, "ordinary readers still batch");
|
||||
eq(isReadOnlyTool("grep"), true, "legacy search reader remains read-only");
|
||||
eq(isReadOnlyTool("use_capability"), false, "legacy unresolved proxy remains fail-closed");
|
||||
eq(isReadOnlyTool("unknown_tool"), false, "unknown history tool remains fail-closed");
|
||||
|
||||
const searchHistory = historyMessagesToItems([{
|
||||
role: "assistant",
|
||||
content: "answer only",
|
||||
serverSearch: [{
|
||||
id: "s1",
|
||||
query: "bitcoin",
|
||||
results: [{ title: "新闻本文", url: "https://example.com/a" }],
|
||||
}],
|
||||
}] as HistoryMessage[], "h-").items;
|
||||
eq(searchHistory[0]?.kind, "tool", "server search hydrates as a tool card before the answer");
|
||||
eq(searchHistory[0]?.kind === "tool" ? searchHistory[0].name : "", "web_search", "server search card uses web_search");
|
||||
eq(searchHistory[0]?.kind === "tool" ? subjectOf("web_search", searchHistory[0].args) : "", "bitcoin", "search card subject is the query");
|
||||
eq(searchHistory[1]?.kind, "assistant", "model text stays a separate assistant item");
|
||||
eq(searchHistory[1]?.kind === "assistant" ? searchHistory[1].text : "", "answer only", "answer text does not include search results");
|
||||
eq(searchHistory[1]?.kind === "assistant" ? searchHistory[1].searchSources?.[0]?.title : "", "新闻本文", "answer hydrates search footnotes");
|
||||
|
||||
if (failed) {
|
||||
process.stdout.write(`\n${failed} failed, ${passed} passed\n`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -134,8 +134,9 @@ const checkpoints: CheckpointMeta[] = [];
|
||||
const tabA = tabMeta("tab-a", { active: true });
|
||||
const tabB = tabMeta("tab-b");
|
||||
const tabC = tabMeta("tab-c");
|
||||
const tabR = tabMeta("tab-r", { running: true, cancellable: true });
|
||||
let backendActiveId = "tab-a";
|
||||
const tabsById = new Map([tabA, tabB, tabC].map((tab) => [tab.id, tab]));
|
||||
const tabsById = new Map([tabA, tabB, tabC, tabR].map((tab) => [tab.id, tab]));
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
const readyHandlers: Array<(tabId?: string) => void> = [];
|
||||
const topicActivationHandlers: Array<(e: TopicActivationEvent) => void> = [];
|
||||
@@ -151,6 +152,7 @@ function emitActivation(event: TopicActivationEvent): void {
|
||||
}
|
||||
|
||||
function historyFor(tabID: string): HistoryMessage[] {
|
||||
if (tabID === "tab-r") return [{ role: "user", content: "帮我查询,现在我链接的 deep" }];
|
||||
return [{ role: "user", content: `history ${tabID}` }];
|
||||
}
|
||||
|
||||
@@ -297,6 +299,55 @@ await act(async () => {
|
||||
});
|
||||
eq(controller?.state.meta?.gitBranch, "feature/x", "tab:meta for a different session binding is discarded");
|
||||
|
||||
// ── switch away from a thinking session and back: keep its transcript ──────
|
||||
await act(async () => {
|
||||
await controller?.activateTopic("project", tabR.workspaceRoot, tabR.topicId ?? "");
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
emitActivation({ requestId: requestIdByTab.get("tab-r") ?? "", tabId: "tab-r", phase: "ready" });
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("running session hydrates on first open", () =>
|
||||
controller?.activeTabId === "tab-r" &&
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "帮我查询,现在我链接的 deep") ?? false),
|
||||
);
|
||||
eq(controller?.state.running, true, "reattached thinking session stays marked running");
|
||||
eq(controller?.state.hydrating, false, "running-session hydrate settles instead of leaving Welcome");
|
||||
|
||||
await act(async () => {
|
||||
await controller?.activateTopic("project", tabB.workspaceRoot, tabB.topicId ?? "");
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
emitActivation({ requestId: requestIdByTab.get("tab-b") ?? "", tabId: "tab-b", phase: "ready" });
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("idle session replaces the thinking surface", () => hasHistory("tab-b") && controller?.state.hydrating === false);
|
||||
|
||||
await act(async () => {
|
||||
await controller?.activateTopic("project", tabR.workspaceRoot, tabR.topicId ?? "");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-r", "switch-back selects the thinking session");
|
||||
eq(
|
||||
controller?.state.hydratePlaceholderItems?.some((item) => item.kind === "user" && item.text === "history tab-b") ?? false,
|
||||
false,
|
||||
"switch-back does not present the other session as a hydration placeholder",
|
||||
);
|
||||
await act(async () => {
|
||||
emitActivation({ requestId: requestIdByTab.get("tab-r") ?? "", tabId: "tab-r", phase: "ready" });
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("switch-back restores the thinking transcript", () =>
|
||||
controller?.activeTabId === "tab-r" &&
|
||||
controller.state.hydrating === false &&
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "帮我查询,现在我链接的 deep") ?? false),
|
||||
);
|
||||
eq(controller?.state.running, true, "switch-back keeps the composer in the live turn");
|
||||
eq(controller?.state.items.some((item) => item.kind === "user" && item.text === "history tab-b") ?? false, false, "switch-back does not keep the other session as the visible transcript");
|
||||
eq(controller?.state.hydratePlaceholderItems?.length ?? 0, 0, "switch-back clears the foreign placeholder after live history lands");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
import type { ReasoningDisplayMode } from "../lib/reasoningDisplayPreference";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
export interface TranscriptHarnessOptions {
|
||||
@@ -18,6 +19,8 @@ export interface TranscriptHarnessOptions {
|
||||
rowHeight?: number;
|
||||
/** Extra localStorage seed values (display mode, fold preference, …). */
|
||||
storage?: Record<string, string>;
|
||||
/** Authoritative reasoning mode to hydrate before Transcript is imported. */
|
||||
reasoningDisplayMode?: ReasoningDisplayMode;
|
||||
}
|
||||
|
||||
export interface TranscriptHarness {
|
||||
@@ -53,6 +56,7 @@ export async function createTranscriptHarness(options: TranscriptHarnessOptions
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.WheelEvent = dom.window.WheelEvent;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window) as typeof getComputedStyle;
|
||||
@@ -185,6 +189,12 @@ export async function createTranscriptHarness(options: TranscriptHarnessOptions
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
if (options.reasoningDisplayMode) {
|
||||
const preference = await server.ssrLoadModule("/src/lib/reasoningDisplayPreference.ts") as {
|
||||
hydrateReasoningDisplayMode: (mode: unknown, explicit: boolean) => void;
|
||||
};
|
||||
preference.hydrateReasoningDisplayMode(options.reasoningDisplayMode, true);
|
||||
}
|
||||
const { TranscriptTestSurface } = await server.ssrLoadModule("/src/__tests__/transcript-test-surface.tsx");
|
||||
const { LocaleProvider } = await server.ssrLoadModule("/src/lib/i18n.tsx");
|
||||
const TranscriptComponent = TranscriptTestSurface as React.ComponentType<Record<string, unknown>>;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Run: tsx src/__tests__/transcript-layout-recovery.test.ts
|
||||
|
||||
import { initialState, reducer, type Item } from "../lib/useController";
|
||||
import { estimateTranscriptRowSize, type TranscriptRow } from "../lib/transcriptRows";
|
||||
import {
|
||||
chooseTranscriptLayoutAnchor,
|
||||
transcriptAnchorInitialLocation,
|
||||
transcriptViewportIsBlank,
|
||||
} from "../lib/transcriptVirtuosoRecovery";
|
||||
|
||||
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 layout recovery");
|
||||
|
||||
const preview: Item = { kind: "assistant", id: "he:answer", text: "preview", reasoning: "", streaming: false };
|
||||
const resolved: Item = { ...preview, text: "resolved ".repeat(2_000) };
|
||||
const patched = reducer(
|
||||
{ ...initialState, items: [preview] },
|
||||
{ type: "history_items_patch", patches: { [preview.id]: resolved } },
|
||||
);
|
||||
check(patched.historyLayoutRevision === 1, "resolved history content invalidates the transcript layout");
|
||||
const ignored = reducer(
|
||||
patched,
|
||||
{ type: "history_items_patch", patches: { missing: resolved } },
|
||||
);
|
||||
check(ignored === patched, "an unrelated content patch does not invalidate the transcript layout");
|
||||
|
||||
const visibleAnchor = chooseTranscriptLayoutAnchor(
|
||||
[
|
||||
{ rowKey: "before", top: -240, bottom: -40 },
|
||||
{ rowKey: "visible", top: 80, bottom: 240 },
|
||||
{ rowKey: "after", top: 260, bottom: 420 },
|
||||
],
|
||||
{ top: 50, bottom: 350 },
|
||||
false,
|
||||
);
|
||||
check(
|
||||
visibleAnchor?.mode === "manual" && visibleAnchor.rowKey === "visible" && visibleAnchor.offset === 30,
|
||||
"manual recovery records the first visible logical row and pixel offset",
|
||||
);
|
||||
|
||||
const nearestAnchor = chooseTranscriptLayoutAnchor(
|
||||
[
|
||||
{ rowKey: "above", top: -400, bottom: -200 },
|
||||
{ rowKey: "below", top: 900, bottom: 1_100 },
|
||||
],
|
||||
{ top: 0, bottom: 500 },
|
||||
false,
|
||||
);
|
||||
check(
|
||||
nearestAnchor?.mode === "manual" && nearestAnchor.rowKey === "above" && nearestAnchor.offset === 0,
|
||||
"blank-viewport recovery snaps the nearest logical row into view",
|
||||
);
|
||||
|
||||
const location = transcriptAnchorInitialLocation(
|
||||
visibleAnchor,
|
||||
new Map([["visible", 7]]),
|
||||
1_000_000,
|
||||
);
|
||||
check(
|
||||
typeof location === "object" && location.index === 1_000_007 && location.align === "start" && location.offset === 30,
|
||||
"manual anchor becomes an absolute Virtuoso restore location",
|
||||
);
|
||||
const tailLocation = transcriptAnchorInitialLocation({ mode: "tail" }, new Map(), 1_000_000);
|
||||
check(
|
||||
typeof tailLocation === "object" && tailLocation.index === "LAST" && tailLocation.align === "end",
|
||||
"tail recovery remains pinned to the last row",
|
||||
);
|
||||
|
||||
check(
|
||||
transcriptViewportIsBlank(
|
||||
[{ rowKey: "above", top: -400, bottom: -200 }, { rowKey: "below", top: 600, bottom: 800 }],
|
||||
{ top: 0, bottom: 500 },
|
||||
true,
|
||||
),
|
||||
"a scrollable viewport with mounted rows only outside its bounds is blank",
|
||||
);
|
||||
check(
|
||||
!transcriptViewportIsBlank([{ rowKey: "visible", top: 100, bottom: 200 }], { top: 0, bottom: 500 }, true),
|
||||
"a row intersecting the viewport is not blank",
|
||||
);
|
||||
|
||||
const shortRow: TranscriptRow = { kind: "answer", key: "a:short", item: preview };
|
||||
const longRow: TranscriptRow = { kind: "answer", key: "a:long", item: resolved };
|
||||
check(
|
||||
estimateTranscriptRowSize(longRow) > estimateTranscriptRowSize(shortRow) * 4,
|
||||
"resolved long-form answers receive a content-aware height estimate",
|
||||
);
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n${failed} transcript layout recovery test(s) failed; ${passed} passed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\n${passed} transcript layout recovery tests passed.`);
|
||||
@@ -83,7 +83,7 @@ const warningTurn: Item[] = [
|
||||
id: "n-delivery",
|
||||
level: "info",
|
||||
variant: "delivery",
|
||||
title: "Delivery checks are not complete",
|
||||
title: "Task completion checks are not complete",
|
||||
text: "The response was generated, but verification and review still need to be completed.",
|
||||
detail: "final-answer readiness failed 3 times: missing verification",
|
||||
action: "continue_delivery",
|
||||
@@ -246,7 +246,99 @@ const warningTurn: Item[] = [
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 3: summaries disabled ─────────────────────────────────────────────
|
||||
// ── Phase 3: expanded reasoning pins its parent process fold ─────────────────
|
||||
{
|
||||
const harness = await createTranscriptHarness({ reasoningDisplayMode: "expanded" });
|
||||
const container = harness.container;
|
||||
try {
|
||||
const runningItems: Item[] = [
|
||||
{ kind: "user", id: "u-expanded-parent", text: "inspect" },
|
||||
{
|
||||
kind: "assistant",
|
||||
id: "a-expanded-parent",
|
||||
text: "done",
|
||||
reasoning: "completed reasoning should remain visible",
|
||||
streaming: true,
|
||||
reasoningComplete: false,
|
||||
workDurationMs: 1_000,
|
||||
},
|
||||
];
|
||||
await render(harness, runningItems, { running: true });
|
||||
await harness.waitFor(
|
||||
() => Boolean(
|
||||
container.querySelector(".turn-collapse--open")
|
||||
&& container.querySelector(".turn-collapse__reasoning-phase--open .md"),
|
||||
),
|
||||
"expanded live reasoning",
|
||||
);
|
||||
ok(Boolean(container.querySelector(".turn-collapse--open")), "expanded reasoning starts with its running parent fold open");
|
||||
ok(Boolean(container.querySelector(".turn-collapse__reasoning-phase--open .md")), "expanded running reasoning renders its full body");
|
||||
|
||||
const completedItems: Item[] = runningItems.map((item) => item.kind === "assistant"
|
||||
? { ...item, streaming: false, reasoningComplete: true }
|
||||
: item);
|
||||
await render(harness, completedItems, { running: false });
|
||||
await harness.waitFor(
|
||||
() => Boolean(
|
||||
container.querySelector(".turn-collapse--open")
|
||||
&& container.querySelector(".turn-collapse__reasoning-phase--open .md"),
|
||||
),
|
||||
"expanded completed reasoning",
|
||||
);
|
||||
ok(Boolean(container.querySelector(".turn-collapse--open")), "expanded reasoning keeps its parent fold open after completion");
|
||||
ok(Boolean(container.querySelector(".turn-collapse__reasoning-phase--open .md")), "expanded completed reasoning remains visible");
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>(".turn-collapse > .reasoning__head")?.click();
|
||||
});
|
||||
await harness.flush();
|
||||
ok(!container.querySelector(".turn-collapse--open"), "manual parent collapse still wins in expanded reasoning mode");
|
||||
ok(!container.querySelector(".turn-collapse__body"), "manual parent collapse unmounts the expanded reasoning body");
|
||||
|
||||
await render(harness, completedItems, { running: false });
|
||||
ok(!container.querySelector(".turn-collapse--open"), "completed rerenders preserve a manual parent collapse");
|
||||
await harness.settle();
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// A completed reasoning fold restored from history has no live transition to
|
||||
// open it, so the expanded-mode default must pin it on first reconciliation.
|
||||
{
|
||||
const harness = await createTranscriptHarness({ reasoningDisplayMode: "expanded" });
|
||||
const container = harness.container;
|
||||
try {
|
||||
await render(harness, [
|
||||
{ kind: "user", id: "u-expanded-history", text: "inspect" },
|
||||
{
|
||||
kind: "assistant",
|
||||
id: "a-expanded-history",
|
||||
text: "done",
|
||||
reasoning: "restored completed reasoning",
|
||||
streaming: false,
|
||||
reasoningComplete: true,
|
||||
workDurationMs: 1_000,
|
||||
},
|
||||
]);
|
||||
await harness.waitFor(
|
||||
() => Boolean(
|
||||
container.querySelector(".turn-collapse--open")
|
||||
&& container.querySelector(".turn-collapse__reasoning-phase--open .md"),
|
||||
),
|
||||
"expanded reasoning restored from history",
|
||||
);
|
||||
ok(Boolean(container.querySelector(".turn-collapse--open")), "expanded history restores the parent reasoning fold open");
|
||||
ok(Boolean(container.querySelector(".turn-collapse__reasoning-phase--open .md")), "expanded history restores completed reasoning visible");
|
||||
await harness.settle();
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 4: summaries disabled ─────────────────────────────────────────────
|
||||
{
|
||||
const harness = await createTranscriptHarness({
|
||||
storage: { "reasonix-process-fold": "expanded", "reasonix-reasoning-summary": "0" },
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Run: tsx src/__tests__/transcript-recovery-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 { useTranscriptVirtuosoRecovery } from "../lib/useTranscriptVirtuosoRecovery";
|
||||
import type { TranscriptRow } from "../lib/transcriptRows";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
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 recovery 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.entries()];
|
||||
frames.clear();
|
||||
await act(async () => pending.forEach(([, callback]) => callback(performance.now())));
|
||||
}
|
||||
|
||||
const scrollElement = dom.window.document.getElementById("scroll") as HTMLDivElement;
|
||||
const rowElement = scrollElement.querySelector<HTMLElement>(".transcript__row")!;
|
||||
scrollElement.getBoundingClientRect = () => ({ top: 0, bottom: 100, height: 100, left: 0, right: 800, width: 800, x: 0, y: 0, toJSON: () => ({}) });
|
||||
rowElement.getBoundingClientRect = () => ({ top: 200, bottom: 300, height: 100, left: 0, right: 800, width: 800, x: 0, y: 200, toJSON: () => ({}) });
|
||||
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 100 });
|
||||
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, value: 500 });
|
||||
|
||||
const item: Item = { kind: "assistant", id: "a", text: "answer", reasoning: "", streaming: false };
|
||||
const rows: TranscriptRow[] = [{ kind: "answer", key: "row-a", item }];
|
||||
const scrollRef = { current: scrollElement };
|
||||
const pinnedRef = { current: false };
|
||||
const readyRef = { current: true };
|
||||
let scrollByCalls = 0;
|
||||
const virtuosoRef = {
|
||||
current: {
|
||||
scrollBy: () => { scrollByCalls += 1; },
|
||||
scrollToIndex: () => undefined,
|
||||
} as unknown as VirtuosoHandle,
|
||||
};
|
||||
let recovery: ReturnType<typeof useTranscriptVirtuosoRecovery> | undefined;
|
||||
|
||||
function Probe({ surfaceKey }: { surfaceKey: string }) {
|
||||
recovery = useTranscriptVirtuosoRecovery({
|
||||
surfaceKey,
|
||||
historyLayoutRevision: 0,
|
||||
rows,
|
||||
rowIndexByKey: new Map([["row-a", 0]]),
|
||||
scrollRef,
|
||||
pinnedRef,
|
||||
virtuosoRef,
|
||||
readyRef,
|
||||
scrollToBottom: () => undefined,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(dom.window.document.getElementById("root")!);
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-a" />));
|
||||
await act(async () => recovery?.scheduleBlankViewportCheck());
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-b" />));
|
||||
await flushFrames();
|
||||
check(recovery?.resetKey === "surface-b:0", "surface switch cancels the previous blank-viewport watchdog");
|
||||
|
||||
await act(async () => recovery?.scheduleBlankViewportCheck());
|
||||
await flushFrames();
|
||||
await flushFrames();
|
||||
check(recovery?.resetKey === "surface-b:1", "blank viewport schedules a controlled size-tree rebuild");
|
||||
await act(async () => recovery?.handleItemsRendered(1));
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-c" />));
|
||||
await flushFrames();
|
||||
check(scrollByCalls === 0, "stale anchor correction cannot scroll the newly selected surface");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n${failed} transcript recovery race test(s) failed; ${passed} passed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\n${passed} transcript recovery race tests passed.`);
|
||||
@@ -135,6 +135,20 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
|
||||
ok(!rows.some((row) => row.kind === "tool-batch"), "creation mode never emits read-only batches");
|
||||
}
|
||||
|
||||
{
|
||||
const models = buildTurnModels([
|
||||
{ kind: "user", id: "u-search", text: "search" },
|
||||
{ kind: "tool", id: "s1", name: "web_search", args: `{"query":"bitcoin"}`, readOnly: true, status: "done" },
|
||||
{ kind: "tool", id: "r1", name: "read_file", args: "{}", readOnly: true, status: "done" },
|
||||
{ kind: "assistant", id: "a-search", text: "answer only", reasoning: "", streaming: false },
|
||||
]);
|
||||
const rows = buildTranscriptRows(models, rowOptions(EMPTY_FOLDS, "expanded"));
|
||||
const searchRow = rows.find((row) => row.kind === "tool" && "item" in row && row.item.id === "s1");
|
||||
const batch = rows.find((row) => row.kind === "tool-batch");
|
||||
ok(Boolean(searchRow), "provider web search stays a standalone tool card");
|
||||
eq(batch && "items" in batch ? batch.items.map((item) => item.name).join(",") : "", "read_file", "ordinary readers still batch beside the search card");
|
||||
}
|
||||
|
||||
{
|
||||
// A fold whose process items are all filtered out (sub-agent subcalls,
|
||||
// todo_write) renders no header row at all.
|
||||
@@ -160,6 +174,20 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
|
||||
eq(kinds(rows), "notice,user,answer,turn-actions", "prelude notices render without a synthetic user row");
|
||||
}
|
||||
|
||||
{
|
||||
const models = buildTurnModels([
|
||||
{ kind: "user", id: "u1", text: "请继续完成 PPT 任务" },
|
||||
{ kind: "assistant", id: "a-step", text: "下一步:安装 pptxgenjs 并确认 Pillow。", reasoning: "", streaming: false },
|
||||
{ kind: "notice", id: "auto-guard", level: "info", text: "↪ A tool failed. Use read-only diagnosis as needed, continue unrelated work automatically." },
|
||||
{ kind: "notice", id: "user-steer", level: "info", text: "↪ 改用 Pillow 10 验证" },
|
||||
{ kind: "assistant", id: "a-done", text: "pptxgenjs 已安装成功", reasoning: "", streaming: false },
|
||||
]);
|
||||
const rows = buildTranscriptRows(models, rowOptions(EMPTY_FOLDS));
|
||||
eq(kinds(rows), "user,answer,notice,answer,turn-actions", "host Auto Guard guidance is not a user-side steer row");
|
||||
const notice = rows.find((row) => row.kind === "notice");
|
||||
eq(notice && "item" in notice ? notice.item.text : "", "↪ 改用 Pillow 10 验证", "real user steers stay visible");
|
||||
}
|
||||
|
||||
{
|
||||
const models = buildTurnModels([
|
||||
{ kind: "user", id: "u1", text: "cancelled before output" },
|
||||
@@ -192,6 +220,7 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
|
||||
], undefined, false, true);
|
||||
const mixedStates = foldSegmentStates(mixed);
|
||||
eq(mixedStates.length, 1, "hidden reasoning keeps a mixed tool process fold");
|
||||
eq(foldSegmentStates(mixed, true)[0]?.keepReasoningExpanded, false, "expanded reasoning mode does not pin tool-only folds");
|
||||
eq(mixed[0]?.segments[0]?.displayItems.filter((item) => item.kind === "assistant").length ?? -1, 0, "hidden reasoning is excluded from fold body items");
|
||||
eq(mixed[0]?.segments[0]?.displayItems.filter((item) => item.kind === "tool").length ?? -1, 1, "hidden reasoning does not hide tools");
|
||||
}
|
||||
@@ -215,6 +244,27 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
|
||||
eq(reconcileFoldEntries(closed ?? EMPTY_FOLDS, settledStates, "auto", false), null, "steady state reconciles to no change");
|
||||
}
|
||||
|
||||
{
|
||||
// Expanded reasoning pins only reasoning-bearing folds across completion.
|
||||
const running = buildTurnModels(fixture.slice(0, 7), { id: "a2", hasAnswerText: true, hasReasoning: false, reasoningComplete: true }, true);
|
||||
const runningStates = foldSegmentStates(running, true);
|
||||
eq(runningStates[0]?.keepReasoningExpanded, true, "expanded mode marks a reasoning-bearing fold as pinned");
|
||||
const seeded = reconcileFoldEntries(EMPTY_FOLDS, runningStates, "auto", false) ?? EMPTY_FOLDS;
|
||||
|
||||
const settledModels = buildTurnModels(fixture.slice(0, 7), undefined, false);
|
||||
const settledPinnedStates = foldSegmentStates(settledModels, true);
|
||||
const completed = reconcileFoldEntries(seeded, settledPinnedStates, "auto", false);
|
||||
ok(completed?.get("a1")?.open === true, "expanded reasoning keeps its parent fold open after completion");
|
||||
|
||||
const manuallyCollapsed = foldMapWithToggle(seeded, "a1", true);
|
||||
const completedCollapsed = reconcileFoldEntries(manuallyCollapsed, settledPinnedStates, "auto", false);
|
||||
ok(completedCollapsed?.get("a1")?.open === false, "manual parent collapse still wins when expanded reasoning completes");
|
||||
|
||||
const settledAutoStates = foldSegmentStates(settledModels, false);
|
||||
const backToAuto = reconcileFoldEntries(completed ?? EMPTY_FOLDS, settledAutoStates, "auto", false);
|
||||
ok(backToAuto?.get("a1")?.open === false, "leaving expanded reasoning re-applies the automatic parent fold policy");
|
||||
}
|
||||
|
||||
{
|
||||
// User override survives completion; a fold with nothing outside never
|
||||
// auto-closes.
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// Run: node --import tsx src/__tests__/transcript-scroll-release.test.ts
|
||||
// Regression: non-wheel upward scroll must release tail-follow immediately,
|
||||
// not wait 500ms. Covers the fix for native scrollbar drag and middle-button
|
||||
// autoscroll suppression during a bottomRequest window.
|
||||
|
||||
import {
|
||||
isPinnedTranscriptLayoutGrowth,
|
||||
TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX,
|
||||
} from "../lib/useTranscriptVirtuosoScroll";
|
||||
INITIAL_TRANSCRIPT_SCROLL_STATE,
|
||||
reduceTranscriptScroll,
|
||||
type TranscriptScrollEvent,
|
||||
type TranscriptScrollState,
|
||||
} from "../lib/transcriptScrollController";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -21,64 +20,86 @@ function check(condition: boolean, label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\ntranscript scroll release");
|
||||
|
||||
// Mock the hook's atBottomStateChange behavior with the fix applied.
|
||||
// The real verification path is the browser bench, but this proves the logic.
|
||||
function mockAtBottomStateChange(
|
||||
atBottom: boolean,
|
||||
bottomRequestActive: boolean,
|
||||
scrollElement: { scrollHeight: number; scrollTop: number; clientHeight: number } | null,
|
||||
) {
|
||||
if (!atBottom && bottomRequestActive) {
|
||||
if (scrollElement) {
|
||||
const distanceFromBottom = scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight;
|
||||
if (distanceFromBottom > TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX) {
|
||||
return "manual"; // Released immediately by the fix
|
||||
}
|
||||
}
|
||||
return "suppressed"; // Old behavior: early return, no mode change
|
||||
function run(events: readonly TranscriptScrollEvent[], initial = INITIAL_TRANSCRIPT_SCROLL_STATE) {
|
||||
let state: TranscriptScrollState = initial;
|
||||
const commands: string[] = [];
|
||||
for (const event of events) {
|
||||
const next = reduceTranscriptScroll(state, event);
|
||||
state = next.state;
|
||||
commands.push(...next.commands.map((command) => command.type));
|
||||
}
|
||||
return atBottom ? "tail-follow" : "manual";
|
||||
return { state, commands };
|
||||
}
|
||||
|
||||
// Scenario 1: atBottom=false during bottomRequest, but physically at bottom.
|
||||
// Expected: suppressed (Virtuoso is still converging; keep tail intent).
|
||||
const s1 = mockAtBottomStateChange(false, true, { scrollHeight: 10000, scrollTop: 9397, clientHeight: 600 });
|
||||
check(s1 === "suppressed", "physically at bottom during request window: intent preserved");
|
||||
console.log("\ntranscript scroll controller");
|
||||
|
||||
// Scenario 2: atBottom=false during bottomRequest, physically far from bottom.
|
||||
// Expected: manual (native scrollbar drag or middle-button autoscroll moved away).
|
||||
const s2 = mockAtBottomStateChange(false, true, { scrollHeight: 10000, scrollTop: 8500, clientHeight: 600 });
|
||||
check(s2 === "manual", "non-wheel upward scroll during request window: release tail-follow immediately");
|
||||
const streaming = run([
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: true, scrollable: true },
|
||||
{ type: "TAIL_CONTENT_CHANGED" },
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: false, scrollable: true },
|
||||
{ type: "LAYOUT_HEIGHT_CHANGED" },
|
||||
]);
|
||||
check(streaming.state.mode === "tail-follow", "dynamic atBottom=false does not steal tail ownership");
|
||||
check(streaming.commands.join(",") === "AUTOSCROLL_TO_BOTTOM,AUTOSCROLL_TO_BOTTOM", "tail growth emits only Virtuoso autoscroll commands");
|
||||
|
||||
// Scenario 3: atBottom=false, no active request.
|
||||
// Expected: manual (ordinary path).
|
||||
const s3 = mockAtBottomStateChange(false, false, { scrollHeight: 10000, scrollTop: 5000, clientHeight: 600 });
|
||||
check(s3 === "manual", "upward scroll outside request window: ordinary manual mode");
|
||||
const manual = run([
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: true, scrollable: true },
|
||||
{ type: "USER_SCROLL_INTENT" },
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: false, scrollable: true },
|
||||
{ type: "TAIL_CONTENT_CHANGED" },
|
||||
{ type: "VIEWPORT_RESIZED" },
|
||||
]);
|
||||
check(manual.state.mode === "manual", "explicit user intent releases tail-follow");
|
||||
check(manual.commands.length === 0, "manual reading never receives tail commands");
|
||||
|
||||
// Scenario 4: atBottom=true.
|
||||
// Expected: tail-follow (re-engaged).
|
||||
const s4 = mockAtBottomStateChange(true, false, null);
|
||||
check(s4 === "tail-follow", "scrolled back to physical bottom: tail-follow restored");
|
||||
const returned = run([
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: true, scrollable: true },
|
||||
{ type: "USER_SCROLL_INTENT" },
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: false, scrollable: true },
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: true, scrollable: true },
|
||||
]);
|
||||
check(returned.state.mode === "tail-follow", "reaching the real bottom re-engages tail-follow");
|
||||
|
||||
const layoutGrowth = isPinnedTranscriptLayoutGrowth({
|
||||
pinned: true,
|
||||
previousScrollHeight: 10000,
|
||||
previousScrollTop: 9400,
|
||||
scrollHeight: 10320,
|
||||
scrollTop: 9714,
|
||||
});
|
||||
check(layoutGrowth, "pinned row growth preserves tail-follow through callback reordering");
|
||||
const shortTranscript = run([
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: true, scrollable: false },
|
||||
{ type: "USER_SCROLL_INTENT" },
|
||||
]);
|
||||
check(shortTranscript.state.mode === "tail-follow", "non-overflow transcript always stays tail-follow");
|
||||
|
||||
const userScrollDuringGrowth = isPinnedTranscriptLayoutGrowth({
|
||||
pinned: true,
|
||||
previousScrollHeight: 10000,
|
||||
previousScrollTop: 9400,
|
||||
scrollHeight: 10320,
|
||||
scrollTop: 9000,
|
||||
});
|
||||
check(!userScrollDuringGrowth, "upward user movement is not mistaken for pinned row growth");
|
||||
const fold = run([
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: true, scrollable: true },
|
||||
{ type: "USER_RESIZE_BEGIN" },
|
||||
{ type: "LAYOUT_HEIGHT_CHANGED" },
|
||||
{ type: "USER_RESIZE_END" },
|
||||
]);
|
||||
check(fold.state.mode === "manual", "user fold resize settles in manual mode");
|
||||
check(fold.commands.length === 0, "user fold resize cannot tug the viewport to the tail");
|
||||
|
||||
const selection = run([
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: true, scrollable: true },
|
||||
{ type: "SELECTION_BEGIN" },
|
||||
{ type: "SCROLL_TO_OFFSET", owner: "selection-edge-scroll", top: 120 },
|
||||
{ type: "LAYOUT_HEIGHT_CHANGED" },
|
||||
{ type: "SELECTION_END" },
|
||||
]);
|
||||
check(selection.state.mode === "manual", "selection returns to manual reading");
|
||||
check(selection.commands.join(",") === "SCROLL_TO_OFFSET", "selection owns only its explicit edge-scroll command");
|
||||
|
||||
const jump = run([
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: false, scrollable: true },
|
||||
{ type: "USER_SCROLL_INTENT" },
|
||||
{ type: "JUMP_TO_BOTTOM", behavior: "smooth" },
|
||||
]);
|
||||
check(jump.state.mode === "tail-follow", "jump-bottom explicitly owns the tail");
|
||||
check(jump.commands.join(",") === "SCROLL_TO_LAST", "jump-bottom uses Virtuoso scrollToIndex only");
|
||||
|
||||
const restore = run([
|
||||
{ type: "AT_BOTTOM_CHANGED", atBottom: true, scrollable: true },
|
||||
{ type: "JUMP_TO_INDEX", index: 42 },
|
||||
{ type: "PROGRAMMATIC_END" },
|
||||
]);
|
||||
check(restore.state.mode === "manual", "question/rewind navigation settles in manual mode");
|
||||
check(restore.commands.join(",") === "SCROLL_TO_INDEX", "navigation emits one indexed Virtuoso command");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -233,7 +233,7 @@ await act(async () => {
|
||||
document.dispatchEvent(new window.MouseEvent("pointermove", { bubbles: true, clientX: 100, clientY: 40 }));
|
||||
});
|
||||
eq(transcriptSelectionStore.getSnapshot().mode, "logical-dragging", "a dead native range still promotes from the frozen anchor during drag");
|
||||
eq(mode, "logical-selecting", "dead-native promotion transfers scroll ownership to logical selection");
|
||||
eq(mode, "selection", "dead-native promotion transfers scroll ownership to logical selection");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0, clientX: 100, clientY: 40 }));
|
||||
});
|
||||
|
||||
@@ -77,6 +77,9 @@ function firstTextNode(root: Node): Text | null {
|
||||
await harness.settle();
|
||||
const el = harness.scrollElement();
|
||||
el.scrollTop = 2000;
|
||||
// Match a reader leaving the tail (wheel-up). A raw scrollTop write leaves
|
||||
// the pin set, and a later LAST/undershoot path would snap back to bottom.
|
||||
el.dispatchEvent(new WheelEvent("wheel", { deltaY: -40, bubbles: true }));
|
||||
dispatchScroll(el);
|
||||
await harness.flush();
|
||||
const before = el.scrollTop;
|
||||
@@ -164,6 +167,27 @@ function firstTextNode(root: Node): Text | null {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lazy content size invalidation rebuilds the Virtuoso size tree ───────────
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
try {
|
||||
const items = turns(20);
|
||||
await harness.render(items, { running: false, tabId: "layout-tab", historyLayoutRevision: 0 });
|
||||
await harness.settle();
|
||||
const before = harness.container.querySelector("[data-testid='virtuoso-item-list']");
|
||||
await harness.render(items, { running: false, tabId: "layout-tab", historyLayoutRevision: 1 });
|
||||
for (let i = 0; i < 5; i += 1) await harness.flush();
|
||||
const after = harness.container.querySelector("[data-testid='virtuoso-item-list']");
|
||||
ok(before != null && after != null && before !== after, "lazy content layout revision rebuilds Virtuoso's cached size tree");
|
||||
const el = harness.scrollElement();
|
||||
const distance = el.scrollHeight - el.clientHeight - el.scrollTop;
|
||||
ok(Math.abs(distance) <= 1, `tail-follow survives the size-tree rebuild (bottom distance ${distance})`);
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cross-page selection promotes to the logical model ───────────────────────
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
@@ -248,5 +272,96 @@ function firstTextNode(root: Node): Text | null {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Short transcripts must not clone the first user bubble at the top ────────
|
||||
// Virtuoso alignToBottom uses margin-top:auto to pin short lists to the
|
||||
// composer. Combined with firstItemIndex it also paints a second copy of the
|
||||
// first user row at the scroller top, leaving a large empty band in between.
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 600, rowHeight: 80 });
|
||||
try {
|
||||
await harness.render(
|
||||
[
|
||||
{ kind: "user", id: "u-short", text: "你好" },
|
||||
{ kind: "assistant", id: "a-short", text: "hello", reasoning: "", streaming: false },
|
||||
],
|
||||
{ running: false },
|
||||
);
|
||||
await harness.settle();
|
||||
const users = harness.container.querySelectorAll(".msg--user");
|
||||
ok(users.length === 1, `a one-turn transcript mounts the user bubble once (got ${users.length})`);
|
||||
const list = harness.container.querySelector<HTMLElement>('[data-testid="virtuoso-item-list"]');
|
||||
ok(list != null, "short transcript mounts the Virtuoso item list");
|
||||
ok(list?.style.marginTop !== "auto", `short content is not bottom-shifted (marginTop=${JSON.stringify(list?.style.marginTop ?? null)})`);
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── A short interrupted turn has no phantom "bottom" to jump to ─────────────
|
||||
// Once alignToBottom was removed, short content correctly stayed at the top.
|
||||
// An upward wheel intent still unpinned it even though the scroller had no
|
||||
// overflow, exposing a jump-bottom button whose click could not move anywhere.
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 700, rowHeight: 60 });
|
||||
try {
|
||||
const interrupted: Item[] = [
|
||||
{ kind: "user", id: "u-interrupted", text: "inspect the four metrics" },
|
||||
{ kind: "assistant", id: "r1", text: "", reasoning: "checking the first source", streaming: false },
|
||||
{ kind: "tool", id: "t1", name: "read_file", args: "{}", output: "ok", status: "done", readOnly: true },
|
||||
{ kind: "assistant", id: "r2", text: "", reasoning: "checking the second source", streaming: false },
|
||||
{ kind: "tool", id: "t2", name: "read_file", args: "{}", output: "ok", status: "done", readOnly: true },
|
||||
{ kind: "notice", id: "cancelled", level: "info", code: "cancelled_turn_display", text: "This turn was interrupted." },
|
||||
];
|
||||
await harness.render(interrupted, { running: false });
|
||||
await harness.settle();
|
||||
const el = harness.scrollElement();
|
||||
ok(el.scrollHeight <= el.clientHeight, `interrupted fixture has no scroll range (${el.scrollHeight}/${el.clientHeight})`);
|
||||
|
||||
el.dispatchEvent(new WheelEvent("wheel", { deltaY: -40, bubbles: true }));
|
||||
dispatchScroll(el);
|
||||
await harness.flush();
|
||||
|
||||
ok(
|
||||
el.dataset.scrollMode === "tail-follow",
|
||||
"wheel-up on a non-overflowing interrupted turn preserves tail-follow state",
|
||||
);
|
||||
ok(
|
||||
harness.container.querySelector(".transcript__jump-bottom") === null,
|
||||
"wheel-up on a non-overflowing interrupted turn does not expose a dead jump-bottom button",
|
||||
);
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Real overflow still exposes a working jump-bottom control ──────────────
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
try {
|
||||
await harness.render(turns(10), { running: false });
|
||||
await harness.settle();
|
||||
const el = harness.scrollElement();
|
||||
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight - 400);
|
||||
el.dispatchEvent(new WheelEvent("wheel", { deltaY: -40, bubbles: true }));
|
||||
dispatchScroll(el);
|
||||
await harness.flush();
|
||||
|
||||
const jump = harness.container.querySelector<HTMLButtonElement>(".transcript__jump-bottom");
|
||||
ok(jump != null, "a transcript with real overflow still exposes jump-bottom after wheel-up");
|
||||
jump?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await harness.settle();
|
||||
|
||||
ok(
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight <= 1,
|
||||
"jump-bottom still reaches the native bottom when overflow exists",
|
||||
);
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -3,6 +3,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { TEXT_SIZES } from "../lib/textSize";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -98,6 +99,69 @@ eq(
|
||||
);
|
||||
eq(finalDeclaration(".transcript--empty", "overflow-y"), "auto", "empty transcript can scroll instead of clipping");
|
||||
eq(finalDeclaration(".welcome", "overflow"), "visible", "welcome empty state is not clipped by its own box");
|
||||
ok(
|
||||
/\.md\s*>\s*:where\([^)]*p[^)]*ul[^)]*ol[^)]*\)\s*\{[^}]*content-visibility:\s*auto;[^}]*contain-intrinsic-size:\s*auto 72px;/.test(styles),
|
||||
"non-transcript markdown still culls offscreen blocks with a 72px placeholder",
|
||||
);
|
||||
ok(
|
||||
/\.transcript__row\s+\.md\s*>\s*\*\s*(?:,[^{]*)?\{[^}]*content-visibility:\s*visible;[^}]*contain-intrinsic-size:\s*none;/.test(styles),
|
||||
"virtual transcript rows do not measure markdown through 72px placeholders",
|
||||
);
|
||||
ok(
|
||||
hasDeclaration(".transcript__row .msg", "content-visibility", "visible") &&
|
||||
hasDeclaration(".transcript__row .turn-collapse", "content-visibility", "visible"),
|
||||
"virtual transcript cards stay measurable after the markdown override",
|
||||
);
|
||||
|
||||
function paddingSides(value: string) {
|
||||
const parts = value.trim().split(/\s+/);
|
||||
if (parts.length === 1) return { right: parts[0], left: parts[0] };
|
||||
if (parts.length === 2 || parts.length === 3) return { right: parts[1], left: parts[1] };
|
||||
return { right: parts[1], left: parts[3] };
|
||||
}
|
||||
function isZeroPad(value: string | undefined) {
|
||||
return value === undefined || value === "0" || value === "0px";
|
||||
}
|
||||
for (const block of matchingBlocks(".transcript")) {
|
||||
const shorthand = /(?:^|;)\s*padding\s*:\s*([^;]+)/.exec(block);
|
||||
if (shorthand) {
|
||||
const sides = paddingSides(shorthand[1]);
|
||||
ok(
|
||||
isZeroPad(sides.left) && isZeroPad(sides.right),
|
||||
`Virtuoso scroller padding stays vertical-only (${shorthand[1].trim()})`,
|
||||
);
|
||||
}
|
||||
const padLeft = /(?:^|;)\s*padding-left\s*:\s*([^;]+)/.exec(block);
|
||||
const padRight = /(?:^|;)\s*padding-right\s*:\s*([^;]+)/.exec(block);
|
||||
ok(isZeroPad(padLeft?.[1].trim()), "Virtuoso scroller does not set padding-left");
|
||||
ok(isZeroPad(padRight?.[1].trim()), "Virtuoso scroller does not set padding-right");
|
||||
}
|
||||
ok(hasDeclaration(".transcript", "--transcript-inline-pad", "32px"), "default transcript inline inset is 32px");
|
||||
ok(hasDeclaration(".transcript", "--transcript-inline-pad", "16px"), "narrow viewports tighten the transcript inline inset");
|
||||
eq(finalDeclaration(".transcript__row", "padding-left"), "var(--transcript-inline-pad, 32px)", "virtual rows own the left inset");
|
||||
eq(finalDeclaration(".transcript__row", "padding-right"), "var(--transcript-inline-pad, 32px)", "virtual rows own the right inset");
|
||||
eq(finalDeclaration(".transcript__header", "padding-left"), "var(--transcript-inline-pad, 32px)", "load-older header uses the same inline inset");
|
||||
eq(finalDeclaration(".transcript--empty", "padding"), "16px 32px", "empty transcript keeps its own horizontal inset");
|
||||
|
||||
{
|
||||
const stylesheet = readFileSync(resolve(testDir, "../styles.css"), "utf8");
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html><html><head><style>${stylesheet}</style></head><body>
|
||||
<div class="transcript__row"><div class="md"><p id="inside">inside</p></div></div>
|
||||
<div class="md"><p id="outside">outside</p></div>
|
||||
</body></html>`,
|
||||
{ pretendToBeVisual: true },
|
||||
);
|
||||
const inside = dom.window.getComputedStyle(dom.window.document.getElementById("inside")!);
|
||||
const outside = dom.window.getComputedStyle(dom.window.document.getElementById("outside")!);
|
||||
// jsdom may not implement content-visibility; treat an empty computed value
|
||||
// as "engine gap" and still require the source contract above.
|
||||
if (inside.contentVisibility || outside.contentVisibility) {
|
||||
eq(inside.contentVisibility, "visible", "computed style keeps transcript markdown measurable");
|
||||
eq(outside.contentVisibility, "auto", "computed style still culls markdown outside the transcript");
|
||||
}
|
||||
dom.window.close();
|
||||
}
|
||||
ok(
|
||||
hasDeclaration(".transcript--empty > .welcome", "margin-block", "auto"),
|
||||
"empty-state auto margins apply only to the welcome content",
|
||||
|
||||
@@ -161,7 +161,11 @@ await waitFor("hydration completion", () => controller?.state.hydrating === fals
|
||||
|
||||
ok(controller?.state.running ?? false, "late history keeps the live turn running");
|
||||
ok(controller?.state.items.some((item) => item.kind === "assistant" && item.streaming) ?? false, "late history keeps the live assistant stream");
|
||||
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "stale durable history") ?? false), "late history cannot replace the live transcript");
|
||||
// The page read before the turn started is this session's history, not a
|
||||
// competing version of it: it goes in front of the live turn instead of
|
||||
// replacing it, so the turn never streams over a blank transcript.
|
||||
ok(controller?.state.items[0]?.kind === "user", "late history lands in front of the live turn");
|
||||
ok(controller?.state.items.at(-1)?.kind === "assistant", "late history leaves the live turn at the tail");
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
|
||||
@@ -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, shouldReconcileStaleTurn, tokenModeSwitchNoticeText, type Item } from "../lib/useController";
|
||||
import { currentTurnWaitMs, effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, historyMessagesToItems, initialState, localizedBackendNoticeText, localizedNoticeText, metaFromTab, modelSwitchNoticeText, reducer, sameMeta, tokenModeSwitchNoticeText, type Item } from "../lib/useController";
|
||||
import { shouldReconcileStaleTurn } from "../lib/useStaleTurnWatchdog";
|
||||
import { parseTodos } from "../lib/tools";
|
||||
import { resolveTodoPanelTodos } from "../lib/todoVisibility";
|
||||
import type { HistoryMessage, Meta, TabMeta, WireUsage } from "../lib/types";
|
||||
@@ -201,22 +202,22 @@ console.log("\nuse controller meta");
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as recovery branch 20260706-152144.863947300-longcat-openai-LongCat-2.0-119b7259f151-recovery-693ce51bcbcbaa9"),
|
||||
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
|
||||
"The session changed on disk, so the unsaved local transcript was kept as another saved version.",
|
||||
"legacy recovery branch notice can be normalized without exposing internal branch id",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as a conflict copy"),
|
||||
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
|
||||
"The session changed on disk, so the unsaved local transcript was kept as another saved version.",
|
||||
"recovery copy notice can be normalized",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session conflicts kept recurring; kept the transcript on the current recovery branch"),
|
||||
"Repeated save conflicts were detected, so the current conflict copy was saved in an isolated recovery branch.",
|
||||
"Repeated save conflicts were detected, so the current version was saved separately.",
|
||||
"legacy repeated recovery conflict notice can be normalized",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("repeated save conflicts were detected; saved the current conflict copy in place"),
|
||||
"Repeated save conflicts were detected, so the current conflict copy was saved in an isolated recovery branch.",
|
||||
"Repeated save conflicts were detected, so the current version was saved separately.",
|
||||
"repeated recovery conflict notice can be normalized",
|
||||
);
|
||||
eq(
|
||||
@@ -264,7 +265,7 @@ console.log("\nuse controller meta");
|
||||
);
|
||||
eq(
|
||||
localizedNoticeText("reworded recovery copy", "session_recovery_forked"),
|
||||
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
|
||||
"The session changed on disk, so the unsaved local transcript was kept as another saved version.",
|
||||
"session recovery fork localization uses its stable notice code",
|
||||
);
|
||||
eq(
|
||||
@@ -274,7 +275,7 @@ console.log("\nuse controller meta");
|
||||
);
|
||||
eq(
|
||||
localizedNoticeText("reworded depth cap", "session_recovery_depth_cap"),
|
||||
"Repeated save conflicts were detected, so the current conflict copy was saved in an isolated recovery branch.",
|
||||
"Repeated save conflicts were detected, so the current version was saved separately.",
|
||||
"session recovery depth-cap localization uses its stable notice code",
|
||||
);
|
||||
eq(
|
||||
@@ -394,6 +395,16 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
|
||||
true,
|
||||
"equivalent empty canonical todo lists keep meta stable",
|
||||
);
|
||||
eq(
|
||||
sameMeta(meta({ dismissedTodoBatches: ["a"] }), meta({ dismissedTodoBatches: ["a"] })),
|
||||
true,
|
||||
"identical dismissed todo batches keep meta stable",
|
||||
);
|
||||
eq(
|
||||
sameMeta(meta({ dismissedTodoBatches: ["a"] }), meta({ dismissedTodoBatches: ["b"] })),
|
||||
false,
|
||||
"persisted todo dismissal changes invalidate meta equality",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -403,6 +414,10 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
|
||||
const todos = [{ content: "Keep task state", status: "in_progress" }];
|
||||
const withTodos = metaFromTab(tab(), meta({ canonicalTodos: todos }));
|
||||
eq(withTodos.canonicalTodos, todos, "optimistic tab metadata preserves canonical todos for the same session");
|
||||
const dismissed = metaFromTab(tab({ sessionPath: "/s/a.jsonl" }), meta({ sessionPath: "/s/a.jsonl", dismissedTodoBatches: ["done"] }));
|
||||
eq(dismissed.dismissedTodoBatches?.[0], "done", "optimistic metadata keeps session-sidecar todo dismissals");
|
||||
const remounted = metaFromTab(tab({ sessionPath: "/s/leaf.jsonl" }), meta({ sessionPath: "/s/a.jsonl", dismissedTodoBatches: ["done"] }));
|
||||
eq(remounted.dismissedTodoBatches, undefined, "a session remount waits for the new sidecar dismissals");
|
||||
}
|
||||
|
||||
{
|
||||
@@ -411,8 +426,10 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
|
||||
const updated = reducer({ ...initialState, meta: before }, { type: "meta", meta: completed });
|
||||
eq(updated.meta?.canonicalTodos?.[0]?.status, "completed", "meta refresh applies canonical todo progress");
|
||||
|
||||
const reset = reducer(updated, { type: "reset" });
|
||||
const withDismissed = reducer(updated, { type: "meta", meta: meta({ canonicalTodos: completed.canonicalTodos, dismissedTodoBatches: ["done"] }) });
|
||||
const reset = reducer(withDismissed, { type: "reset" });
|
||||
eq(reset.meta?.canonicalTodos, undefined, "session reset clears canonical todos from the previous session");
|
||||
eq(reset.meta?.dismissedTodoBatches, undefined, "session reset clears persisted todo dismissals from the previous session");
|
||||
|
||||
const cleared = reducer(reset, { type: "meta", meta: meta({ canonicalTodos: [] }) });
|
||||
eq(cleared.meta?.canonicalTodos?.length, 0, "authoritative empty canonical todos survive meta refresh");
|
||||
@@ -474,7 +491,9 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
|
||||
eq(rendered.live, undefined, "final message closes the live stream before turn_done");
|
||||
eq(shouldReconcileStaleTurn(rendered, 1_000, 31_000), true, "stale completed stream still reconciles missed turn_done");
|
||||
eq(shouldReconcileStaleTurn(rendered, 1_000, 20_000), false, "fresh completed stream waits before reconciling");
|
||||
eq(shouldReconcileStaleTurn({ ...rendered, turnActive: false }, 1_000, 31_000), false, "local pending send before turn_started does not reconcile");
|
||||
const optimistic = reducer(initialState, { type: "user", text: "hello", seq: 0, submissionId: "watchdog-submit" });
|
||||
eq(optimistic.turnActive, false, "optimistic send starts before turn_started arrives");
|
||||
eq(shouldReconcileStaleTurn(optimistic, 0, optimistic.turnStartAt + 30_000), true, "optimistic send reconciles even when turn_started is missed");
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
// Run: tsx src/__tests__/use-controller-stale-turn-watchdog.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { HistoryMessage, Meta, TabMeta, WireEvent } from "../lib/types";
|
||||
import { useController } from "../lib/useController";
|
||||
import { historySliceFromMessages } from "./mockHistorySlice";
|
||||
|
||||
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 flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => globalThis.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}`);
|
||||
}
|
||||
|
||||
console.log("\nstale turn watchdog");
|
||||
|
||||
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.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const originalNow = Date.now;
|
||||
let fakeNow = 1_000;
|
||||
Date.now = () => fakeNow;
|
||||
let nextTimerID = 1;
|
||||
const watchdogTimers = new Map<number, () => void>();
|
||||
window.setTimeout = ((handler: TimerHandler, delay?: number) => {
|
||||
const id = nextTimerID++;
|
||||
if ((delay ?? 0) >= 29_000 && typeof handler === "function") watchdogTimers.set(id, handler);
|
||||
else globalThis.setTimeout(handler, delay);
|
||||
return id;
|
||||
}) as typeof window.setTimeout;
|
||||
window.clearTimeout = ((id?: number) => {
|
||||
if (id !== undefined) watchdogTimers.delete(id);
|
||||
}) as typeof window.clearTimeout;
|
||||
|
||||
const tabID = "tab-watchdog";
|
||||
const sessionPath = "/repo/sessions/watchdog.jsonl";
|
||||
let backendRunning = false;
|
||||
let revision = 1;
|
||||
let history: HistoryMessage[] = [];
|
||||
let listTabsCalls = 0;
|
||||
let historyCalls = 0;
|
||||
const agentEventHandlers: Array<(event: WireEvent) => void> = [];
|
||||
|
||||
function tabMeta(): TabMeta {
|
||||
return {
|
||||
id: tabID,
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
topicId: "topic-watchdog",
|
||||
topicTitle: "Watchdog",
|
||||
sessionPath,
|
||||
sessionRevision: revision,
|
||||
sessionDigest: `digest-${revision}`,
|
||||
label: "model",
|
||||
ready: true,
|
||||
running: backendRunning,
|
||||
cancellable: backendRunning,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
cwd: "/repo",
|
||||
};
|
||||
}
|
||||
|
||||
function meta(): Meta {
|
||||
return {
|
||||
label: "model",
|
||||
ready: true,
|
||||
eventChannel: "agent:event",
|
||||
sessionPath,
|
||||
sessionRevision: revision,
|
||||
sessionDigest: `digest-${revision}`,
|
||||
cwd: "/repo",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
};
|
||||
}
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: (name, handler) => {
|
||||
if (name === "agent:event") agentEventHandlers.push(handler as (event: WireEvent) => void);
|
||||
return () => {};
|
||||
},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => {
|
||||
listTabsCalls += 1;
|
||||
return [tabMeta()];
|
||||
},
|
||||
MetaForTab: async () => meta(),
|
||||
ContextUsageForTab: async () => ({ used: 0, window: 1000, sessionTokens: 0 }),
|
||||
EffortForTab: async () => ({ supported: true, current: "auto", default: "auto", levels: ["auto"] }),
|
||||
BalanceForTab: async () => ({ available: false, display: "" }),
|
||||
JobsForTab: async () => [],
|
||||
CheckpointsForTab: async () => [],
|
||||
HistorySliceForTab: async (_id, request) => {
|
||||
historyCalls += 1;
|
||||
return historySliceFromMessages(tabID, history, request, { revision, digest: `digest-${revision}` });
|
||||
},
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
SubmitToTabWithID: async () => {
|
||||
backendRunning = true;
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("missing root");
|
||||
const root = createRoot(rootElement);
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("initial session", () => controller?.activeTabId === tabID && historyCalls > 0);
|
||||
|
||||
await act(async () => {
|
||||
for (const handler of agentEventHandlers) {
|
||||
handler({ kind: "turn_started", tabId: tabID });
|
||||
handler({ kind: "turn_done", tabId: tabID });
|
||||
}
|
||||
await flushPromises();
|
||||
});
|
||||
fakeNow += 60_000;
|
||||
await act(async () => {
|
||||
await controller?.send("hello");
|
||||
await flushPromises();
|
||||
});
|
||||
ok(controller?.state.running ?? false, "optimistic submit enters running state without any agent events");
|
||||
ok(controller?.state.turnActive === false, "missing turn_started leaves no live turn evidence");
|
||||
ok(watchdogTimers.size === 1, "optimistic submit arms the stale-turn watchdog");
|
||||
|
||||
const firstTimer = watchdogTimers.entries().next().value as [number, () => void] | undefined;
|
||||
if (firstTimer) {
|
||||
watchdogTimers.delete(firstTimer[0]);
|
||||
fakeNow += 30_000;
|
||||
await act(async () => {
|
||||
firstTimer[1]();
|
||||
await flushPromises();
|
||||
});
|
||||
}
|
||||
ok(listTabsCalls >= 2, "first quiet-period probe reconciles backend runtime state");
|
||||
ok(controller?.state.running ?? false, "a genuinely running backend remains running");
|
||||
ok(watchdogTimers.size === 1, "still-running probe re-arms instead of stopping after one check");
|
||||
|
||||
backendRunning = false;
|
||||
revision = 2;
|
||||
history = [
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "assistant", content: "recovered answer" },
|
||||
];
|
||||
const secondTimer = watchdogTimers.entries().next().value as [number, () => void] | undefined;
|
||||
if (secondTimer) {
|
||||
watchdogTimers.delete(secondTimer[0]);
|
||||
fakeNow += 30_000;
|
||||
await act(async () => {
|
||||
secondTimer[1]();
|
||||
await flushPromises();
|
||||
});
|
||||
}
|
||||
await waitFor("missed answer hydration", () => controller?.state.items.some(
|
||||
(item) => item.kind === "assistant" && item.text === "recovered answer",
|
||||
) ?? false);
|
||||
ok(controller?.state.running === false, "idle backend settles the spinner without switching tabs");
|
||||
ok(controller?.state.items.some((item) => item.kind === "assistant" && item.text === "recovered answer") ?? false, "watchdog hydrates the persisted missed answer");
|
||||
ok(watchdogTimers.size === 0, "settled turn leaves no watchdog timer behind");
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
Date.now = originalNow;
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const panel = readFileSync(resolve(testDir, "../components/WorkspacePanel.tsx"), "utf8");
|
||||
const stabilityCSS = readFileSync(resolve(testDir, "../components/WorkspacePanelStability.css"), "utf8");
|
||||
const workspaceChangesResource = readFileSync(resolve(testDir, "../lib/useWorkspaceChangesResource.ts"), "utf8");
|
||||
const workspaceTreeScrollPersistence = readFileSync(resolve(testDir, "../lib/useWorkspaceTreeScrollPersistence.ts"), "utf8");
|
||||
|
||||
assert.match(
|
||||
workspaceChangesResource,
|
||||
/emptyKeyedResource<WorkspaceChangesView>/,
|
||||
"working-tree changes use the same keyed stale-while-revalidate resource as preview and history",
|
||||
);
|
||||
assert.match(
|
||||
workspaceChangesResource,
|
||||
/setResource\(\(current\) => rejectKeyedResourceRequest/,
|
||||
"a failed working-tree refresh preserves the last painted data",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
workspaceChangesResource,
|
||||
/setResource\(\{\s*files:\s*\[\]/,
|
||||
"a failed working-tree refresh never clears the visible list",
|
||||
);
|
||||
assert.match(
|
||||
panel,
|
||||
/loadingHistory\s*&&\s*gitHistory\.length\s*===\s*0/,
|
||||
"history uses a loading placeholder only when there is no stale data to paint",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
panel,
|
||||
/\{loadingHistory\s*\?\s*\(/,
|
||||
"history refresh does not replace an already-painted list with a loading branch",
|
||||
);
|
||||
|
||||
const cwdReset = panel.match(/useEffect\(\(\) => \{[\s\S]*?\}, \[cwd, loadDir, open\]\);/)?.[0] ?? "";
|
||||
assert.ok(cwdReset, "workspace cwd reset effect is present");
|
||||
assert.doesNotMatch(cwdReset, /setSelected(?:File|Change)Path\(null\)/, "cwd reset does not erase restored per-project selections");
|
||||
|
||||
const scopeReset = panel.match(/useEffect\(\(\) => \{[\s\S]*?\}, \[open, resetWorkspaceChanges, viewMode, workspaceScopeKey\]\);/)?.[0] ?? "";
|
||||
assert.ok(scopeReset, "workspace scope reset effect is present");
|
||||
assert.doesNotMatch(scopeReset, /setSelectedChangePath\(null\)/, "scope reset does not erase the restored change selection");
|
||||
|
||||
assert.match(
|
||||
stabilityCSS,
|
||||
/\.workspace-resource-status\s*\{[\s\S]*?position:\s*absolute;/,
|
||||
"refresh and error badges overlay stale content without changing its layout",
|
||||
);
|
||||
assert.match(
|
||||
panel,
|
||||
/onScroll=\{onWorkspaceTreeScroll\}/,
|
||||
"tree scrolling updates memory without synchronously serializing localStorage",
|
||||
);
|
||||
assert.match(
|
||||
workspaceTreeScrollPersistence,
|
||||
/addEventListener\("scrollend", flush\)/,
|
||||
"tree scroll persistence flushes at the native scroll boundary",
|
||||
);
|
||||
assert.match(
|
||||
workspaceTreeScrollPersistence,
|
||||
/addEventListener\("pagehide", flush\)/,
|
||||
"pending tree scroll state flushes before page suspension",
|
||||
);
|
||||
|
||||
console.log(" PASS workspace panel SWR and per-project restoration contract");
|
||||
@@ -0,0 +1,121 @@
|
||||
// Run: tsx src/__tests__/workspace-tree-memory.test.ts
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import {
|
||||
flushWorkspaceTreeMemory,
|
||||
readWorkspaceTreeMemory,
|
||||
rememberWorkspaceTreeScroll,
|
||||
rememberWorkspaceTreeState,
|
||||
resetWorkspaceTreeMemoryForTests,
|
||||
} from "../lib/workspaceTreeMemory";
|
||||
import {
|
||||
createWorkspaceTreePersistenceScheduler,
|
||||
type WorkspaceTreePersistenceClock,
|
||||
} from "../lib/workspaceTreePersistence";
|
||||
|
||||
let passed = 0;
|
||||
function ok(value: boolean, label: string): void {
|
||||
if (!value) throw new Error(label);
|
||||
passed += 1;
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
}
|
||||
|
||||
const dom = new JSDOM("<!doctype html>", { url: "http://localhost/" });
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
|
||||
console.log("\nversioned per-project workspace memory");
|
||||
resetWorkspaceTreeMemoryForTests();
|
||||
|
||||
rememberWorkspaceTreeState("project-a", {
|
||||
openDirs: new Set(["", "src/"]),
|
||||
selectedFilePath: "src/App.tsx",
|
||||
selectedChangePath: "src/store.ts",
|
||||
treeWidth: 276,
|
||||
treeWidthMode: "even",
|
||||
scrollTop: 144,
|
||||
dockTreeWidth: 320,
|
||||
dockPreviewWidth: 640,
|
||||
});
|
||||
rememberWorkspaceTreeState("project-b", { selectedFilePath: "README.md", treeWidth: 220 });
|
||||
|
||||
const projectA = readWorkspaceTreeMemory("project-a");
|
||||
const projectB = readWorkspaceTreeMemory("project-b");
|
||||
ok(projectA?.selectedFilePath === "src/App.tsx", "restores the file selection independently");
|
||||
ok(projectA?.selectedChangePath === "src/store.ts", "restores the change selection independently");
|
||||
ok(projectA?.openDirs.has("src/") === true && projectA.scrollTop === 144, "restores expanded directories and tree scroll");
|
||||
ok(projectA?.dockTreeWidth === 320 && projectA.dockPreviewWidth === 640, "restores both outer dock widths");
|
||||
ok(projectB?.selectedFilePath === "README.md" && projectB.treeWidth === 220, "keeps project state isolated by key");
|
||||
|
||||
const persisted = JSON.parse(localStorage.getItem("reasonix.workspaceState.v2") ?? "null") as { version?: number } | null;
|
||||
ok(persisted?.version === 2, "writes an explicit schema version");
|
||||
|
||||
let synchronousWrites = 0;
|
||||
const originalStorage = globalThis.localStorage;
|
||||
const countingStorage: Storage = {
|
||||
get length() { return originalStorage.length; },
|
||||
clear: () => originalStorage.clear(),
|
||||
getItem: (key) => originalStorage.getItem(key),
|
||||
key: (index) => originalStorage.key(index),
|
||||
removeItem: (key) => originalStorage.removeItem(key),
|
||||
setItem: (key, value) => {
|
||||
synchronousWrites += 1;
|
||||
originalStorage.setItem(key, value);
|
||||
},
|
||||
};
|
||||
globalThis.localStorage = countingStorage;
|
||||
for (let index = 0; index < 120; index += 1) rememberWorkspaceTreeScroll("project-a", 200 + index);
|
||||
ok(synchronousWrites === 0, "keeps high-frequency scroll updates off the synchronous storage path");
|
||||
ok(readWorkspaceTreeMemory("project-a")?.scrollTop === 319, "updates the in-memory scroll position immediately");
|
||||
flushWorkspaceTreeMemory();
|
||||
ok(synchronousWrites === 1, "coalesces 120 scroll updates into one durable write");
|
||||
globalThis.localStorage = originalStorage;
|
||||
|
||||
let nextHandle = 1;
|
||||
const frames = new Map<number, () => void>();
|
||||
const timers = new Map<number, () => void>();
|
||||
const fakeClock: WorkspaceTreePersistenceClock = {
|
||||
requestFrame(callback) {
|
||||
const handle = nextHandle++;
|
||||
frames.set(handle, callback);
|
||||
return handle;
|
||||
},
|
||||
cancelFrame(handle) {
|
||||
frames.delete(handle);
|
||||
},
|
||||
setTimer(callback) {
|
||||
const handle = nextHandle++;
|
||||
timers.set(handle, callback);
|
||||
return handle as unknown as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
clearTimer(handle) {
|
||||
timers.delete(handle as unknown as number);
|
||||
},
|
||||
};
|
||||
const persistedKeys: string[] = [];
|
||||
const scheduler = createWorkspaceTreePersistenceScheduler((key) => persistedKeys.push(key), 200, fakeClock);
|
||||
for (let index = 0; index < 120; index += 1) scheduler.schedule("project-a");
|
||||
ok(frames.size === 1 && timers.size === 0, "allows at most one persistence scheduling frame");
|
||||
for (const callback of Array.from(frames.values())) callback();
|
||||
frames.clear();
|
||||
ok(timers.size === 1 && persistedKeys.length === 0, "waits for the quiet-period timer after the frame");
|
||||
for (const callback of Array.from(timers.values())) callback();
|
||||
timers.clear();
|
||||
ok(persistedKeys.join(",") === "project-a", "persists the final project once after the quiet period");
|
||||
|
||||
scheduler.schedule("project-b");
|
||||
scheduler.flush();
|
||||
ok(
|
||||
frames.size === 0 && persistedKeys[persistedKeys.length - 1] === "project-b",
|
||||
"flushes pending state before a scope or page exit",
|
||||
);
|
||||
|
||||
resetWorkspaceTreeMemoryForTests();
|
||||
localStorage.setItem("reasonix.workspaceState.v2", JSON.stringify({ version: 99, projects: [{ key: "future", state: {} }] }));
|
||||
ok(readWorkspaceTreeMemory("future") === null, "safely ignores storage from an unsupported future schema");
|
||||
|
||||
resetWorkspaceTreeMemoryForTests();
|
||||
localStorage.setItem("reasonix.workspaceState.v2", "{not-json");
|
||||
ok(readWorkspaceTreeMemory("broken") === null, "a corrupt cache cannot prevent workspace startup");
|
||||
|
||||
dom.window.close();
|
||||
console.log(`\n${passed} passed, 0 failed, ${passed} total`);
|
||||
@@ -30,8 +30,9 @@ export function AssistantReasoningPanel({
|
||||
const t = useT();
|
||||
const displayMode = useReasoningDisplayMode();
|
||||
const running = item.streaming && !item.reasoningComplete;
|
||||
const followsWhileStreaming = displayMode === "auto" || expandWhileStreaming;
|
||||
const [open, setOpen] = useState(defaultExpanded || (followsWhileStreaming && running));
|
||||
const followsWhileStreaming = displayMode === "auto" || displayMode === "expanded" || expandWhileStreaming;
|
||||
const keepExpanded = displayMode === "expanded";
|
||||
const [open, setOpen] = useState(defaultExpanded || keepExpanded || (followsWhileStreaming && running));
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const userOverridden = useRef(false);
|
||||
const previousStreaming = useRef(item.streaming);
|
||||
@@ -48,15 +49,15 @@ export function AssistantReasoningPanel({
|
||||
previousMode.current = displayMode;
|
||||
if (modeChanged) {
|
||||
userOverridden.current = false;
|
||||
setOpen(defaultExpanded || (followsWhileStreaming && !complete));
|
||||
setOpen(defaultExpanded || keepExpanded || (followsWhileStreaming && !complete));
|
||||
} else if (item.streaming) {
|
||||
if (!wasStreaming) userOverridden.current = false;
|
||||
if (defaultExpanded) setOpen(true);
|
||||
if (defaultExpanded || keepExpanded) setOpen(true);
|
||||
else if (!userOverridden.current) setOpen(followsWhileStreaming && !complete);
|
||||
} else if ((complete && !wasComplete) || wasStreaming) {
|
||||
if (!defaultExpanded && !userOverridden.current) setOpen(false);
|
||||
if (!defaultExpanded && !keepExpanded && !userOverridden.current) setOpen(false);
|
||||
}
|
||||
}, [defaultExpanded, displayMode, followsWhileStreaming, item.reasoningComplete, item.streaming]);
|
||||
}, [defaultExpanded, displayMode, followsWhileStreaming, keepExpanded, item.reasoningComplete, item.streaming]);
|
||||
|
||||
const toggle = () => {
|
||||
userOverridden.current = true;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useT } from "../lib/i18n";
|
||||
|
||||
export function blankProjectNameProblem(name: string): "required" | "invalid" | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return "required";
|
||||
if (trimmed === "." || trimmed === ".." || /[\\/]/.test(trimmed) || /[\u0000-\u001f\u007f]/.test(trimmed)) {
|
||||
return "invalid";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function BlankProjectDialog({
|
||||
parentDirectory,
|
||||
createdPath,
|
||||
busy,
|
||||
error,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
parentDirectory: string;
|
||||
createdPath?: string;
|
||||
busy: boolean;
|
||||
error?: string;
|
||||
onSubmit: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const titleId = useId();
|
||||
const errorId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const problem = blankProjectNameProblem(name);
|
||||
const visibleError = error || "";
|
||||
|
||||
useLayoutEffect(() => {
|
||||
restoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
inputRef.current?.focus();
|
||||
return () => {
|
||||
if (restoreFocusRef.current?.isConnected) restoreFocusRef.current.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !busy) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||
'input:not(:disabled), button:not(:disabled)',
|
||||
) ?? []);
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown, { capture: true });
|
||||
return () => document.removeEventListener("keydown", onKeyDown, { capture: true });
|
||||
}, [busy, onCancel]);
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (busy || (!createdPath && problem)) return;
|
||||
onSubmit(name.trim());
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-backdrop blank-project-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (!busy && event.target === event.currentTarget) onCancel();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="modal blank-project-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
<div className="modal__title" id={titleId}>{t("projectTree.createBlankProject")}</div>
|
||||
<div className="blank-project-dialog__field">
|
||||
<span className="blank-project-dialog__label">{t("memory.showStorage")}</span>
|
||||
<code className="blank-project-dialog__path">{parentDirectory}</code>
|
||||
</div>
|
||||
<label className="blank-project-dialog__field">
|
||||
<span className="blank-project-dialog__label">{t("caps.name")}</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="mem-input blank-project-dialog__input"
|
||||
type="text"
|
||||
value={name}
|
||||
disabled={busy || Boolean(createdPath)}
|
||||
aria-invalid={Boolean(visibleError && !createdPath)}
|
||||
aria-describedby={visibleError && !createdPath ? errorId : undefined}
|
||||
autoComplete="off"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{createdPath ? (
|
||||
<p className="blank-project-dialog__created">
|
||||
{t("memory.createdSkillSuggestion")}: {createdPath}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="blank-project-dialog__error" id={errorId} role={visibleError ? "alert" : undefined}>
|
||||
{visibleError || "\u00a0"}
|
||||
</div>
|
||||
<div className="modal__actions blank-project-dialog__actions">
|
||||
<button className="btn btn--small" type="button" disabled={busy} onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button className="btn btn--small btn--primary" type="submit" disabled={busy || (!createdPath && Boolean(problem))}>
|
||||
{busy ? t("common.loading") : createdPath ? t("common.retry") : t("common.confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { app } from "../lib/bridge";
|
||||
import { useToast } from "../lib/toast";
|
||||
import { BlankProjectDialog } from "./BlankProjectDialog";
|
||||
|
||||
export function BlankProjectFlow({
|
||||
onOpenProject,
|
||||
onRefresh,
|
||||
onClose,
|
||||
}: {
|
||||
onOpenProject: (path: string) => Promise<void>;
|
||||
onRefresh: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const onCloseRef = useRef(onClose);
|
||||
const showToastRef = useRef(showToast);
|
||||
onCloseRef.current = onClose;
|
||||
showToastRef.current = showToast;
|
||||
const [draft, setDraft] = useState<{ parentDirectory: string; createdPath?: string; error?: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const busyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void app.PickBlankProjectParent().then((parentDirectory) => {
|
||||
if (cancelled) return;
|
||||
if (parentDirectory) setDraft({ parentDirectory });
|
||||
else onCloseRef.current();
|
||||
}).catch((err) => {
|
||||
if (cancelled) return;
|
||||
showToastRef.current(err instanceof Error ? err.message : String(err), "error");
|
||||
onCloseRef.current();
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const submit = async (projectName: string) => {
|
||||
if (!draft || busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setDraft((current) => current ? { ...current, error: undefined } : current);
|
||||
let createdPath = draft.createdPath ?? "";
|
||||
try {
|
||||
if (!createdPath) {
|
||||
createdPath = await app.CreateBlankProject(draft.parentDirectory, projectName);
|
||||
setDraft((current) => current ? { ...current, createdPath } : current);
|
||||
}
|
||||
await onOpenProject(createdPath);
|
||||
await onRefresh();
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
onCloseRef.current();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setDraft((current) => current ? { ...current, createdPath: createdPath || current.createdPath, error: message } : current);
|
||||
} finally {
|
||||
if (busyRef.current) {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!draft) return null;
|
||||
return (
|
||||
<BlankProjectDialog
|
||||
parentDirectory={draft.parentDirectory}
|
||||
createdPath={draft.createdPath}
|
||||
busy={busy}
|
||||
error={draft.error}
|
||||
onSubmit={(name) => void submit(name)}
|
||||
onCancel={() => {
|
||||
if (!busyRef.current) onCloseRef.current();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { lazy, Suspense, type CSSProperties } from "react";
|
||||
|
||||
export type CodeScrollMode = "expand" | "bounded";
|
||||
|
||||
export interface EditorProps {
|
||||
value: string;
|
||||
language?: string;
|
||||
readOnly?: boolean;
|
||||
maxHeight?: number;
|
||||
scrollMode?: CodeScrollMode;
|
||||
maxHeight?: CSSProperties["maxHeight"];
|
||||
/** Original source size in bytes when the caller already has it. */
|
||||
sourceSize?: number;
|
||||
/** Opt in to the workspace-oriented viewer with line numbers and search. */
|
||||
@@ -24,11 +27,12 @@ const LineNumberImpl = lazy(() => import("./editors/LineNumberCode"));
|
||||
|
||||
export function CodeViewer(props: EditorProps) {
|
||||
const Impl = props.showLineNumbers ? LineNumberImpl : HljsImpl;
|
||||
const bounded = props.scrollMode === "bounded" || (props.scrollMode !== "expand" && props.maxHeight != null);
|
||||
return (
|
||||
<div className="code-block">
|
||||
<Suspense
|
||||
fallback={
|
||||
<pre className="code code--loading">
|
||||
<pre className={`code code--loading${bounded ? " code--scroll-y" : ""}`} data-nested-scroll={bounded ? "" : undefined}>
|
||||
<code>{props.value}</code>
|
||||
</pre>
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import { filterAtMatches } from "../lib/atMatches";
|
||||
import { DedupIndex, sha256 } from "../lib/attachDedup";
|
||||
import { app, onFilesDropped } from "../lib/bridge";
|
||||
import { enqueueInboxGuidance } from "../lib/inboxSubmit";
|
||||
import { formatInboxError } from "../lib/inboxError";
|
||||
import { guidanceNeedsRetry, guidanceTextMatches, kickIdleGuidance, markGuidanceQueued } from "../lib/composerGuidance";
|
||||
import { formatInboxError, isInboxItemMissing } from "../lib/inboxError";
|
||||
import { inboxScopeKey } from "../lib/composerInboxQueue";
|
||||
import { useComposerInboxRefresh } from "../lib/useComposerInboxRefresh";
|
||||
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";
|
||||
import { sessionTurnsLabel } from "../lib/sessionCatalogPresentation";
|
||||
@@ -543,6 +545,7 @@ export function Composer({
|
||||
pendingAsk = false,
|
||||
transientDismissSignal,
|
||||
sessionKey,
|
||||
inboxSessionPath,
|
||||
workspaceScopeKey,
|
||||
fileRefRefreshKey,
|
||||
guidanceConsumedKey,
|
||||
@@ -635,6 +638,7 @@ export function Composer({
|
||||
pendingAsk?: boolean;
|
||||
transientDismissSignal?: number;
|
||||
sessionKey?: string;
|
||||
inboxSessionPath?: string;
|
||||
workspaceScopeKey?: string;
|
||||
fileRefRefreshKey?: number | string;
|
||||
guidanceConsumedKey?: string;
|
||||
@@ -659,6 +663,7 @@ export function Composer({
|
||||
const redoComboLabel = useShortcutComboLabel("composer.redo");
|
||||
const yoloComboLabel = useShortcutComboLabel("toolApproval.yolo");
|
||||
const draftKey = sessionKey || tabId || DEFAULT_COMPOSER_DRAFT_KEY;
|
||||
const inboxSessionKey = inboxScopeKey(inboxSessionPath, workspaceScopeKey);
|
||||
const now = useTick(running);
|
||||
const [text, setText] = useState("");
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
@@ -1137,6 +1142,11 @@ export function Composer({
|
||||
restoreComposerDraft(draftsBySessionRef.current[draftKey] ?? emptyComposerDraft());
|
||||
}, [draftKey]);
|
||||
|
||||
const applyInboxQueue = useCallback((items: PendingGuidance[]) => updatePendingGuidanceForDraft(draftKey, () => items), [draftKey]);
|
||||
const collapseInboxQueue = useCallback(() => setGuidanceExpanded(false), []);
|
||||
const refreshInboxQueue = useCallback(() => setGuidanceRetryNonce((value) => value + 1), []);
|
||||
useComposerInboxRefresh(tabId, draftKey, guidanceDraftKey, inboxSessionKey, guidanceQueuePreviewKey, guidanceRetryNonce, running, applyInboxQueue, collapseInboxQueue, refreshInboxQueue);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
draftsBySessionRef.current[activeDraftKeyRef.current] = snapshotComposerDraft();
|
||||
@@ -1175,52 +1185,6 @@ export function Composer({
|
||||
if (next?.id.startsWith("local-")) void sendQueuedGuidance(next, draftKey);
|
||||
}, [draftKey, guidanceDraftKey, guidanceRetryNonce, running, submitDisabled, pendingGuidance, suspendedByDecision]);
|
||||
|
||||
useEffect(() => {
|
||||
if (guidanceDraftKey !== draftKey) return;
|
||||
let live = true;
|
||||
const fallback = guidanceQueuePreviewKey
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((text, i) => ({ id: `local-${i}`, text, submitText: text }));
|
||||
// Older Wails bindings and focused component tests do not expose the new
|
||||
// inbox methods yet. Preserve their local preview contract.
|
||||
if (typeof app.InboxSnapshot !== "function") {
|
||||
updatePendingGuidanceForDraft(draftKey, () => fallback);
|
||||
setGuidanceExpanded(false);
|
||||
return;
|
||||
}
|
||||
// Refresh durable server metadata when running transitions change Controller-owned dispatch/ack.
|
||||
void app.InboxSnapshot(tabId || "").then((snap) => {
|
||||
if (!live) return;
|
||||
const durable = (snap?.items ?? []).map((it: { id: string; preview: string; state?: string; intent?: string; source?: string }) => ({
|
||||
id: it.id,
|
||||
text: it.preview,
|
||||
submitText: "",
|
||||
state: it.state,
|
||||
intent: it.intent,
|
||||
source: it.source,
|
||||
paused: Boolean(snap?.paused),
|
||||
recoveredCount: snap?.paused && snap?.recovered
|
||||
? (snap.recoveredCount || snap.items.length)
|
||||
: undefined,
|
||||
}));
|
||||
updatePendingGuidanceForDraft(draftKey, () => durable.length > 0 ? durable : fallback);
|
||||
setGuidanceExpanded(false);
|
||||
}).catch(() => {
|
||||
if (!live) return;
|
||||
// Fallback: local preview lines without durable ids (will re-enqueue).
|
||||
updatePendingGuidanceForDraft(
|
||||
draftKey,
|
||||
() =>
|
||||
guidanceQueuePreviewKey
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((text, i) => ({ id: `local-${i}`, text, submitText: text })),
|
||||
);
|
||||
});
|
||||
return () => { live = false; };
|
||||
}, [draftKey, guidanceDraftKey, guidanceQueuePreviewKey, running, tabId, guidanceRetryNonce]);
|
||||
|
||||
useEffect(() => {
|
||||
if (guidanceExpanded && pendingGuidance.length <= 2) setGuidanceExpanded(false);
|
||||
}, [guidanceExpanded, pendingGuidance.length]);
|
||||
@@ -2133,7 +2097,7 @@ export function Composer({
|
||||
if (guidanceText) {
|
||||
// Durable follow-up: only clear the composer after a durable receipt.
|
||||
try {
|
||||
const receipt = await enqueueInboxGuidance(app, submitTabId || "", guidanceText, guidanceSubmitText, structured);
|
||||
const receipt = await enqueueInboxGuidance(app, submitTabId || "", guidanceText, guidanceSubmitText, structured, { steer: true });
|
||||
if (receipt?.error) throw new Error(receipt.error);
|
||||
updatePendingGuidanceForDraft(submitDraftKey, (items) => [
|
||||
...items.map((item) => receipt.paused ? { ...item, paused: true } : item),
|
||||
@@ -2249,10 +2213,31 @@ export function Composer({
|
||||
(items) => items.filter((queued) => queued.id !== item.id),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isInboxItemMissing(error)) {
|
||||
updatePendingGuidanceForDraft(
|
||||
activeDraftKeyRef.current,
|
||||
(items) => items.filter((queued) => queued.id !== item.id),
|
||||
);
|
||||
return;
|
||||
}
|
||||
showToast(formatInboxError(error, locale), "warn");
|
||||
}
|
||||
};
|
||||
|
||||
const editQueuedGuidance = async (item: PendingGuidance, nextText: string) => {
|
||||
const text = nextText.trim();
|
||||
if (!text || item.id.startsWith("local-")) return;
|
||||
try {
|
||||
await app.UpdateInboxItem(tabId || "", item.id, text, text);
|
||||
updatePendingGuidanceForDraft(activeDraftKeyRef.current, (items) =>
|
||||
items.map((queued) => queued.id === item.id ? { ...queued, text, submitText: text } : queued),
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(formatInboxError(error, locale), "warn");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const readFileAsDataURL = (file: File) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -4316,7 +4301,7 @@ export function Composer({
|
||||
{pendingGuidance.length > 0 && (
|
||||
<Suspense fallback={null}>
|
||||
<ComposerGuidanceShelf
|
||||
recovery={pendingGuidance[0]?.paused ? {
|
||||
recovery={pendingGuidance[0]?.paused && !pendingGuidance.some((item) => guidanceIsInFlight(item.state)) ? {
|
||||
draftKey,
|
||||
tabId: tabId || "",
|
||||
count: pendingGuidance[0].recoveredCount || pendingGuidance.length,
|
||||
@@ -4335,6 +4320,7 @@ export function Composer({
|
||||
onToggleExpanded={() => setGuidanceExpanded((value) => !value)}
|
||||
onSend={(item) => void sendQueuedGuidance(item)}
|
||||
onDismiss={(item) => void dismissQueuedGuidance(item)}
|
||||
onEdit={(item, text) => editQueuedGuidance(item, text)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChevronDown, ChevronUp, CornerDownRight, Trash2 } from "lucide-react";
|
||||
import { guidanceNeedsRetry } from "../lib/composerGuidance";
|
||||
import { useState } from "react";
|
||||
import { Check, ChevronDown, ChevronUp, CornerDownRight, Pencil, Trash2, X } from "lucide-react";
|
||||
import { guidanceIsEditable, guidanceIsInFlight, guidanceNeedsRetry } from "../lib/composerGuidance";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import type { StructuredInvocationSubmit } from "../lib/invocationDisplay";
|
||||
import { InboxRecoveryBanner } from "./InboxRecoveryBanner";
|
||||
@@ -24,10 +25,6 @@ export type InboxRecoveryNotice = {
|
||||
recovered: boolean;
|
||||
};
|
||||
|
||||
function guidanceIsInFlight(state?: string): boolean {
|
||||
return state === "running" || state === "steer_accepted" || state === "steer_consumed";
|
||||
}
|
||||
|
||||
export function ComposerGuidanceShelf({
|
||||
recovery,
|
||||
recoveryDisabled,
|
||||
@@ -43,6 +40,7 @@ export function ComposerGuidanceShelf({
|
||||
onToggleExpanded,
|
||||
onSend,
|
||||
onDismiss,
|
||||
onEdit,
|
||||
}: {
|
||||
recovery: InboxRecoveryNotice | null;
|
||||
recoveryDisabled: boolean;
|
||||
@@ -58,10 +56,38 @@ export function ComposerGuidanceShelf({
|
||||
onToggleExpanded: () => void;
|
||||
onSend: (item: PendingGuidance) => void;
|
||||
onDismiss: (item: PendingGuidance) => void;
|
||||
onEdit?: (item: PendingGuidance, text: string) => void | Promise<void>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const visible = expanded ? items : items.slice(0, 2);
|
||||
const hiddenCount = Math.max(0, items.length - 2);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editDraft, setEditDraft] = useState("");
|
||||
const [editBusy, setEditBusy] = useState(false);
|
||||
|
||||
const startEdit = (item: PendingGuidance) => {
|
||||
setEditingId(item.id);
|
||||
setEditDraft(item.submitText.trim() || item.text);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditDraft("");
|
||||
setEditBusy(false);
|
||||
};
|
||||
|
||||
const saveEdit = async (item: PendingGuidance) => {
|
||||
if (!onEdit || editBusy) return;
|
||||
const next = editDraft.trim();
|
||||
if (!next) return;
|
||||
setEditBusy(true);
|
||||
try {
|
||||
await onEdit(item, next);
|
||||
cancelEdit();
|
||||
} catch {
|
||||
setEditBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -90,6 +116,8 @@ export function ComposerGuidanceShelf({
|
||||
const inFlight = guidanceIsInFlight(item.state);
|
||||
const needsRetry = guidanceNeedsRetry(item.state);
|
||||
const waitingForEarlier = !running && !inFlight && index > 0;
|
||||
const canEdit = Boolean(onEdit) && !readOnly && !disabled && guidanceIsEditable(item) && !waitingForEarlier && sendingId === null;
|
||||
const editing = editingId === item.id;
|
||||
const actionLabel = inFlight
|
||||
? t("composer.guidanceInFlight")
|
||||
: waitingForEarlier
|
||||
@@ -98,32 +126,93 @@ export function ComposerGuidanceShelf({
|
||||
? t("composer.guidanceRetry")
|
||||
: t("composer.guidanceSend");
|
||||
return (
|
||||
<div className="composer-guidance-item" key={item.id}>
|
||||
<div className={`composer-guidance-item${editing ? " composer-guidance-item--editing" : ""}`} key={item.id}>
|
||||
<CornerDownRight size={14} className="composer-guidance-item__icon" />
|
||||
<span className="composer-guidance-item__text">{item.text}</span>
|
||||
<Tooltip label={actionLabel}>
|
||||
<button
|
||||
className="composer-guidance-item__guide"
|
||||
type="button"
|
||||
aria-label={actionLabel}
|
||||
disabled={inFlight || waitingForEarlier || disabled || readOnly || sendingId !== null || (running && !needsRetry && Boolean(item.structured)) || Boolean(item.paused)}
|
||||
onClick={() => onSend(item)}
|
||||
>
|
||||
<CornerDownRight size={13} />
|
||||
<span>{t(needsRetry ? "composer.guidanceRetryMode" : running ? "composer.guidanceMode" : "composer.guidanceSendMode")}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={inFlight ? actionLabel : t("composer.guidanceDismiss")}>
|
||||
<button
|
||||
className="composer-guidance-item__action"
|
||||
type="button"
|
||||
aria-label={inFlight ? actionLabel : t("composer.guidanceDismiss")}
|
||||
disabled={inFlight || sendingId === item.id}
|
||||
onClick={() => onDismiss(item)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{editing ? (
|
||||
<input
|
||||
className="composer-guidance-item__editor"
|
||||
value={editDraft}
|
||||
onChange={(event) => setEditDraft(event.target.value)}
|
||||
aria-label={t("composer.guidanceEdit")}
|
||||
disabled={editBusy}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void saveEdit(item);
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="composer-guidance-item__text">{item.text.trim() || t("composer.guidanceEmptyPreview")}</span>
|
||||
)}
|
||||
{editing ? (
|
||||
<>
|
||||
<Tooltip label={t("composer.guidanceSaveEdit")}>
|
||||
<button
|
||||
className="composer-guidance-item__action"
|
||||
type="button"
|
||||
aria-label={t("composer.guidanceSaveEdit")}
|
||||
disabled={editBusy || !editDraft.trim()}
|
||||
onClick={() => void saveEdit(item)}
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("composer.guidanceCancelEdit")}>
|
||||
<button
|
||||
className="composer-guidance-item__action"
|
||||
type="button"
|
||||
aria-label={t("composer.guidanceCancelEdit")}
|
||||
disabled={editBusy}
|
||||
onClick={cancelEdit}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{canEdit && (
|
||||
<Tooltip label={t("composer.guidanceEdit")}>
|
||||
<button
|
||||
className="composer-guidance-item__action"
|
||||
type="button"
|
||||
aria-label={t("composer.guidanceEdit")}
|
||||
onClick={() => startEdit(item)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={actionLabel}>
|
||||
<button
|
||||
className="composer-guidance-item__guide"
|
||||
type="button"
|
||||
aria-label={actionLabel}
|
||||
disabled={inFlight || waitingForEarlier || disabled || readOnly || sendingId !== null || (running && !needsRetry && Boolean(item.structured)) || Boolean(item.paused)}
|
||||
onClick={() => onSend(item)}
|
||||
>
|
||||
<CornerDownRight size={13} />
|
||||
<span>{t(needsRetry ? "composer.guidanceRetryMode" : running ? "composer.guidanceMode" : "composer.guidanceSendMode")}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={inFlight ? actionLabel : t("composer.guidanceDismiss")}>
|
||||
<button
|
||||
className="composer-guidance-item__action"
|
||||
type="button"
|
||||
aria-label={inFlight ? actionLabel : t("composer.guidanceDismiss")}
|
||||
disabled={inFlight || sendingId === item.id}
|
||||
onClick={() => onDismiss(item)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { Archive, Pencil, Search, Trash2, RotateCcw } from "lucide-react";
|
||||
import { Archive, GitBranch, Pencil, Search, Trash2, RotateCcw } from "lucide-react";
|
||||
import { app } from "../lib/bridge";
|
||||
import { t, useT } from "../lib/i18n";
|
||||
import { historySessionDisplayTitle, sessionActivityTime } from "../lib/session";
|
||||
import type { HistoryMessage, HistorySearchContextLine, HistorySearchHit, SessionMeta } from "../lib/types";
|
||||
import type { HistoryMessage, HistorySearchContextLine, HistorySearchHit, RecoveryLineageView, SessionMeta } from "../lib/types";
|
||||
import { historyMessagesToItems, type Item } from "../lib/useController";
|
||||
import { useHistoryCatalog } from "../lib/useHistoryCatalog";
|
||||
import { Transcript } from "./Transcript";
|
||||
@@ -12,6 +12,8 @@ import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type Cont
|
||||
import { useDeferredClose } from "../lib/useMountTransition";
|
||||
import { ModalCloseButton } from "./ModalCloseButton";
|
||||
import { HistoryFilterSelect } from "./HistoryFilterSelect";
|
||||
import { RecoveryLineageDialog } from "./RecoveryLineageDialog";
|
||||
import { useToast } from "../lib/toast";
|
||||
|
||||
type HistoryScopeFilter = "all" | "project" | "global";
|
||||
type HistoryStatusFilter = "all" | "current" | "open";
|
||||
@@ -50,9 +52,14 @@ export function HistoryPanel({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const tr = useT();
|
||||
const { showToast } = useToast();
|
||||
const isTrash = kind === "trash";
|
||||
// Play the modal exit animation, then let the parent unmount us.
|
||||
const { status, requestClose } = useDeferredClose(onClose, 240);
|
||||
const [savedVersions, setSavedVersions] = useState<{
|
||||
topic: { scope: string; workspaceRoot?: string; topicId: string };
|
||||
view: RecoveryLineageView;
|
||||
} | null>(null);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -321,6 +328,16 @@ export function HistoryPanel({
|
||||
startRename(target);
|
||||
},
|
||||
},
|
||||
...(menuSession.topicId
|
||||
? [
|
||||
{
|
||||
key: "other-saved-versions",
|
||||
icon: <GitBranch size={13} />,
|
||||
label: tr("recovery.inspectLineage"),
|
||||
onSelect: () => void inspectOtherSavedVersions(menuSession),
|
||||
} as ContextMenuItem,
|
||||
]
|
||||
: []),
|
||||
...(menuSession.current
|
||||
? []
|
||||
: [
|
||||
@@ -396,6 +413,21 @@ export function HistoryPanel({
|
||||
if (!selectedSession || running || isTrash) return;
|
||||
onResume(selectedSession);
|
||||
};
|
||||
const inspectOtherSavedVersions = async (session: SessionMeta) => {
|
||||
if (isTrash || !session.topicId) return;
|
||||
closeHistoryMenus();
|
||||
try {
|
||||
const topic = {
|
||||
scope: session.scope || "global",
|
||||
workspaceRoot: session.workspaceRoot || undefined,
|
||||
topicId: session.topicId,
|
||||
};
|
||||
const view = await app.GetRecoveryLineage(topic);
|
||||
setSavedVersions({ topic, view });
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
}
|
||||
};
|
||||
const renameSelected = () => {
|
||||
if (!selectedSession || running || isTrash) return;
|
||||
closeHistoryMenus();
|
||||
@@ -684,6 +716,16 @@ export function HistoryPanel({
|
||||
<button className="btn btn--small" type="button" disabled={!selectedSession || running} onClick={renameSelected}>
|
||||
{tr("history.rename")}
|
||||
</button>
|
||||
{selectedSession?.topicId && (
|
||||
<button
|
||||
className="btn btn--small"
|
||||
type="button"
|
||||
disabled={!selectedSession}
|
||||
onClick={() => void inspectOtherSavedVersions(selectedSession)}
|
||||
>
|
||||
<GitBranch size={13} /> {tr("recovery.inspectLineage")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn--small btn--danger"
|
||||
type="button"
|
||||
@@ -727,6 +769,35 @@ export function HistoryPanel({
|
||||
ariaLabel={tr("history.trashActions")}
|
||||
onClose={closeHistoryMenus}
|
||||
/>
|
||||
{savedVersions && (
|
||||
<RecoveryLineageDialog
|
||||
topic={savedVersions.topic}
|
||||
initial={savedVersions.view}
|
||||
onClose={() => setSavedVersions(null)}
|
||||
onChanged={async () => {
|
||||
const view = await app.GetRecoveryLineage(savedVersions.topic);
|
||||
setSavedVersions({ topic: savedVersions.topic, view });
|
||||
}}
|
||||
onOpenVersion={async (path) => {
|
||||
const session = sessions.find((item) => item.path === path) ?? {
|
||||
path,
|
||||
preview: "",
|
||||
turns: 0,
|
||||
turnsState: "unknown",
|
||||
createdAt: 0,
|
||||
lastActivityAt: 0,
|
||||
modTime: 0,
|
||||
current: false,
|
||||
open: false,
|
||||
scope: savedVersions.topic.scope,
|
||||
workspaceRoot: savedVersions.topic.workspaceRoot,
|
||||
topicId: savedVersions.topic.topicId,
|
||||
recovered: true,
|
||||
};
|
||||
onResume(session);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -8,14 +8,16 @@ import { LiveStreamContext } from "./LiveStreamContext";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { ProcessBrainIcon } from "./ProcessCard";
|
||||
import { ReasoningSummary } from "./ReasoningSummary";
|
||||
import { useTranscriptUserResizeIntent } from "./TranscriptLayoutIntentContext";
|
||||
|
||||
export function InlineAssistantReasoning({ item, onManualOpen }: { item: AssistantItem; onManualOpen?: () => void }) {
|
||||
const t = useT();
|
||||
const beginUserResize = useTranscriptUserResizeIntent();
|
||||
const live = useContext(LiveStreamContext);
|
||||
const displayMode = useReasoningDisplayMode();
|
||||
const shown = live?.id === item.id ? { reasoning: live.reasoning, streaming: true, reasoningComplete: live.reasoningComplete } : item;
|
||||
const running = shown.streaming && !shown.reasoningComplete;
|
||||
const [open, setOpen] = useState(displayMode === "auto" && running);
|
||||
const [open, setOpen] = useState(displayMode === "expanded" || (displayMode === "auto" && running));
|
||||
const userOverridden = useRef(false);
|
||||
const previousRunning = useRef(running);
|
||||
const previousMode = useRef(displayMode);
|
||||
@@ -26,8 +28,8 @@ export function InlineAssistantReasoning({ item, onManualOpen }: { item: Assista
|
||||
previousRunning.current = running;
|
||||
if (modeChanged) {
|
||||
userOverridden.current = false;
|
||||
setOpen(displayMode === "auto" && running);
|
||||
} else if (displayMode === "auto" && running && !wasRunning) {
|
||||
setOpen(displayMode === "expanded" || (displayMode === "auto" && running));
|
||||
} else if (running && !wasRunning && (displayMode === "auto" || displayMode === "expanded")) {
|
||||
userOverridden.current = false;
|
||||
setOpen(true);
|
||||
} else if (displayMode === "auto" && !running && wasRunning && !userOverridden.current) {
|
||||
@@ -35,10 +37,11 @@ export function InlineAssistantReasoning({ item, onManualOpen }: { item: Assista
|
||||
}
|
||||
}, [displayMode, running]);
|
||||
const toggle = useCallback(() => {
|
||||
beginUserResize();
|
||||
userOverridden.current = true;
|
||||
if (!open) onManualOpen?.();
|
||||
setOpen(!open);
|
||||
}, [onManualOpen, open]);
|
||||
}, [beginUserResize, onManualOpen, open]);
|
||||
const reasoning = shown.reasoning.trim();
|
||||
if (!reasoning) return null;
|
||||
const visibleReasoning = open ? displayReasoningText(shown.reasoning, {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { lazy, memo, startTransition, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
const MarkdownRenderer = lazy(() => import("./MarkdownRenderer"));
|
||||
const MarkdownHistory = lazy(() => import("./MarkdownHistory"));
|
||||
async function loadMarkdownView<T>(component: Promise<T>): Promise<T> {
|
||||
await import("./MarkdownImage.css");
|
||||
return component;
|
||||
}
|
||||
|
||||
const MarkdownRenderer = lazy(() => loadMarkdownView(import("./MarkdownRenderer")));
|
||||
const MarkdownHistory = lazy(() => loadMarkdownView(import("./MarkdownHistory")));
|
||||
const STREAMING_TAIL_THRESHOLD = 8_000;
|
||||
const FINALIZE_SETTLE_MS = 50;
|
||||
const FINALIZE_IDLE_TIMEOUT_MS = 1_000;
|
||||
@@ -131,12 +136,15 @@ export function streamingMarkdownCommitInterval(textLength: number): number {
|
||||
return 50;
|
||||
}
|
||||
|
||||
// streamingCommitTarget returns the prefix worth parsing while a stream is
|
||||
// live: everything up to the last completed block — a blank line, a closed
|
||||
// fence, closed display math, or the start of a heading, all on terminated
|
||||
// lines. The in-progress block rides the plain-text tail instead, so Markdown
|
||||
// re-parses once per completed block rather than once per commit interval. An
|
||||
// unclosed fence or $$ keeps the whole text parsed for live code highlighting.
|
||||
const STREAMING_LIST_ITEM_RE = /^ {0,3}(?:[*+-]|\d{1,9}[.)])(?:[ \t]+|$)/;
|
||||
const STREAMING_THEMATIC_BREAK_RE = /^ {0,3}(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})[ \t]*$/;
|
||||
|
||||
function isStreamingListItemLine(line: string): boolean {
|
||||
return STREAMING_LIST_ITEM_RE.test(line) && !STREAMING_THEMATIC_BREAK_RE.test(line);
|
||||
}
|
||||
|
||||
// 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.
|
||||
export function streamingCommitTarget(text: string): string {
|
||||
let lineStart = 0;
|
||||
let fence: { marker: string; length: number } | null = null;
|
||||
@@ -165,6 +173,7 @@ export function streamingCommitTarget(text: string): string {
|
||||
// A heading interrupts a paragraph, so a partial heading line already
|
||||
// completes everything before it; a terminated one is itself complete.
|
||||
else if (/^ {0,3}#{1,6}[ \t]+/.test(line)) boundary = terminated ? lineEnd : lineStart;
|
||||
else if (isStreamingListItemLine(line)) boundary = lineStart;
|
||||
}
|
||||
lineStart = lineEnd;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,11 @@
|
||||
// While a parse is in flight the caller-provided fallback stays on screen
|
||||
// (plain full text for a fresh history mount — never truncated — or the
|
||||
// committed streaming view when a live answer just completed). A single huge
|
||||
// row mounts its blocks progressively: the first chunk immediately, the rest
|
||||
// in idle slices so one 500KiB answer cannot monopolize a frame.
|
||||
// row keeps a viewport-driven tail window: opening a session paints its newest
|
||||
// blocks, and scrolling toward older content prepends another bounded chunk.
|
||||
|
||||
import { Fragment, memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { Fragment, memo, startTransition, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { hastBlockToJsx } from "../lib/hastJsx";
|
||||
import { scheduleIdleTask } from "../lib/idleTask";
|
||||
import {
|
||||
estimateHastBytes,
|
||||
markdownContentRevision,
|
||||
@@ -23,9 +22,29 @@ import { getTranscriptStore } from "../lib/transcriptStore";
|
||||
import { createComponents } from "./markdownComponents";
|
||||
import { VirtualMarkdownSourceTable } from "./MarkdownTable";
|
||||
|
||||
// Blocks beyond this count mount in idle chunks instead of one commit.
|
||||
const PROGRESSIVE_INITIAL_BLOCKS = 24;
|
||||
const PROGRESSIVE_CHUNK_BLOCKS = 24;
|
||||
// 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
|
||||
// bounded two-sided window only when one of its edges enters the viewport.
|
||||
// The previous idle loop forced one React/layout commit per second until every block was in
|
||||
// the DOM, which could keep WebView2 busy for minutes after a session switch.
|
||||
const MARKDOWN_TAIL_BLOCKS = 24;
|
||||
const MARKDOWN_PREPEND_BLOCKS = 96;
|
||||
const MARKDOWN_WINDOW_BLOCKS = MARKDOWN_TAIL_BLOCKS + MARKDOWN_PREPEND_BLOCKS * 2;
|
||||
const MARKDOWN_SENTINEL_STYLE = { display: "block", height: 1 } as const;
|
||||
const MARKDOWN_ANCHOR_STYLE = { display: "block", height: 0 } as const;
|
||||
|
||||
type BlockWindow = {
|
||||
identity: MarkdownBlock[] | undefined;
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
type PendingScrollAnchor = {
|
||||
identity: MarkdownBlock[];
|
||||
index: number;
|
||||
top: number;
|
||||
scroller: HTMLElement | null;
|
||||
};
|
||||
|
||||
function cachedBlocks(entryId: string | undefined, revision: number, text: string): MarkdownBlock[] | undefined {
|
||||
if (!entryId) return undefined;
|
||||
@@ -35,24 +54,63 @@ function cachedBlocks(entryId: string | undefined, revision: number, text: strin
|
||||
return cached && cached.source === text ? cached.blocks : undefined;
|
||||
}
|
||||
|
||||
/** Mount blocks progressively once a document exceeds the initial chunk. */
|
||||
function useProgressiveBlockCount(total: number): number {
|
||||
const immediate = total <= PROGRESSIVE_INITIAL_BLOCKS;
|
||||
const [count, setCount] = useState(immediate ? total : PROGRESSIVE_INITIAL_BLOCKS);
|
||||
const [prevTotal, setPrevTotal] = useState(total);
|
||||
if (prevTotal !== total) {
|
||||
// New document (text/revision changed): restart from the initial chunk.
|
||||
setPrevTotal(total);
|
||||
setCount(total <= PROGRESSIVE_INITIAL_BLOCKS ? total : PROGRESSIVE_INITIAL_BLOCKS);
|
||||
}
|
||||
/** Keep a bounded block window whose edges advance only on viewport demand. */
|
||||
function useProgressiveBlockWindow(total: number, identity: MarkdownBlock[] | undefined): [BlockWindow, (direction: "older" | "newer") => void] {
|
||||
const initial = useMemo<BlockWindow>(() => ({
|
||||
identity,
|
||||
start: Math.max(0, total - MARKDOWN_TAIL_BLOCKS),
|
||||
end: total,
|
||||
}), [identity, total]);
|
||||
const [window, setWindow] = useState(initial);
|
||||
// Derive the new tail synchronously so a worker result never performs one
|
||||
// discarded full-document JSX conversion before the reset effect commits.
|
||||
const current = window.identity === identity ? window : initial;
|
||||
useEffect(() => {
|
||||
if (count >= total) return;
|
||||
// Each completed chunk re-runs this effect and schedules the next slice.
|
||||
return scheduleIdleTask(() => {
|
||||
setCount((current) => Math.min(total, current + PROGRESSIVE_CHUNK_BLOCKS));
|
||||
setWindow((value) => value.identity === identity ? value : initial);
|
||||
}, [identity, initial]);
|
||||
const move = useCallback((direction: "older" | "newer") => {
|
||||
setWindow((value) => {
|
||||
const active = value.identity === identity ? value : initial;
|
||||
if (direction === "older") {
|
||||
const start = Math.max(0, active.start - MARKDOWN_PREPEND_BLOCKS);
|
||||
return { identity, start, end: Math.min(active.end, start + MARKDOWN_WINDOW_BLOCKS) };
|
||||
}
|
||||
const end = Math.min(total, active.end + MARKDOWN_PREPEND_BLOCKS);
|
||||
return { identity, start: Math.max(active.start, end - MARKDOWN_WINDOW_BLOCKS), end };
|
||||
});
|
||||
}, [count, total]);
|
||||
return Math.min(count, total);
|
||||
}, [identity, initial, total]);
|
||||
return [current, move];
|
||||
}
|
||||
|
||||
function useBlockWindowSentinel(
|
||||
identity: MarkdownBlock[] | undefined,
|
||||
enabled: boolean,
|
||||
onEnter: (sentinel: HTMLSpanElement) => void,
|
||||
) {
|
||||
const sentinelRef = useRef<HTMLSpanElement>(null);
|
||||
const armedRef = useRef(true);
|
||||
const observedIdentityRef = useRef(identity);
|
||||
useEffect(() => {
|
||||
if (observedIdentityRef.current !== identity) {
|
||||
observedIdentityRef.current = identity;
|
||||
armedRef.current = true;
|
||||
}
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!enabled || !sentinel || typeof IntersectionObserver === "undefined") return;
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const visible = entries.some((entry) => entry.isIntersecting);
|
||||
if (!visible) {
|
||||
armedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (!armedRef.current) return;
|
||||
armedRef.current = false;
|
||||
startTransition(() => onEnter(sentinel));
|
||||
}, { rootMargin: "240px 0px" });
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [enabled, identity, onEnter]);
|
||||
return sentinelRef;
|
||||
}
|
||||
|
||||
export const MarkdownHistory = memo(function MarkdownHistory({
|
||||
@@ -118,32 +176,87 @@ export const MarkdownHistory = memo(function MarkdownHistory({
|
||||
}, [text, entryId, revision]);
|
||||
|
||||
const components = useMemo(() => createComponents(plainStatusBlocks), [plainStatusBlocks]);
|
||||
const visibleCount = useProgressiveBlockCount(blocks?.length ?? 0);
|
||||
const totalBlocks = blocks?.length ?? 0;
|
||||
const [blockWindow, moveBlockWindow] = useProgressiveBlockWindow(totalBlocks, blocks);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const pendingAnchorRef = useRef<PendingScrollAnchor | null>(null);
|
||||
const moveFromBoundary = useCallback((direction: "older" | "newer", sentinel: HTMLSpanElement) => {
|
||||
if (!blocks) return;
|
||||
if (pendingAnchorRef.current?.identity === blocks) return;
|
||||
const scroller = rootRef.current?.closest<HTMLElement>(".transcript") ?? null;
|
||||
const index = direction === "older" ? blockWindow.start : blockWindow.end;
|
||||
const boundary = sentinel.getBoundingClientRect();
|
||||
pendingAnchorRef.current = {
|
||||
identity: blocks,
|
||||
index,
|
||||
// The older sentinel sits immediately before the old leading block, so
|
||||
// its bottom is that block's stable boundary. The newer sentinel's top
|
||||
// is already the boundary immediately after the old trailing block.
|
||||
top: direction === "older" ? boundary.bottom : boundary.top,
|
||||
scroller,
|
||||
};
|
||||
moveBlockWindow(direction);
|
||||
}, [blockWindow.end, blockWindow.start, blocks, moveBlockWindow]);
|
||||
const loadOlder = useCallback((sentinel: HTMLSpanElement) => moveFromBoundary("older", sentinel), [moveFromBoundary]);
|
||||
const loadNewer = useCallback((sentinel: HTMLSpanElement) => moveFromBoundary("newer", sentinel), [moveFromBoundary]);
|
||||
const olderSentinelRef = useBlockWindowSentinel(blocks, blockWindow.start > 0, loadOlder);
|
||||
const newerSentinelRef = useBlockWindowSentinel(blocks, blockWindow.end < totalBlocks, loadNewer);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const pending = pendingAnchorRef.current;
|
||||
if (!pending) return;
|
||||
if (pending.identity !== blocks) {
|
||||
pendingAnchorRef.current = null;
|
||||
return;
|
||||
}
|
||||
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]);
|
||||
|
||||
// JSX per block depends only on the block and the components map; build it
|
||||
// lazily so progressive mounting never re-converts settled blocks.
|
||||
const jsxCacheRef = useRef<{ blocks: MarkdownBlock[]; nodes: ReactNode[] } | null>(null);
|
||||
// lazily so viewport-window growth never re-converts settled blocks.
|
||||
const jsxCacheRef = useRef<{ blocks: MarkdownBlock[]; nodes: Map<number, ReactNode> } | null>(null);
|
||||
if (!blocks) return <>{fallback}</>;
|
||||
let cache = jsxCacheRef.current;
|
||||
if (!cache || cache.blocks !== blocks) {
|
||||
cache = { blocks, nodes: new Array<ReactNode>(blocks.length) };
|
||||
cache = { blocks, nodes: new Map<number, ReactNode>() };
|
||||
jsxCacheRef.current = cache;
|
||||
}
|
||||
for (const index of cache.nodes.keys()) {
|
||||
if (index < blockWindow.start || index >= blockWindow.end) cache.nodes.delete(index);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="md"
|
||||
data-markdown-blocks={blocks.length}
|
||||
data-markdown-visible-blocks={visibleCount}
|
||||
data-markdown-visible-blocks={blockWindow.end - blockWindow.start}
|
||||
data-markdown-window-start={blockWindow.start}
|
||||
data-markdown-window-end={blockWindow.end}
|
||||
data-markdown-window-cap={MARKDOWN_WINDOW_BLOCKS}
|
||||
>
|
||||
{blocks.slice(0, visibleCount).map((block, index) => {
|
||||
const cached = cache.nodes[index];
|
||||
if (cached !== undefined) return <Fragment key={block.key}>{cached}</Fragment>;
|
||||
const node = block.virtualTable
|
||||
? <VirtualMarkdownSourceTable data={block.virtualTable} />
|
||||
: hastBlockToJsx(block, components);
|
||||
cache.nodes[index] = node;
|
||||
return <Fragment key={block.key}>{node}</Fragment>;
|
||||
{blockWindow.start > 0 && <span ref={olderSentinelRef} style={MARKDOWN_SENTINEL_STYLE} data-markdown-older-sentinel aria-hidden="true" />}
|
||||
{blocks.slice(blockWindow.start, blockWindow.end).map((block, offset) => {
|
||||
const index = blockWindow.start + offset;
|
||||
const cached = cache.nodes.get(index);
|
||||
const node = cached !== undefined
|
||||
? cached
|
||||
: block.virtualTable
|
||||
? <VirtualMarkdownSourceTable data={block.virtualTable} />
|
||||
: hastBlockToJsx(block, components);
|
||||
if (cached === undefined) cache.nodes.set(index, node);
|
||||
return (
|
||||
<Fragment key={block.key}>
|
||||
{pendingAnchorRef.current?.identity === blocks && pendingAnchorRef.current.index === index
|
||||
? <span style={MARKDOWN_ANCHOR_STYLE} data-markdown-scroll-anchor={index} aria-hidden="true" />
|
||||
: null}
|
||||
{node}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{blockWindow.end < blocks.length && <span ref={newerSentinelRef} style={MARKDOWN_SENTINEL_STYLE} data-markdown-newer-sentinel aria-hidden="true" />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
.md > img,
|
||||
.md p > img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.md-image-placeholder,
|
||||
.md-image-fallback {
|
||||
display: inline-flex;
|
||||
min-width: 96px;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-soft);
|
||||
color: var(--fg-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.md-image-placeholder::before {
|
||||
content: "";
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { app } from "../lib/bridge";
|
||||
import { hasMarkdownImageResolver, markdownImageSource, type MarkdownImageView } from "../lib/markdownImage";
|
||||
import { RichMarkdownLink } from "./githubLink";
|
||||
import { MarkdownImageTabContext } from "./MarkdownImageContext";
|
||||
|
||||
export function MarkdownImage({ src, alt, title }: { src?: string; alt?: string; title?: string }) {
|
||||
const tabId = useContext(MarkdownImageTabContext);
|
||||
const source = src?.trim() ?? "";
|
||||
const resolverAvailable = hasMarkdownImageResolver();
|
||||
const legacyView = useMemo<MarkdownImageView>(
|
||||
() => ({ url: markdownImageSource(source), openHref: /^https?:\/\//i.test(source) ? source : undefined }),
|
||||
[source],
|
||||
);
|
||||
const [resolved, setResolved] = useState<MarkdownImageView | null>(() => resolverAvailable ? null : legacyView);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setLoadFailed(false);
|
||||
if (!hasMarkdownImageResolver()) {
|
||||
setResolved(legacyView);
|
||||
return () => { live = false; };
|
||||
}
|
||||
setResolved(null);
|
||||
void app.ResolveMarkdownImageForTab(tabId, source).then((view) => {
|
||||
if (live) setResolved(view);
|
||||
}).catch(() => {
|
||||
if (live) setResolved({ url: "", errorCode: "resolve-failed" });
|
||||
});
|
||||
return () => { live = false; };
|
||||
}, [legacyView, source, tabId]);
|
||||
|
||||
const unavailable = loadFailed || Boolean(resolved?.errorCode) || (resolved !== null && !resolved.url);
|
||||
if (unavailable) {
|
||||
const label = alt?.trim() || resolved?.filename || "Image unavailable";
|
||||
return (
|
||||
<span className="md-image-fallback" role="img" aria-label={label} title={resolved?.errorCode || title}>
|
||||
<span>{label}</span>
|
||||
{resolved?.openHref && <RichMarkdownLink href={resolved.openHref}>Open image</RichMarkdownLink>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!resolved) {
|
||||
return <span className="md-image-placeholder" role="status" aria-label={alt?.trim() || "Loading image"} />;
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={resolved.url}
|
||||
alt={alt ?? ""}
|
||||
title={title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setLoadFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export const MarkdownImageTabContext = createContext("");
|
||||
@@ -4,7 +4,7 @@ import "katex/dist/katex.min.css";
|
||||
import { normalizeMath } from "./mathNormalize";
|
||||
import { createComponents } from "./markdownComponents";
|
||||
import { reasonixRehypePlugins, reasonixRemarkPlugins } from "./markdownRemarkPlugins";
|
||||
import { markdownUrlTransform } from "../lib/markdownPipeline";
|
||||
import { markdownImageUrlTransform, markdownUrlTransform } from "../lib/markdownPipeline";
|
||||
|
||||
// Markdown rendering via react-markdown + remark-gfm (tables, task lists,
|
||||
// strike, autolinks) and remark-math + rehype-katex for $/$$ KaTeX math.
|
||||
@@ -39,7 +39,9 @@ const MarkdownRenderer = memo(function MarkdownRenderer({
|
||||
components={components}
|
||||
// file:/// anchors (local path linkification) are safe to keep; the
|
||||
// default transform would blank them along with javascript: etc.
|
||||
urlTransform={markdownUrlTransform}
|
||||
urlTransform={(value, key, node) => node.tagName === "img" && key === "src"
|
||||
? markdownImageUrlTransform(value)
|
||||
: markdownUrlTransform(value)}
|
||||
>
|
||||
{mathContent}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { MarkdownTableAlignment, VirtualMarkdownTableData } from "../lib/largeMarkdownTable";
|
||||
import { t } from "../lib/i18n";
|
||||
import { useTranscriptUserResizeIntent } from "./TranscriptLayoutIntentContext";
|
||||
|
||||
export const MARKDOWN_TABLE_VIRTUAL_MIN_ROWS = 50;
|
||||
/** How many body rows to show in the default collapsed preview. */
|
||||
@@ -52,10 +53,11 @@ function TableFoldToggle({
|
||||
totalRows: number;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const beginUserResize = useTranscriptUserResizeIntent();
|
||||
// Module-level t() so unit tests do not need a LocaleProvider; App still
|
||||
// re-renders under LocaleProvider when the locale flips.
|
||||
return (
|
||||
<button type="button" className="md-table-fold__toggle" onClick={onToggle}>
|
||||
<button type="button" className="md-table-fold__toggle" onClick={() => { beginUserResize(); onToggle(); }}>
|
||||
{expanded
|
||||
? t("markdown.tableCollapse")
|
||||
: t("markdown.tableExpandAll", { n: totalRows })}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { visibleTranscriptMemoryCitations } from "../lib/memoryCitationVisibility";
|
||||
import type { MemoryCitation } from "../lib/types";
|
||||
|
||||
export function MemoryCitations({ citations }: { citations?: MemoryCitation[] }) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const clean = visibleTranscriptMemoryCitations(citations)
|
||||
.filter((citation) => (citation.source ?? citation.id ?? citation.note ?? "").trim() !== "")
|
||||
.slice(0, 5);
|
||||
if (clean.length === 0) return null;
|
||||
return (
|
||||
<div className="msg-memory-citations">
|
||||
<button
|
||||
type="button"
|
||||
className="msg-memory-citations__toggle"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<ChevronRight className={`msg-memory-citations__chevron${open ? " msg-memory-citations__chevron--open" : ""}`} size={15} />
|
||||
<span>{t("msg.memoryCompilerCitationsCount", { n: clean.length })}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="msg-memory-citations__body">
|
||||
{clean.map((citation, index) => {
|
||||
const lines = memoryCitationLines(citation, t);
|
||||
return (
|
||||
<div key={`${citation.id ?? citation.source}-${index}`} className="msg-memory-citations__item">
|
||||
<div className="msg-memory-citations__source">
|
||||
<span>{memoryCitationSource(citation)}</span>
|
||||
{lines && <span className="msg-memory-citations__lines">{lines}</span>}
|
||||
</div>
|
||||
{citation.note && <div className="msg-memory-citations__note">{citation.note}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function memoryCitationSource(citation: MemoryCitation): string {
|
||||
const source = (citation.source || citation.id || "Memory v5").trim();
|
||||
if (citation.kind === "compiler_reference" && source === "Memory v5") return "Memory v5 compiler";
|
||||
return source;
|
||||
}
|
||||
|
||||
function memoryCitationLines(citation: MemoryCitation, t: ReturnType<typeof useT>): string {
|
||||
const start = citation.lineStart ?? 0;
|
||||
const end = citation.lineEnd ?? 0;
|
||||
if (start <= 0) return "";
|
||||
if (end > 0 && end !== start) return t("msg.memoryCitationLineRange", { start, end });
|
||||
return t("msg.memoryCitationLine", { line: start });
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { BrainCircuit, ChevronDown, ChevronRight, FileText, Folder, GitBranch, Image, MessageSquare, Pencil, RotateCcw, ScrollText } from "lucide-react";
|
||||
import { BrainCircuit, ChevronDown, FileText, Folder, GitBranch, Image, MessageSquare, Pencil, RotateCcw, ScrollText } from "lucide-react";
|
||||
import { MemoryCitations } from "./MemoryCitations";
|
||||
import { hasSearchFootnotes, SearchFootnotes } from "./SearchFootnotes";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { ComposerContextCard } from "./ComposerContextCard";
|
||||
@@ -14,10 +16,9 @@ import { Tooltip } from "./Tooltip";
|
||||
import { useReasoningDisplayMode } from "../lib/reasoningDisplayPreference";
|
||||
import { historyEntryIdForItemId } from "../lib/transcriptRows";
|
||||
import { stripMemoryCompilerExecution } from "../lib/memoryCompilerDisplay";
|
||||
import { visibleTranscriptMemoryCitations } from "../lib/memoryCitationVisibility";
|
||||
import { invocationSegmentsFromMessage, type InvocationMetadataMap } from "../lib/invocationDisplay";
|
||||
import type { Item, MessageActionScope } from "../lib/useController";
|
||||
import type { CheckpointMeta, MemoryCitation } from "../lib/types";
|
||||
import type { CheckpointMeta } from "../lib/types";
|
||||
import { InvocationBadge } from "./InvocationBadge";
|
||||
import { CodeViewer } from "./CodeViewer";
|
||||
import { formatSelectionLabels, languageFor, parseSelectedTextContext, stripSelectionLabels } from "../lib/selectedTextContext";
|
||||
@@ -146,58 +147,6 @@ export function parseSelectedTextBlocks(text: string, submitText?: string): Sele
|
||||
});
|
||||
}
|
||||
|
||||
function MemoryCitations({ citations }: { citations?: MemoryCitation[] }) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const clean = visibleTranscriptMemoryCitations(citations)
|
||||
.filter((citation) => (citation.source ?? citation.id ?? citation.note ?? "").trim() !== "")
|
||||
.slice(0, 5);
|
||||
if (clean.length === 0) return null;
|
||||
return (
|
||||
<div className="msg-memory-citations">
|
||||
<button
|
||||
type="button"
|
||||
className="msg-memory-citations__toggle"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<ChevronRight className={`msg-memory-citations__chevron${open ? " msg-memory-citations__chevron--open" : ""}`} size={15} />
|
||||
<span>{t("msg.memoryCompilerCitationsCount", { n: clean.length })}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="msg-memory-citations__body">
|
||||
{clean.map((citation, index) => {
|
||||
const lines = memoryCitationLines(citation, t);
|
||||
return (
|
||||
<div key={`${citation.id ?? citation.source}-${index}`} className="msg-memory-citations__item">
|
||||
<div className="msg-memory-citations__source">
|
||||
<span>{memoryCitationSource(citation)}</span>
|
||||
{lines && <span className="msg-memory-citations__lines">{lines}</span>}
|
||||
</div>
|
||||
{citation.note && <div className="msg-memory-citations__note">{citation.note}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function memoryCitationSource(citation: MemoryCitation): string {
|
||||
const source = (citation.source || citation.id || "Memory v5").trim();
|
||||
if (citation.kind === "compiler_reference" && source === "Memory v5") return "Memory v5 compiler";
|
||||
return source;
|
||||
}
|
||||
|
||||
function memoryCitationLines(citation: MemoryCitation, t: ReturnType<typeof useT>): string {
|
||||
const start = citation.lineStart ?? 0;
|
||||
const end = citation.lineEnd ?? 0;
|
||||
if (start <= 0) return "";
|
||||
if (end > 0 && end !== start) return t("msg.memoryCitationLineRange", { start, end });
|
||||
return t("msg.memoryCitationLine", { line: start });
|
||||
}
|
||||
|
||||
function messageDate(value?: number): Date {
|
||||
return new Date(typeof value === "number" && Number.isFinite(value) && value > 0 ? value : Date.now());
|
||||
}
|
||||
@@ -866,8 +815,9 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
}) {
|
||||
const reasoningDisplayMode = useReasoningDisplayMode();
|
||||
const hasText = item.streaming || item.text.trim() !== "";
|
||||
const processOnly = Boolean(item.reasoning) && !hasText;
|
||||
const processWithText = Boolean(item.reasoning) && hasText;
|
||||
const hasFootnotes = hasSearchFootnotes(item.searchSources);
|
||||
const processOnly = Boolean(item.reasoning) && !hasText && !hasFootnotes;
|
||||
const processWithText = Boolean(item.reasoning) && (hasText || hasFootnotes);
|
||||
if (processOnly && (reasoningDisplayMode === "hidden" || reasoningDisplayMode === "pending")) return null;
|
||||
return (
|
||||
<div className={`msg msg--assistant${processOnly ? " msg--process-only" : ""}${processWithText ? " msg--process-with-text" : ""}`} data-history-restore={item.id.startsWith("h") ? "" : undefined} data-entrance={item.id}>
|
||||
@@ -879,14 +829,17 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
truncateStreamingReasoning={truncateStreamingReasoning}
|
||||
/>
|
||||
)}
|
||||
{hasText && (
|
||||
{(hasText || hasFootnotes) && (
|
||||
<div className="msg__body" data-transcript-selectable="message">
|
||||
<Markdown
|
||||
text={item.text}
|
||||
plainStatusBlocks={creationMode}
|
||||
streaming={item.streaming}
|
||||
entryId={historyEntryIdForItemId(item.id)}
|
||||
/>
|
||||
{hasText && (
|
||||
<Markdown
|
||||
text={item.text}
|
||||
plainStatusBlocks={creationMode}
|
||||
streaming={item.streaming}
|
||||
entryId={historyEntryIdForItemId(item.id)}
|
||||
/>
|
||||
)}
|
||||
<SearchFootnotes sources={item.searchSources} />
|
||||
</div>
|
||||
)}
|
||||
<MemoryCitations citations={item.memoryCitations} />
|
||||
|
||||
@@ -10,18 +10,21 @@ import { asArray } from "../lib/array";
|
||||
import { useToast } from "../lib/toast";
|
||||
import { app } from "../lib/bridge";
|
||||
import { onProjectTreeChangedV2 } from "../lib/sessionCatalogBridge";
|
||||
import { isRuntimeSessionNode, isTopicNode, mergeProjectTopicPage, projectTreeDedupedExactTime, projectTreeEventAffectsFolder, projectTreeFolderDisclosure, projectTreeReadActivityKey, projectTreeRevisionIsFresh, projectTreeShouldApplyShellSnapshot, projectTreeShouldRenderTopicActions, projectTreeShouldSuppressOpenForRename, projectTreeTopicArchiveBlocked, projectTreeTopicHasUnreadActivity, projectTreeTopicHoverCardModel, projectTreeTopicMenuOffersPin, projectTreeTopicMetaLine, projectTreeTopicOpenRequest, topicActivityAt, topicActivityDateLabel, topicActivityLabel, topicIsActive, topicStatus, topicStatusLabel, topicUnknownTimeLabel, type ProjectTreePendingTopicOpen, type ProjectTreeReadActivity, type ProjectTreeTopicHoverCard, type ProjectTreeVariant } from "../lib/projectTreeTopic";
|
||||
import { isRuntimeSessionNode, isTopicNode, loadWorkbenchOrganizeMode, loadWorkbenchSortMode, mergeProjectTopicPage, projectTreeDedupedExactTime, projectTreeEventAffectsFolder, projectTreeFolderDisclosure, projectTreeReadActivityKey, projectTreeRevisionIsFresh, projectTreeShellChildren, projectTreeShellSignature, projectTreeShouldApplyShellSnapshot, projectTreeShouldRenderTopicActions, projectTreeShouldSuppressOpenForRename, projectTreeTopicArchiveBlocked, projectTreeTopicHasUnreadActivity, projectTreeTopicHoverCardModel, projectTreeTopicMenuOffersPin, projectTreeTopicMetaLine, projectTreeTopicOpenRequest, projectTreeTopicPageIsFresh, 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";
|
||||
export * from "../lib/projectTreeTopic";
|
||||
import type { ProjectNode, SessionCatalogStatus } from "../lib/types";
|
||||
import { topicActivityTime } from "../lib/session";
|
||||
import { useT, type Translator } from "../lib/i18n";
|
||||
import { PROJECT_COLOR_OPTIONS, projectColorValue } from "../lib/projectColors";
|
||||
import { useProjectTreeArchiveState } from "../lib/projectTreeArchive";
|
||||
import { projectTreeWithoutTopics, reloadProjectTreeTopics, useProjectTreeArchiveController, type ProjectTreeRefresh, type ProjectTreeRefreshOptions } from "../lib/projectTreeArchive";
|
||||
import { topicShortcutLabel, type TopicShortcutEntry } from "../lib/topicShortcuts";
|
||||
import type { ShortcutPlatform } from "../lib/keyboardShortcuts";
|
||||
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { WorktreeBadge } from "./WorktreeBadge";
|
||||
import { useProjectCreation } from "./useProjectCreation";
|
||||
import { useProjectTreeRuntimeProjection } from "../lib/useProjectTreeRuntimeProjection";
|
||||
|
||||
interface ProjectTreeProps {
|
||||
activeScope?: string;
|
||||
activeWorkspaceRoot?: string;
|
||||
@@ -30,7 +33,7 @@ interface ProjectTreeProps {
|
||||
imTopicSources?: Record<string, ProjectTreeImTopicSource>;
|
||||
variant?: ProjectTreeVariant;
|
||||
onOpenTopic: (scope: string, workspaceRoot: string, topicId: string, sessionPath?: string) => Promise<void> | void;
|
||||
onAddProject: () => Promise<void>;
|
||||
onAddProject: (path?: string) => Promise<void>;
|
||||
onCreateTopic?: (scope: string, workspaceRoot: string) => Promise<void> | void;
|
||||
onCreateDeliveryWorktree?: (workspaceRoot: string) => Promise<void> | void;
|
||||
onRenameTopic?: (topicId: string, title: string) => Promise<void> | void;
|
||||
@@ -56,11 +59,8 @@ function projectNodeKey(node: ProjectNode, depth: number): string {
|
||||
return node.key || `${node.kind}-${node.root ?? ""}-${node.topicId ?? ""}-${depth}`;
|
||||
}
|
||||
|
||||
|
||||
type ProjectDropPosition = "before" | "after";
|
||||
type WorkbenchHeaderMenu = "more" | "add" | null;
|
||||
type WorkbenchOrganizeMode = "project" | "recent" | "time";
|
||||
type WorkbenchSortMode = "created" | "updated";
|
||||
|
||||
type CollapseSnapshot = {
|
||||
expanded: Set<string>;
|
||||
@@ -73,11 +73,8 @@ type PinnedTreeSections = {
|
||||
};
|
||||
|
||||
const GLOBAL_PROJECT_ORDER_KEY = "__global__";
|
||||
const WORKBENCH_ORGANIZE_KEY = "projectTree:workbenchOrganize";
|
||||
// Shared by classic and workbench; key string kept for existing saved choices.
|
||||
const WORKBENCH_SORT_KEY = "projectTree:workbenchSort";
|
||||
const READ_ACTIVITY_KEY = "projectTree:readActivity";
|
||||
const READ_ACTIVITY_INIT_KEY = "projectTree:readActivityInitialized";
|
||||
const READ_ACTIVITY_BASELINE_KEY = "projectTree:readActivityBaselineAt";
|
||||
|
||||
function loadReadActivity(): ProjectTreeReadActivity {
|
||||
try {
|
||||
@@ -102,24 +99,16 @@ function saveReadActivity(readActivity: ProjectTreeReadActivity) {
|
||||
}
|
||||
}
|
||||
|
||||
function loadWorkbenchOrganizeMode(): WorkbenchOrganizeMode {
|
||||
function loadReadActivityBaselineAt(): number {
|
||||
try {
|
||||
const value = localStorage.getItem(WORKBENCH_ORGANIZE_KEY);
|
||||
if (value === "recent" || value === "time") return value;
|
||||
const parsed = Number(localStorage.getItem(READ_ACTIVITY_BASELINE_KEY));
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
const now = Date.now();
|
||||
localStorage.setItem(READ_ACTIVITY_BASELINE_KEY, String(now));
|
||||
return now;
|
||||
} catch {
|
||||
/* localStorage unavailable */
|
||||
return Date.now();
|
||||
}
|
||||
return "project";
|
||||
}
|
||||
|
||||
function loadWorkbenchSortMode(): WorkbenchSortMode {
|
||||
try {
|
||||
const value = localStorage.getItem(WORKBENCH_SORT_KEY);
|
||||
if (value === "created") return "created";
|
||||
} catch {
|
||||
/* localStorage unavailable */
|
||||
}
|
||||
return "updated";
|
||||
}
|
||||
|
||||
function projectOrderKey(node: ProjectNode): string {
|
||||
@@ -395,6 +384,7 @@ export function ProjectTree({
|
||||
const [tree, setTree] = useState<ProjectNode[]>([]);
|
||||
const treeRef = useRef<ProjectNode[]>([]);
|
||||
const latestRevisionRef = useRef(0);
|
||||
const topicRevisionRef = useRef<Record<string, number>>({});
|
||||
const [catalogStatus, setCatalogStatus] = useState<SessionCatalogStatus>({
|
||||
state: "opening", revision: 0, indexed: 0, total: 0, repairPending: 0,
|
||||
});
|
||||
@@ -418,7 +408,6 @@ export function ProjectTree({
|
||||
const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
|
||||
const [editingProject, setEditingProject] = useState<{ key: string; root: string } | null>(null);
|
||||
const [projectDraft, setProjectDraft] = useState("");
|
||||
const [addingProject, setAddingProject] = useState(false);
|
||||
const [isolatingProject, setIsolatingProject] = useState<string | null>(null);
|
||||
const [worktreeAvailability, setWorktreeAvailability] = useState<Record<string, { available: boolean; reason?: string }>>({});
|
||||
const [confirmAction, setConfirmAction] = useState<{ topicId: string; action: "trash" } | null>(null);
|
||||
@@ -430,7 +419,9 @@ export function ProjectTree({
|
||||
const [workbenchHeaderMenu, setWorkbenchHeaderMenu] = useState<WorkbenchHeaderMenu>(null);
|
||||
const [workbenchOrganizeMode, setWorkbenchOrganizeMode] = useState<WorkbenchOrganizeMode>(loadWorkbenchOrganizeMode);
|
||||
const [workbenchSortMode, setWorkbenchSortMode] = useState<WorkbenchSortMode>(loadWorkbenchSortMode);
|
||||
const workbenchSortModeRef = useRef(workbenchSortMode);
|
||||
const [readActivity, setReadActivity] = useState<ProjectTreeReadActivity>(loadReadActivity);
|
||||
const [readBaselineAt] = useState(loadReadActivityBaselineAt);
|
||||
const filterRef = useRef<HTMLDivElement>(null);
|
||||
const filterTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -441,7 +432,22 @@ export function ProjectTree({
|
||||
const [hoverCard, setHoverCard] = useState<{ key: string; card: ProjectTreeTopicHoverCard; left: number; top: number } | null>(null);
|
||||
const hoverCardTimerRef = useRef<number | null>(null);
|
||||
const creatingRef = useRef(false);
|
||||
const { trashingTopics, beginTrashingTopic, endTrashingTopic } = useProjectTreeArchiveState();
|
||||
const closeMenu = useCallback(() => {
|
||||
setMenuTopic(null);
|
||||
setMenuProject(null);
|
||||
setMenuPoint(null);
|
||||
setConfirmAction(null);
|
||||
setConfirmRemoveProject(null);
|
||||
setWorkbenchHeaderMenu(null);
|
||||
}, []);
|
||||
const topicLoadSeqRef = useRef<Record<string, number>>({});
|
||||
const refreshRef = useRef<ProjectTreeRefresh>(async () => {});
|
||||
const { trashingTopics, currentArchiveTombstones, trashTopic } = useProjectTreeArchiveController({
|
||||
treeRef, topicLoadSeqRef, topicPageStateRef, updateTopicPageState, refreshRef,
|
||||
optimisticallyRemoveTopic: (topicId) => setTree((current) => projectTreeWithoutTopic(current, topicId)),
|
||||
closeMenu, onTopicsChanged, showToast,
|
||||
});
|
||||
const applyRuntimeProjection = useProjectTreeRuntimeProjection(setTree, currentArchiveTombstones);
|
||||
const clickTimerRef = useRef<ProjectTreePendingTopicOpen | null>(null);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -468,15 +474,6 @@ export function ProjectTree({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setMenuTopic(null);
|
||||
setMenuProject(null);
|
||||
setMenuPoint(null);
|
||||
setConfirmAction(null);
|
||||
setConfirmRemoveProject(null);
|
||||
setWorkbenchHeaderMenu(null);
|
||||
}, []);
|
||||
|
||||
const updateManuallyCollapsed = useCallback((updater: (prev: Set<string>) => Set<string>) => {
|
||||
setManuallyCollapsed((prev) => {
|
||||
const next = updater(prev);
|
||||
@@ -485,7 +482,7 @@ export function ProjectTree({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const topicLoadSeqRef = useRef<Record<string, number>>({});
|
||||
const loadProjectTopicsRef = useRef<(project: ProjectNode, append?: boolean) => Promise<void>>(async () => {});
|
||||
|
||||
const loadProjectTopics = useCallback(async (project: ProjectNode, append = false) => {
|
||||
if (project.kind !== "project" && project.kind !== "global_folder") return;
|
||||
@@ -493,7 +490,8 @@ export function ProjectTree({
|
||||
const pageState = topicPageStateRef.current[key];
|
||||
const cursor = append ? pageState?.nextCursor ?? "" : "";
|
||||
if (append && !cursor) return;
|
||||
// Last-query-wins: never drop a newer search because an older page is still loading.
|
||||
const sortMode = creationTopics ? "updated" : workbenchSortModeRef.current;
|
||||
// Last-query-wins: stale completions cannot overwrite a newer first page.
|
||||
const seq = (topicLoadSeqRef.current[key] ?? 0) + 1;
|
||||
topicLoadSeqRef.current[key] = seq;
|
||||
updateTopicPageState(key, { ...pageState, loading: true });
|
||||
@@ -505,42 +503,85 @@ export function ProjectTree({
|
||||
limit: timeFilter === "10" ? 10 : timeFilter === "20" ? 20 : 50,
|
||||
query: query.trim(),
|
||||
timeFilter: timeFilter === "10" || timeFilter === "20" || timeFilter === "all" ? "" : timeFilter,
|
||||
sortMode,
|
||||
});
|
||||
if (topicLoadSeqRef.current[key] !== seq) return;
|
||||
if (!projectTreeRevisionIsFresh(latestRevisionRef.current, page.revision)) {
|
||||
if (!projectTreeTopicPageIsFresh(topicRevisionRef.current, key, page.revision)) {
|
||||
updateTopicPageState(key, { ...topicPageStateRef.current[key], loading: false });
|
||||
return;
|
||||
}
|
||||
latestRevisionRef.current = Math.max(latestRevisionRef.current, page.revision);
|
||||
const items = asArray(page.items);
|
||||
setTree((current) => current.map((node) => {
|
||||
topicRevisionRef.current[key] = Math.max(topicRevisionRef.current[key] ?? 0, page.revision);
|
||||
const items = projectTreeWithoutTopics(asArray(page.items), currentArchiveTombstones());
|
||||
setTree((current) => applyRuntimeProjection(current.map((node) => {
|
||||
if (node.key !== key) return node;
|
||||
return { ...node, children: mergeProjectTopicPage(asArray(node.children), items, append) };
|
||||
}));
|
||||
})));
|
||||
updateTopicPageState(key, { nextCursor: page.nextCursor, loading: false });
|
||||
} catch {
|
||||
if (topicLoadSeqRef.current[key] !== seq) return;
|
||||
updateTopicPageState(key, { ...topicPageStateRef.current[key], loading: false });
|
||||
}
|
||||
}, [query, timeFilter, updateTopicPageState]);
|
||||
// Snapshot is intentionally shells-only. Preserve already loaded pages by
|
||||
// project key so a metadata refresh does not collapse or blank the sidebar.
|
||||
const refresh = useCallback(async () => {
|
||||
}, [applyRuntimeProjection, creationTopics, currentArchiveTombstones, query, timeFilter, updateTopicPageState]);
|
||||
loadProjectTopicsRef.current = loadProjectTopics;
|
||||
|
||||
const selectWorkbenchSortMode = useCallback((sortMode: WorkbenchSortMode) => {
|
||||
if (workbenchSortModeRef.current === sortMode) {
|
||||
closeMenu();
|
||||
setFilterMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
// Invalidate before scheduling React state so an already-resolved older
|
||||
// request cannot write back during the render/effect gap. Its pagination
|
||||
// cursor belongs to the old order and must not be reused by the new query.
|
||||
workbenchSortModeRef.current = sortMode;
|
||||
for (const key in topicLoadSeqRef.current) topicLoadSeqRef.current[key] += 1;
|
||||
topicPageStateRef.current = {};
|
||||
setTopicPageState({});
|
||||
setWorkbenchSortMode(sortMode);
|
||||
closeMenu();
|
||||
setFilterMenuOpen(false);
|
||||
|
||||
const filtering = query.trim() !== "" || timeFilter !== "all";
|
||||
for (const project of treeRef.current) {
|
||||
const key = projectNodeKey(project, 0);
|
||||
if (filtering || expanded.has(key)) void loadProjectTopics(project);
|
||||
}
|
||||
}, [closeMenu, expanded, loadProjectTopics, query, timeFilter]);
|
||||
// Snapshot carries project shells plus lightweight pinned topic shells.
|
||||
// Preserve already loaded pages by project key while reconciling pins, so a
|
||||
// metadata refresh does not collapse or blank the sidebar.
|
||||
const refresh = useCallback(async (options?: ProjectTreeRefreshOptions) => {
|
||||
const reloadRequestedProjects = (projects: ProjectNode[]) => reloadProjectTreeTopics(projects, options, loadProjectTopicsRef.current);
|
||||
try {
|
||||
const snapshot = await app.GetProjectTreeSnapshot();
|
||||
const rev = snapshot.revision ?? 0, empty = treeRef.current.length === 0;
|
||||
if (!projectTreeShouldApplyShellSnapshot({ currentRevision: latestRevisionRef.current, incomingRevision: rev, treeEmpty: empty })) return;
|
||||
if (!projectTreeShouldApplyShellSnapshot({ currentRevision: latestRevisionRef.current, incomingRevision: rev, treeEmpty: empty })) {
|
||||
await reloadRequestedProjects(treeRef.current);
|
||||
return;
|
||||
}
|
||||
if (projectTreeRevisionIsFresh(latestRevisionRef.current, rev)) latestRevisionRef.current = Math.max(latestRevisionRef.current, rev);
|
||||
const projects = asArray(snapshot.projects);
|
||||
setCatalogStatus(snapshot.catalog);
|
||||
setTree((current) => projects.map((project) => {
|
||||
setTree((current) => applyRuntimeProjection(projects.map((project) => {
|
||||
const previous = current.find((node) => node.key === project.key);
|
||||
return { ...project, children: asArray(previous?.children) };
|
||||
}));
|
||||
// Topic pages reload asynchronously. Keep the last painted children
|
||||
// until their replacement arrives so a mutation cannot blank every
|
||||
// expanded folder for the duration of a catalog scan.
|
||||
return { ...project, children: projectTreeShellChildren(previous?.children, project.children) };
|
||||
})));
|
||||
await reloadRequestedProjects(projects);
|
||||
} catch {
|
||||
/* bridge unavailable */
|
||||
// A shell snapshot is metadata-only. If it fails, the resident folder
|
||||
// identity can still drive the requested canonical topic reload.
|
||||
await reloadRequestedProjects(treeRef.current);
|
||||
}
|
||||
}, []);
|
||||
}, [applyRuntimeProjection]);
|
||||
refreshRef.current = refresh;
|
||||
const { addingProject, handleAddProject, openBlankProjectFlow, blankProjectFlow } = useProjectCreation({
|
||||
onAddProject,
|
||||
onRefresh: refresh,
|
||||
showToast,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
treeRef.current = tree;
|
||||
@@ -575,7 +616,9 @@ export function ProjectTree({
|
||||
}), [expanded, loadProjectTopics, refresh]);
|
||||
|
||||
// Debounce query/timeFilter reloads so typing does not stampede the catalog.
|
||||
// Expansion and tree shell arrival still load on the same path after the delay.
|
||||
// Dependency is the project-shell signature, not tree: topic page loads
|
||||
// rewrite children and would otherwise re-arm this effect in a loop.
|
||||
const projectShellSignature = useMemo(() => projectTreeShellSignature(tree), [tree]);
|
||||
useEffect(() => {
|
||||
const filtering = query.trim() !== "" || timeFilter !== "all";
|
||||
const timer = setTimeout(() => {
|
||||
@@ -585,7 +628,7 @@ export function ProjectTree({
|
||||
}
|
||||
}, 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [expanded, loadProjectTopics, query, timeFilter, tree]);
|
||||
}, [expanded, loadProjectTopics, projectShellSignature, query, timeFilter]);
|
||||
|
||||
// Following the active topic is a view concern over the tree already held.
|
||||
useEffect(() => {
|
||||
@@ -601,52 +644,14 @@ export function ProjectTree({
|
||||
const activityAt = topicActivityAt(node);
|
||||
if (!key || activityAt <= 0) return;
|
||||
setReadActivity((prev) => {
|
||||
if ((prev[key] ?? 0) >= activityAt) return prev;
|
||||
const next = { ...prev, [key]: activityAt };
|
||||
const readAt = Math.max(activityAt, Date.now());
|
||||
if ((prev[key] ?? 0) >= readAt) return prev;
|
||||
const next = { ...prev, [key]: readAt };
|
||||
saveReadActivity(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (tree.length === 0) return;
|
||||
try {
|
||||
if (localStorage.getItem(READ_ACTIVITY_INIT_KEY)) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const baseline: ProjectTreeReadActivity = {};
|
||||
const collectBaseline = (nodes: ProjectNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if ((isTopicNode(node) || isRuntimeSessionNode(node)) && topicStatus(node) === "") {
|
||||
const key = projectTreeReadActivityKey(node);
|
||||
const activityAt = topicActivityAt(node);
|
||||
if (key && activityAt > 0) baseline[key] = Math.max(baseline[key] ?? 0, activityAt);
|
||||
}
|
||||
collectBaseline(asArray(node.children));
|
||||
}
|
||||
};
|
||||
collectBaseline(tree);
|
||||
try {
|
||||
localStorage.setItem(READ_ACTIVITY_INIT_KEY, "1");
|
||||
} catch {
|
||||
/* localStorage unavailable */
|
||||
}
|
||||
if (Object.keys(baseline).length === 0) return;
|
||||
setReadActivity((prev) => {
|
||||
const next = { ...prev };
|
||||
let changed = false;
|
||||
for (const [key, value] of Object.entries(baseline)) {
|
||||
if ((next[key] ?? 0) >= value) continue;
|
||||
next[key] = value;
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return prev;
|
||||
saveReadActivity(next);
|
||||
return next;
|
||||
});
|
||||
}, [tree]);
|
||||
|
||||
useEffect(() => {
|
||||
const markActive = (nodes: ProjectNode[]) => {
|
||||
for (const node of nodes) {
|
||||
@@ -792,17 +797,6 @@ export function ProjectTree({
|
||||
});
|
||||
}, [collapseSnapshot, expanded, folderKeys, hasExpandedFolders, manuallyCollapsed, searchActive, updateManuallyCollapsed]);
|
||||
|
||||
const handleAddProject = async () => {
|
||||
if (addingProject) return;
|
||||
setAddingProject(true);
|
||||
try {
|
||||
await onAddProject();
|
||||
await refresh();
|
||||
} finally {
|
||||
setAddingProject(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openWorkbenchHeaderMenu = (
|
||||
event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>,
|
||||
menu: Exclude<WorkbenchHeaderMenu, null>,
|
||||
@@ -911,21 +905,6 @@ export function ProjectTree({
|
||||
}
|
||||
};
|
||||
|
||||
const trashTopic = async (topicId: string) => {
|
||||
if (!beginTrashingTopic(topicId)) return;
|
||||
try {
|
||||
await app.TrashTopic(topicId);
|
||||
setMenuTopic(null);
|
||||
setMenuPoint(null);
|
||||
setConfirmAction(null);
|
||||
await refresh();
|
||||
await onTopicsChanged?.();
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
endTrashingTopic(topicId);
|
||||
}
|
||||
};
|
||||
const setTopicPinned = async (topicId: string, pinned: boolean) => {
|
||||
try {
|
||||
await app.SetTopicPinned(topicId, pinned);
|
||||
@@ -1172,7 +1151,7 @@ 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*/, "");
|
||||
const conflictCopyLabel = isSessionNode && node.recovered ? t("recovery.badge") : "";
|
||||
// Ordinary list never advertises recovery copies; History owns that view.
|
||||
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
|
||||
@@ -1191,15 +1170,14 @@ export function ProjectTree({
|
||||
const showStatusInSide = status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job";
|
||||
const showWaitingPill = waitingConfirmation;
|
||||
const showSideTime = sideTimeVisible && !showWaitingPill;
|
||||
const unread = projectTreeTopicHasUnreadActivity(node, readActivity, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
|
||||
const unread = projectTreeTopicHasUnreadActivity(node, readActivity, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath, readBaselineAt);
|
||||
const topicId = node.topicId ?? "";
|
||||
const topicTrashing = trashingTopics.has(topicId);
|
||||
const imSource = scope === "global" && topicId ? imTopicSources[topicId] : undefined;
|
||||
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 conflictCopyTitle = isSessionNode && node.recovered ? t("recovery.branch") : "";
|
||||
const title = [label, conflictCopyTitle, imSourceTitle, statusLabel, metaFull, projectTreeDedupedExactTime(metaFull, exactTimeLabel)].filter(Boolean).join(" · ");
|
||||
const title = [label, 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");
|
||||
@@ -1323,7 +1301,7 @@ export function ProjectTree({
|
||||
>
|
||||
<span className="project-tree__topic-copy">
|
||||
<span className="project-tree__topic-heading">
|
||||
<span className="project-tree__topic-label">{conflictCopyLabel ? `${label} · ${conflictCopyLabel}` : label}</span>
|
||||
<span className="project-tree__topic-label">{label}</span>
|
||||
{imSource && (
|
||||
<span
|
||||
className={`project-tree__topic-im project-tree__topic-im--${imSourcePlatform}`}
|
||||
@@ -1334,7 +1312,7 @@ export function ProjectTree({
|
||||
<span>{imSourceLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
{!compactTopics && statusLabel && (!classicTopics || status === "paused" || status === "error") && (
|
||||
{!compactTopics && statusLabel && (!classicTopics || status === "paused" || status === "awaiting_delivery" || status === "error") && (
|
||||
<span className={`project-tree__topic-status project-tree__topic-status--${status}`}>{statusLabel}</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -1873,8 +1851,7 @@ export function ProjectTree({
|
||||
icon: <Clock size={13} />,
|
||||
label: menuLabelWithCheck(t("projectTree.sortByCreatedAt"), workbenchSortMode === "created"),
|
||||
onSelect: () => {
|
||||
setWorkbenchSortMode("created");
|
||||
closeMenu();
|
||||
selectWorkbenchSortMode("created");
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1882,8 +1859,7 @@ export function ProjectTree({
|
||||
icon: <Pencil size={13} />,
|
||||
label: menuLabelWithCheck(t("projectTree.sortByUpdatedAt"), workbenchSortMode === "updated"),
|
||||
onSelect: () => {
|
||||
setWorkbenchSortMode("updated");
|
||||
closeMenu();
|
||||
selectWorkbenchSortMode("updated");
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1893,8 +1869,8 @@ export function ProjectTree({
|
||||
key: "blank-project",
|
||||
icon: <FolderPlus size={13} />,
|
||||
label: t("projectTree.createBlankProject"),
|
||||
disabled: true,
|
||||
onSelect: () => {},
|
||||
disabled: addingProject,
|
||||
onSelect: () => { closeMenu(); openBlankProjectFlow(); },
|
||||
},
|
||||
{
|
||||
key: "existing-folder",
|
||||
@@ -2019,7 +1995,7 @@ export function ProjectTree({
|
||||
<button
|
||||
type="button"
|
||||
className={`project-tree__time-filter-opt${workbenchSortMode === "updated" ? " project-tree__time-filter-opt--on" : ""}`}
|
||||
onClick={() => { setWorkbenchSortMode("updated"); setFilterMenuOpen(false); }}
|
||||
onClick={() => selectWorkbenchSortMode("updated")}
|
||||
role="menuitem"
|
||||
>
|
||||
{t("projectTree.sortByUpdatedAt")}
|
||||
@@ -2027,7 +2003,7 @@ export function ProjectTree({
|
||||
<button
|
||||
type="button"
|
||||
className={`project-tree__time-filter-opt${workbenchSortMode === "created" ? " project-tree__time-filter-opt--on" : ""}`}
|
||||
onClick={() => { setWorkbenchSortMode("created"); setFilterMenuOpen(false); }}
|
||||
onClick={() => selectWorkbenchSortMode("created")}
|
||||
role="menuitem"
|
||||
>
|
||||
{t("projectTree.sortByCreatedAt")}
|
||||
@@ -2270,6 +2246,7 @@ export function ProjectTree({
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
{blankProjectFlow}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useT } from "../lib/i18n";
|
||||
import { useCollapseAnimation } from "../lib/useCollapseAnimation";
|
||||
import type { Item } from "../lib/useController";
|
||||
import { ToolCard } from "./ToolCard";
|
||||
import { useTranscriptUserResizeIntent } from "./TranscriptLayoutIntentContext";
|
||||
|
||||
type ToolItem = Extract<Item, { kind: "tool" }>;
|
||||
|
||||
@@ -15,6 +16,7 @@ type ReadOnlyBatchProps = {
|
||||
|
||||
export const ReadOnlyBatch = memo(function ReadOnlyBatch({ items, subcalls, tabId }: ReadOnlyBatchProps) {
|
||||
const t = useT();
|
||||
const beginUserResize = useTranscriptUserResizeIntent();
|
||||
const [open, setOpen] = useState(false);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
useCollapseAnimation(bodyRef, open);
|
||||
@@ -33,7 +35,7 @@ export const ReadOnlyBatch = memo(function ReadOnlyBatch({ items, subcalls, tabI
|
||||
|
||||
return (
|
||||
<div className={`readonly-batch${open ? " readonly-batch--open" : ""}`} data-entrance={items[0]?.id}>
|
||||
<button type="button" className="reasoning__head" onClick={() => setOpen((v) => !v)} aria-expanded={open}>
|
||||
<button type="button" className="reasoning__head" onClick={() => { beginUserResize(); setOpen((v) => !v); }} aria-expanded={open}>
|
||||
<ChevronRight className={`reasoning__chevron${open ? " reasoning__chevron--open" : ""}`} size={12} />
|
||||
<span className="readonly-batch__label" data-creation-label={t("creation.toolCallsLabel")}>{label}</span>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { GitBranch, Trash2, X } from "lucide-react";
|
||||
import { app } from "../lib/bridge";
|
||||
import { asArray } from "../lib/array";
|
||||
import type { ProjectTopicKey } from "../lib/sessionCatalogTypes";
|
||||
import type { RecoveryCleanupResult, RecoveryLineageView } from "../lib/types";
|
||||
import { useT } from "../lib/i18n";
|
||||
|
||||
interface RecoveryLineageDialogProps {
|
||||
topic: ProjectTopicKey;
|
||||
initial: RecoveryLineageView;
|
||||
onClose: () => void;
|
||||
onChanged: () => Promise<void> | void;
|
||||
onOpenVersion?: (path: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function fileName(path: string): string {
|
||||
const parts = path.split(/[\\/]/);
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
|
||||
function roleKey(role: string): "recovery.role.covered_copy" | "recovery.role.adopted" | "recovery.role.preferred" | "recovery.role.diverged" | "recovery.role.normal" {
|
||||
if (role === "covered_copy") return "recovery.role.covered_copy";
|
||||
if (role === "adopted") return "recovery.role.adopted";
|
||||
if (role === "preferred") return "recovery.role.preferred";
|
||||
if (role === "diverged") return "recovery.role.diverged";
|
||||
return "recovery.role.normal";
|
||||
}
|
||||
|
||||
export function RecoveryLineageDialog({ topic, initial, onClose, onChanged, onOpenVersion }: RecoveryLineageDialogProps) {
|
||||
const t = useT();
|
||||
const [view, setView] = useState(initial);
|
||||
const [preview, setPreview] = useState<RecoveryCleanupResult | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<RecoveryCleanupResult | null>(null);
|
||||
|
||||
const choose = async (path: string) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await app.ChooseRecoveryBranch({ ...topic, path });
|
||||
setView(await app.GetRecoveryLineage(topic));
|
||||
await onChanged();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openVersion = async (path: string) => {
|
||||
if (busy || !onOpenVersion) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await onOpenVersion(path);
|
||||
onClose();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clean = async (apply: boolean) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const next = await app.CleanRecoveryLineage({ ...topic, apply });
|
||||
if (!apply) {
|
||||
setPreview(next);
|
||||
return;
|
||||
}
|
||||
setResult(next);
|
||||
setPreview(null);
|
||||
setView(await app.GetRecoveryLineage(topic));
|
||||
await onChanged();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="management-modal-backdrop recovery-lineage-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
|
||||
<section className="management-modal recovery-lineage-dialog" role="dialog" aria-modal="true" aria-labelledby="recovery-lineage-title">
|
||||
<header className="management-modal__head">
|
||||
<div>
|
||||
<div className="management-modal__title" id="recovery-lineage-title">
|
||||
<GitBranch size={17} aria-hidden="true" /> {t("recovery.lineageTitle")}
|
||||
</div>
|
||||
<div className="management-modal__summary">
|
||||
{t("recovery.lineageSummary", { branches: view.branchCount, unresolved: view.unresolved })}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="icon-btn" onClick={onClose} aria-label={t("common.close")}><X size={16} /></button>
|
||||
</header>
|
||||
<div className="recovery-lineage-dialog__body">
|
||||
{asArray(view.members).map((member) => (
|
||||
<div className="recovery-lineage-dialog__member" key={member.path}>
|
||||
<div className="recovery-lineage-dialog__member-name" title={member.path}>{fileName(member.path)}</div>
|
||||
<div className="recovery-lineage-dialog__member-meta">
|
||||
<span>{t(roleKey(member.role))}</span>
|
||||
<span>{t(member.turns === 1 ? "history.turnOne" : "history.turnOther", { n: member.turns })}</span>
|
||||
{(member.open || member.running) && <span>{t("recovery.inUse")}</span>}
|
||||
{member.canonical && <span>{t("recovery.role.preferred")}</span>}
|
||||
{onOpenVersion && (
|
||||
<button type="button" className="recovery-lineage-dialog__choose" disabled={busy} onClick={() => void openVersion(member.path)}>
|
||||
{t("recovery.openVersion")}
|
||||
</button>
|
||||
)}
|
||||
{member.role !== "covered_copy" && !member.canonical && (
|
||||
<button type="button" className="recovery-lineage-dialog__choose" disabled={busy} onClick={() => void choose(member.path)}>
|
||||
{t("recovery.chooseBranch")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{view.members.length === 0 && <div className="management-modal__summary">{t("recovery.lineageEmpty")}</div>}
|
||||
{preview && (
|
||||
<div className="recovery-lineage-dialog__notice" role="status">
|
||||
{t("recovery.cleanupConfirm", { count: preview.eligible })}
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<div className="recovery-lineage-dialog__notice" role="status">
|
||||
{t("recovery.cleanupResult", { moved: result.moved, busy: result.busy, kept: result.kept })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<footer className="modal__actions recovery-lineage-dialog__actions">
|
||||
<button type="button" className="btn" onClick={onClose}>{t("common.close")}</button>
|
||||
{view.cleanupEligible > 0 && !preview && (
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => void clean(false)}>
|
||||
<Trash2 size={14} /> {t("recovery.previewCleanup")}
|
||||
</button>
|
||||
)}
|
||||
{preview && preview.eligible > 0 && (
|
||||
<button type="button" className="btn btn--danger" disabled={busy} onClick={() => void clean(true)}>
|
||||
<Trash2 size={14} /> {t("recovery.applyCleanup")}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { formatSearchFootnotesMarkdown } from "../lib/searchSources";
|
||||
import type { SearchSource } from "../lib/searchSources";
|
||||
import { Markdown } from "./Markdown";
|
||||
|
||||
export function SearchFootnotes({ sources }: { sources?: SearchSource[] }) {
|
||||
const footnotes = formatSearchFootnotesMarkdown(sources ?? []);
|
||||
if (!footnotes) return null;
|
||||
return (
|
||||
<div className="msg-search-sources">
|
||||
<Markdown text={footnotes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function hasSearchFootnotes(sources?: SearchSource[]): boolean {
|
||||
return formatSearchFootnotesMarkdown(sources ?? []) !== "";
|
||||
}
|
||||
@@ -1705,7 +1705,7 @@ function GeneralSection({ s, busy, apply, agentRunning }: SectionProps & { agent
|
||||
<SettingsField label={t("settings.reasoningDisplay")} hint={t("settings.reasoningDisplayHint")} icon={<BrainCircuit size={18} />}>
|
||||
<div>
|
||||
<div className="set-seg" role="radiogroup" aria-label={t("settings.reasoningDisplay")}>
|
||||
{(["hidden", "summary", "auto"] as const).map((mode) => (
|
||||
{(["hidden", "summary", "auto", "expanded"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
|
||||
@@ -7,12 +7,13 @@ import { diffsFor, languageForToolArgs, subjectOf, summarize, summarizeFileDiff
|
||||
import { useShellExpand } from "../lib/shellExpand";
|
||||
import { app } from "../lib/bridge";
|
||||
import { useCollapseAnimation } from "../lib/useCollapseAnimation";
|
||||
import { isTerminalSubagentPhase, type Item, type SubagentPhase } from "../lib/useController";
|
||||
import { isBatchedReadOnlyTool, isTerminalSubagentPhase, type Item, type SubagentPhase } from "../lib/useController";
|
||||
import type { Translator } from "../lib/i18n";
|
||||
import { ReadOnlyBatch } from "./ReadOnlyBatch";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { ReasoningSummary } from "./ReasoningSummary";
|
||||
import { useReasoningDisplayMode } from "../lib/reasoningDisplayPreference";
|
||||
import { useTranscriptUserResizeIntent } from "./TranscriptLayoutIntentContext";
|
||||
|
||||
type ToolItem = Extract<Item, { kind: "tool" }>;
|
||||
|
||||
@@ -177,6 +178,7 @@ function splitPreview(text: string, n: number): { preview: string; total: number
|
||||
// the sub-agent's work is visible as it happens.
|
||||
export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayName }: { item: ToolItem; subcalls?: ToolItem[]; tabId?: string; displayName?: string }) {
|
||||
const t = useT();
|
||||
const beginUserResize = useTranscriptUserResizeIntent();
|
||||
const nested = subcalls ?? [];
|
||||
const hasNested = nested.length > 0;
|
||||
const isSubagent = SUBAGENT_TOOLS.has(item.name);
|
||||
@@ -213,7 +215,9 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
// user sees nested calls; they collapse when done. Reasoning (AssistantMessage)
|
||||
// also opens while streaming and closes on finish.
|
||||
const subagentReasoningRunning = sp?.phase === "reasoning";
|
||||
const defaultOpen = (hasNested && item.status === "running") || (reasoningDisplayMode === "auto" && subagentReasoningRunning);
|
||||
const liveFollow = reasoningDisplayMode === "auto" || reasoningDisplayMode === "expanded";
|
||||
const keepSubagentReasoningExpanded = reasoningDisplayMode === "expanded" && Boolean(sp?.reasoning);
|
||||
const defaultOpen = (hasNested && item.status === "running") || (liveFollow && subagentReasoningRunning) || keepSubagentReasoningExpanded;
|
||||
const [userOpen, setUserOpen] = useState<boolean | null>(null);
|
||||
const open = userOpen ?? defaultOpen;
|
||||
const openRef = useRef(open);
|
||||
@@ -223,7 +227,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
// The sub-agent reasoning preview opens as a one-line summary; the full
|
||||
// Markdown only mounts after the user expands the reasoning section.
|
||||
const [subagentReasoningOpen, setSubagentReasoningOpen] = useState(
|
||||
() => reasoningDisplayMode === "auto" && subagentReasoningRunning,
|
||||
() => reasoningDisplayMode === "expanded" || (reasoningDisplayMode === "auto" && subagentReasoningRunning),
|
||||
);
|
||||
const subagentReasoningUserOverridden = useRef(false);
|
||||
const previousSubagentReasoningRunning = useRef(subagentReasoningRunning);
|
||||
@@ -235,17 +239,16 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
previousSubagentReasoningRunning.current = subagentReasoningRunning;
|
||||
if (modeChanged) {
|
||||
subagentReasoningUserOverridden.current = false;
|
||||
setSubagentReasoningOpen(reasoningDisplayMode === "auto" && subagentReasoningRunning);
|
||||
return;
|
||||
}
|
||||
if (reasoningDisplayMode !== "auto") {
|
||||
setSubagentReasoningOpen(false);
|
||||
setSubagentReasoningOpen(reasoningDisplayMode === "expanded" || (reasoningDisplayMode === "auto" && subagentReasoningRunning));
|
||||
return;
|
||||
}
|
||||
if (subagentReasoningRunning && !wasRunning) {
|
||||
subagentReasoningUserOverridden.current = false;
|
||||
setSubagentReasoningOpen(true);
|
||||
} else if (!subagentReasoningRunning && wasRunning && !subagentReasoningUserOverridden.current) {
|
||||
if (liveFollow) setSubagentReasoningOpen(true);
|
||||
return;
|
||||
}
|
||||
if (reasoningDisplayMode !== "auto") return;
|
||||
if (!subagentReasoningRunning && wasRunning && !subagentReasoningUserOverridden.current) {
|
||||
setSubagentReasoningOpen(false);
|
||||
}
|
||||
}, [reasoningDisplayMode, subagentReasoningRunning]);
|
||||
@@ -307,7 +310,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
// completion so they don't clutter the transcript. During execution they still
|
||||
// render so the user sees progress.
|
||||
const quiet =
|
||||
item.readOnly && !hasNested && item.status !== "error" && item.status !== "stopped";
|
||||
item.readOnly && item.name !== "web_search" && !hasNested && item.status !== "error" && item.status !== "stopped";
|
||||
|
||||
const duration = item.status === "running" ? "" : (shellSummary || formatToolDuration(item.durationMs));
|
||||
// While the model is still streaming this call's arguments (partial
|
||||
@@ -333,7 +336,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
type="button"
|
||||
className="tool__head"
|
||||
data-running={item.status === "running" ? "" : undefined}
|
||||
onClick={() => hasBody && setUserOpen(!open)}
|
||||
onClick={() => { if (hasBody) { beginUserResize(); setUserOpen(!open); } }}
|
||||
aria-expanded={hasBody ? open : undefined}
|
||||
aria-label={a11yLabel}
|
||||
>
|
||||
@@ -393,6 +396,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
type="button"
|
||||
className="tool__subagent-preview-label tool__subagent-preview-label--toggle"
|
||||
onClick={() => {
|
||||
beginUserResize();
|
||||
subagentReasoningUserOverridden.current = true;
|
||||
const next = !subagentReasoningOpen;
|
||||
if (next) setUserOpen(true);
|
||||
@@ -411,6 +415,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
text={sp.reasoning}
|
||||
streaming={sp.phase === "reasoning"}
|
||||
onOpen={() => {
|
||||
beginUserResize();
|
||||
subagentReasoningUserOverridden.current = true;
|
||||
setUserOpen(true);
|
||||
setSubagentReasoningOpen(true);
|
||||
@@ -446,7 +451,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
roBatch.length = 0;
|
||||
};
|
||||
for (const c of nested) {
|
||||
if (c.readOnly && c.name !== "todo_write") {
|
||||
if (isBatchedReadOnlyTool(c.name, c.readOnly)) {
|
||||
roBatch.push(c);
|
||||
continue;
|
||||
}
|
||||
@@ -469,7 +474,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
<>
|
||||
<CodeViewer value={showAll ? shellOutput! : shellPreview.preview} maxHeight={showAll ? 480 : 260} />
|
||||
{shellPreview.hasMore && !showAll && (
|
||||
<button className="tool__showall" onClick={() => setShowAll(true)}>
|
||||
<button className="tool__showall" onClick={() => { beginUserResize(); setShowAll(true); }}>
|
||||
{t("tool.showAllLines", { n: shellPreview.total })}
|
||||
</button>
|
||||
)}
|
||||
@@ -504,7 +509,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
<button
|
||||
type="button"
|
||||
className="tool__err-toggle"
|
||||
onClick={() => setShowErrorDetails((value) => !value)}
|
||||
onClick={() => { beginUserResize(); setShowErrorDetails((value) => !value); }}
|
||||
aria-expanded={showErrorDetails}
|
||||
>
|
||||
<ChevronRight className={`tool__err-toggle-icon${showErrorDetails ? " tool__err-toggle-icon--open" : ""}`} size={12} aria-hidden="true" />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useT } from "../lib/i18n";
|
||||
import { useCollapseAnimation } from "../lib/useCollapseAnimation";
|
||||
import type { Item } from "../lib/useController";
|
||||
import { ToolCard } from "./ToolCard";
|
||||
import { useTranscriptUserResizeIntent } from "./TranscriptLayoutIntentContext";
|
||||
|
||||
type ToolItem = Extract<Item, { kind: "tool" }>;
|
||||
|
||||
@@ -15,7 +16,7 @@ const MODIFY_TOOLS = new Set(["write_file", "edit_file", "multi_edit", "move_fil
|
||||
const DELEGATE_TOOLS = new Set(["task", "run_skill", "explore", "research", "review", "security_review"]);
|
||||
|
||||
export function toolGroupKind(item: ToolItem): ToolGroupKind | null {
|
||||
if (item.parentId || item.name === "todo_write" || item.name === "exit_plan_mode") return null;
|
||||
if (item.parentId || item.name === "todo_write" || item.name === "exit_plan_mode" || item.name === "web_search") return null;
|
||||
if (SHELL_TOOLS.has(item.name)) return "shell";
|
||||
if (EXPLORE_TOOLS.has(item.name)) return "explore";
|
||||
if (MODIFY_TOOLS.has(item.name)) return "modify";
|
||||
@@ -92,6 +93,7 @@ function toolDisplayName(name: string): string {
|
||||
case "read_file": return "Read";
|
||||
case "ls": return "List";
|
||||
case "web_fetch": return "Web Fetch";
|
||||
case "web_search": return "Search";
|
||||
case "code_index": return "Code Index";
|
||||
case "write_file": return "Write";
|
||||
case "edit_file": return "Edit";
|
||||
@@ -119,6 +121,7 @@ export const ToolGroup = memo(function ToolGroup({
|
||||
tabId?: string;
|
||||
}) {
|
||||
const t = useT();
|
||||
const beginUserResize = useTranscriptUserResizeIntent();
|
||||
const [open, setOpen] = useState(false);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
useCollapseAnimation(bodyRef, open);
|
||||
@@ -127,7 +130,7 @@ export const ToolGroup = memo(function ToolGroup({
|
||||
|
||||
return (
|
||||
<div className={`tool-group tool-group--${kind}${open ? " tool-group--open" : ""}`} data-kind={kind} data-entrance={items[0]?.id}>
|
||||
<button type="button" className="tool-group__head" onClick={() => setOpen((value) => !value)} aria-expanded={open}>
|
||||
<button type="button" className="tool-group__head" onClick={() => { beginUserResize(); setOpen((value) => !value); }} aria-expanded={open}>
|
||||
<span className="tool-group__title">{titleFor(kind, t)}</span>
|
||||
<span className="tool-group__summary">{groupSummary(kind, items, t)}</span>
|
||||
<ChevronRight className={`tool-group__chevron${open ? " tool-group__chevron--open" : ""}`} size={12} />
|
||||
|
||||
@@ -16,7 +16,7 @@ import { getProcessFoldPreference, onProcessFoldPreferenceChange, type ProcessFo
|
||||
import { STEER_NOTICE_PREFIX, isSteerNoticeText } from "../lib/useController";
|
||||
import { useTranscriptEntranceAnimation } from "../lib/useEntranceAnimation";
|
||||
import { useTranscriptSelectionRetention } from "../lib/useTranscriptSelectionRetention";
|
||||
import { compactQuestionText, lastQuestionTurn, questionAnchorId, questionTurnsById, scrollVersion, type QuestionAnchor } from "../lib/transcriptGrouping";
|
||||
import { compactQuestionText, lastQuestionTurn, questionAnchorId, questionTurnsById, type QuestionAnchor } from "../lib/transcriptGrouping";
|
||||
import {
|
||||
buildTranscriptRows,
|
||||
buildTurnModels,
|
||||
@@ -47,8 +47,10 @@ import { useTranscriptSelectableRows } from "../lib/useTranscriptSelectableRows"
|
||||
import { TranscriptSelectionOverlay } from "./TranscriptSelectionOverlay";
|
||||
import { useCreationTranscriptScrollbar } from "../lib/useCreationTranscriptScrollbar";
|
||||
import { useTranscriptScrollInteractions } from "../lib/useTranscriptScrollInteractions";
|
||||
import { TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX, useTranscriptVirtuosoScroll } from "../lib/useTranscriptVirtuosoScroll";
|
||||
import { useTranscriptVirtuosoFirstItemIndex } from "../lib/transcriptVirtuosoIndex";
|
||||
import { hasTranscriptScrollableRange, TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX, useTranscriptVirtuosoScroll } from "../lib/useTranscriptVirtuosoScroll";
|
||||
import { useTranscriptVirtuosoRecovery } from "../lib/useTranscriptVirtuosoRecovery";
|
||||
import { TranscriptLayoutIntentProvider, useTranscriptUserResizeIntent } from "./TranscriptLayoutIntentContext";
|
||||
import { MarkdownImageTabContext } from "./MarkdownImageContext";
|
||||
type OpenTurnAction = { turn: number; menu: "summary" | "rewind" };
|
||||
const QUESTION_NAV_MIN_COUNT = 2;
|
||||
type AssistantReasoningDisplay = "normal" | "hide";
|
||||
@@ -231,6 +233,7 @@ export function Transcript({
|
||||
actionHoverMenus = false,
|
||||
rewindSignal = 0,
|
||||
revealSignal = 0,
|
||||
historyLayoutRevision = 0,
|
||||
hydrating = false,
|
||||
hasOlderHistory = false,
|
||||
olderHistoryCount = 0,
|
||||
@@ -259,6 +262,7 @@ export function Transcript({
|
||||
actionHoverMenus?: boolean;
|
||||
rewindSignal?: number;
|
||||
revealSignal?: number;
|
||||
historyLayoutRevision?: number;
|
||||
hydrating?: boolean;
|
||||
hasOlderHistory?: boolean;
|
||||
olderHistoryCount?: number;
|
||||
@@ -295,6 +299,7 @@ export function Transcript({
|
||||
atBottomStateChange,
|
||||
scrollToBottom,
|
||||
followGrowingTail,
|
||||
beginUserResize,
|
||||
scrollToDataIndex,
|
||||
releaseTailFollow,
|
||||
setMode: setScrollMode,
|
||||
@@ -302,8 +307,8 @@ export function Transcript({
|
||||
reset: resetScroll,
|
||||
finishProgrammaticScroll,
|
||||
} = useTranscriptVirtuosoScroll();
|
||||
const autoScrollFrame = useRef<number | null>(null);
|
||||
const virtuosoReadyRef = useRef(false);
|
||||
const layoutSurfaceKey = `${tabId ?? ""}:${revealSignal}`;
|
||||
|
||||
const entranceRef = useTranscriptEntranceAnimation<HTMLDivElement>(tabId, revealSignal, items);
|
||||
|
||||
@@ -314,12 +319,7 @@ export function Transcript({
|
||||
return () => releaseMarkdownWorkerClient();
|
||||
}, []);
|
||||
|
||||
const cancelStreamingAutoScroll = useCallback(() => {
|
||||
if (autoScrollFrame.current !== null) {
|
||||
cancelAnimationFrame(autoScrollFrame.current);
|
||||
autoScrollFrame.current = null;
|
||||
}
|
||||
}, []);
|
||||
const cancelStreamingAutoScroll = useCallback(() => {}, []);
|
||||
|
||||
const cancelStreamingAndFollow = useCallback(() => {
|
||||
cancelStreamingAutoScroll();
|
||||
@@ -373,34 +373,11 @@ export function Transcript({
|
||||
virtuosoReadyRef.current = false;
|
||||
}, [resetScroll, revealSignal, tabId]);
|
||||
|
||||
// Auto-scroll to bottom during streaming. Coalesce fast token/reasoning
|
||||
// updates into one layout read/write per animation frame.
|
||||
const contentVersion = useMemo(() => scrollVersion(items), [items]);
|
||||
// Row measurement and footer resize share the same coalesced height path.
|
||||
useEffect(() => {
|
||||
if (items.length === 0) return;
|
||||
if (!virtuosoReadyRef.current) return;
|
||||
if (!stick.current) return;
|
||||
if (autoScrollFrame.current !== null) return;
|
||||
autoScrollFrame.current = requestAnimationFrame(() => {
|
||||
autoScrollFrame.current = null;
|
||||
if (!stick.current) return;
|
||||
followGrowingTail();
|
||||
});
|
||||
}, [contentVersion, followGrowingTail, live?.text?.length ?? 0, live?.reasoning?.length ?? 0, stick]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoScrollFrame.current !== null) {
|
||||
cancelAnimationFrame(autoScrollFrame.current);
|
||||
autoScrollFrame.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Virtuoso observes both its viewport and rows. When the composer changes
|
||||
// height, ask its tail policy to settle only if the reader is still pinned.
|
||||
useEffect(() => {
|
||||
if (items.length > 0 && virtuosoReadyRef.current) followGrowingTail();
|
||||
}, [followGrowingTail, footerHeight, items.length]);
|
||||
if (!virtuosoReadyRef.current || !stick.current) return;
|
||||
followGrowingTail();
|
||||
}, [footerHeight, followGrowingTail, stick]);
|
||||
|
||||
// Sub-agent calls carry a parentId; collect them under their parent `task`
|
||||
// call so the parent card can render them nested, and skip them at top level.
|
||||
@@ -432,7 +409,7 @@ export function Transcript({
|
||||
[liveId, liveHasAnswerText, liveHasReasoning, liveReasoningComplete],
|
||||
);
|
||||
const turnModels = useMemo(() => buildTurnModels(items, liveFlags, running, hideReasoning), [items, liveFlags, running, hideReasoning]);
|
||||
const segmentStates = useMemo(() => foldSegmentStates(turnModels), [turnModels]);
|
||||
const segmentStates = useMemo(() => foldSegmentStates(turnModels, reasoningDisplayMode === "expanded"), [reasoningDisplayMode, turnModels]);
|
||||
|
||||
const [foldPreference, setFoldPreference] = useState<ProcessFoldPreference>(getProcessFoldPreference);
|
||||
useEffect(() => onProcessFoldPreferenceChange(setFoldPreference), []);
|
||||
@@ -448,13 +425,15 @@ export function Transcript({
|
||||
}, [segmentStates, foldPreference]);
|
||||
|
||||
const handleFoldToggle = useCallback((segmentKey: string, currentlyOpen: boolean) => {
|
||||
beginUserResize();
|
||||
setFolds((prev) => foldMapWithToggle(prev, segmentKey, currentlyOpen));
|
||||
}, []);
|
||||
}, [beginUserResize]);
|
||||
|
||||
const handleReasoningManualOpen = useCallback((segmentKey: string) => {
|
||||
beginUserResize();
|
||||
const running = segmentStates.find((segment) => segment.key === segmentKey)?.hasRunningWork ?? false;
|
||||
setFolds((prev) => foldMapWithReasoningOpen(prev, segmentKey, running));
|
||||
}, [segmentStates]);
|
||||
}, [beginUserResize, segmentStates]);
|
||||
|
||||
// ── The turn action menu ──────────────────────────────────────────────────
|
||||
const [openAction, setOpenAction] = useState<OpenTurnAction | null>(null);
|
||||
@@ -512,8 +491,23 @@ export function Transcript({
|
||||
onScrollEnd: finishProgrammaticScroll,
|
||||
onSelectionPointerDown: selectionRetention.onPointerDownCapture,
|
||||
});
|
||||
const virtuosoResetKey = `${tabId ?? ""}:${revealSignal}`;
|
||||
const firstItemIndex = useTranscriptVirtuosoFirstItemIndex(virtualRows, virtuosoResetKey);
|
||||
const {
|
||||
resetKey: virtuosoResetKey,
|
||||
firstItemIndex,
|
||||
restoreLocation,
|
||||
handleItemsRendered: handleRecoveryItemsRendered,
|
||||
scheduleBlankViewportCheck,
|
||||
} = useTranscriptVirtuosoRecovery({
|
||||
surfaceKey: layoutSurfaceKey,
|
||||
historyLayoutRevision,
|
||||
rows: virtualRows,
|
||||
rowIndexByKey,
|
||||
scrollRef,
|
||||
pinnedRef: stick,
|
||||
virtuosoRef,
|
||||
readyRef: virtuosoReadyRef,
|
||||
scrollToBottom,
|
||||
});
|
||||
const heightEstimates = useMemo(() => virtualRows.map((row) => estimateTranscriptRowSize(row)), [virtualRows]);
|
||||
const overlayRevision = useMemo(
|
||||
() => virtualRows.map((row) => String(row.key)).join("|"),
|
||||
@@ -539,12 +533,13 @@ export function Transcript({
|
||||
const handleItemsRendered = useCallback((rendered: ListItem<TranscriptRow>[]) => {
|
||||
noteTranscriptRowCounts(rendered.length, virtualRows.length);
|
||||
selectionRetention.reconcileLogicalFocus();
|
||||
if (!virtuosoReadyRef.current && rendered.length > 0) {
|
||||
virtuosoReadyRef.current = true;
|
||||
requestAnimationFrame(() => scrollToBottom());
|
||||
}
|
||||
}, [scrollToBottom, selectionRetention.reconcileLogicalFocus, virtualRows.length]);
|
||||
handleRecoveryItemsRendered(rendered.length);
|
||||
}, [handleRecoveryItemsRendered, selectionRetention.reconcileLogicalFocus, virtualRows.length]);
|
||||
|
||||
const handleTranscriptScroll = useCallback(() => {
|
||||
if (creationMode) handleCreationScroll();
|
||||
scheduleBlankViewportCheck();
|
||||
}, [creationMode, handleCreationScroll, scheduleBlankViewportCheck]);
|
||||
// ── JumpBar integration ───────────────────────────────────────────────────
|
||||
const handleJumpToQuestion = useCallback((question: QuestionAnchor) => {
|
||||
const index = rowIndexByKey.get(String(userRowKey(question.id)));
|
||||
@@ -701,6 +696,8 @@ export function Transcript({
|
||||
// ── Assemble rendered output ──────────────────────────────────────────────
|
||||
return (
|
||||
<InvocationMetadataContext.Provider value={invocationMetadata}>
|
||||
<MarkdownImageTabContext.Provider value={tabId ?? ""}>
|
||||
<TranscriptLayoutIntentProvider value={beginUserResize}>
|
||||
<div className="transcript-shell">
|
||||
{empty ? (
|
||||
<div
|
||||
@@ -721,8 +718,10 @@ export function Transcript({
|
||||
components={hasOlderHistory ? TRANSCRIPT_VIRTUOSO_COMPONENTS_WITH_HEADER : TRANSCRIPT_VIRTUOSO_COMPONENTS}
|
||||
computeItemKey={(_index, row) => `${tabId ?? ""}:${String(row.key)}`}
|
||||
firstItemIndex={firstItemIndex}
|
||||
alignToBottom
|
||||
followOutput={(atBottom) => atBottom ? "auto" : false}
|
||||
initialTopMostItemIndex={restoreLocation}
|
||||
// Do not set alignToBottom: Virtuoso's margin-top:auto plus
|
||||
// firstItemIndex paints a ghost first-user bubble and empty band
|
||||
// in short chats. The coordinator owns tail following.
|
||||
atBottomThreshold={TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX}
|
||||
atBottomStateChange={atBottomStateChange}
|
||||
heightEstimates={heightEstimates}
|
||||
@@ -733,7 +732,7 @@ export function Transcript({
|
||||
itemsRendered={handleItemsRendered}
|
||||
totalListHeightChanged={followGrowingTail}
|
||||
itemContent={(_index, row) => renderRow(row)}
|
||||
onScroll={creationMode ? handleCreationScroll : undefined}
|
||||
onScroll={handleTranscriptScroll}
|
||||
onWheelCapture={scrollInteractions.onWheelCapture}
|
||||
onTouchStartCapture={onTouchStartIntent}
|
||||
onTouchMoveCapture={scrollInteractions.onTouchMoveCapture}
|
||||
@@ -761,7 +760,7 @@ export function Transcript({
|
||||
<QuestionJumpBar questions={questions} onJump={handleJumpToQuestion} />
|
||||
)}
|
||||
|
||||
{!empty && !isAtBottom && (
|
||||
{!empty && !isAtBottom && scrollElement && hasTranscriptScrollableRange(scrollElement) && (
|
||||
<button
|
||||
type="button"
|
||||
className="transcript__jump-bottom"
|
||||
@@ -773,6 +772,8 @@ export function Transcript({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TranscriptLayoutIntentProvider>
|
||||
</MarkdownImageTabContext.Provider>
|
||||
</InvocationMetadataContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1061,12 +1062,13 @@ export function NoticeCard({ item, onAction, actionDisabled = false }: { item: N
|
||||
function CompactionCard({ item }: { item: CompactionItem }) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const beginUserResize = useTranscriptUserResizeIntent();
|
||||
if (item.pending) {
|
||||
return <div className="compaction compaction--pending" data-entrance={item.id}><ProcessCompactIcon size={12} /><span>{t("compaction.working")}</span></div>;
|
||||
}
|
||||
return (
|
||||
<div className="compaction" data-entrance={item.id}>
|
||||
<button type="button" className="compaction__head" onClick={() => setOpen((v) => !v)} aria-expanded={open}>
|
||||
<button type="button" className="compaction__head" onClick={() => { beginUserResize(); setOpen((v) => !v); }} aria-expanded={open}>
|
||||
<ProcessCompactIcon size={12} />
|
||||
<span>{t("compaction.title")}</span>
|
||||
<span className="compaction__meta">{t("compaction.messages", { n: item.messages })}{item.trigger ? ` · ${item.trigger}` : ""}</span>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
const TranscriptLayoutIntentContext = createContext<() => void>(() => {});
|
||||
|
||||
export const TranscriptLayoutIntentProvider = TranscriptLayoutIntentContext.Provider;
|
||||
|
||||
export function useTranscriptUserResizeIntent(): () => void {
|
||||
return useContext(TranscriptLayoutIntentContext);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { FilePreview } from "../lib/types";
|
||||
import { workspaceBasename } from "../lib/workspacePanelFormat";
|
||||
|
||||
export function WorkspaceMediaPreview({ preview }: { preview: FilePreview }) {
|
||||
if (!preview.url) return null;
|
||||
if (preview.kind === "image") {
|
||||
return (
|
||||
<div className="workspace-media workspace-media--image">
|
||||
<img src={preview.url} alt={workspaceBasename(preview.path)} decoding="async" draggable={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (preview.kind === "pdf") {
|
||||
return <iframe className="workspace-media workspace-media--pdf" src={preview.url} title={workspaceBasename(preview.path)} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
KeyboardEvent,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
ReactElement,
|
||||
} from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
@@ -45,9 +44,17 @@ import { mergeWorkspaceSearchResults } from "../lib/workspaceTreeSearch";
|
||||
import {
|
||||
readWorkspaceTreeMemory,
|
||||
rememberWorkspaceTreeOpenDirs,
|
||||
rememberWorkspaceTreeState,
|
||||
touchWorkspaceTreeVisit,
|
||||
workspaceTreeVisitId,
|
||||
} from "../lib/workspaceTreeMemory";
|
||||
import { useWorkspaceTreeScrollPersistence } from "../lib/useWorkspaceTreeScrollPersistence";
|
||||
import { loadLayoutSize, loadOptionalLayoutSize } from "../lib/layoutPreferences";
|
||||
import {
|
||||
RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH,
|
||||
defaultCreationRightDockTreeWidth,
|
||||
defaultRightDockTreeWidth,
|
||||
} from "../store/layout";
|
||||
import type {
|
||||
DirEntry,
|
||||
FilePreview,
|
||||
@@ -55,7 +62,6 @@ import type {
|
||||
GitCommitDetailView,
|
||||
RewindResultView,
|
||||
WorkspaceChangeDetailView,
|
||||
WorkspaceChangesView,
|
||||
WireCompletionSummary,
|
||||
} from "../lib/types";
|
||||
import { workspaceGitStatusLabel } from "../lib/workspaceChanges";
|
||||
@@ -76,8 +82,23 @@ import { FloatingMenu, FloatingMenuItems } from "./FloatingMenu";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { AnchoredPopover } from "./AnchoredPopover";
|
||||
import { MarkdownImageTabContext } from "./MarkdownImageContext";
|
||||
import { WorkspaceFileIcon } from "./WorkspaceFileIcon";
|
||||
import { WorkspaceMediaPreview } from "./WorkspaceMediaPreview";
|
||||
import { WorkspaceTreeMenu } from "./WorkspaceTreeMenu";
|
||||
import { useWorkspaceChangesResource } from "../lib/useWorkspaceChangesResource";
|
||||
import {
|
||||
workspaceBasename as basename, workspaceEntryPath as entryPath,
|
||||
workspaceFormatBytes as formatBytes, workspaceFormatCommitDate as formatCommitDate,
|
||||
workspaceParentDirs as parentDirs, workspaceParentPath as parentPath,
|
||||
workspaceShortCwd as shortCwd, workspaceTopLevelDirPath as topLevelDirPath,
|
||||
} from "../lib/workspacePanelFormat";
|
||||
import {
|
||||
beginKeyedResourceRequest,
|
||||
emptyKeyedResource,
|
||||
rejectKeyedResourceRequest,
|
||||
resolveKeyedResourceRequest,
|
||||
} from "../lib/keyedResource";
|
||||
|
||||
const WORKSPACE_TREE_MIN_WIDTH = 140;
|
||||
const WORKSPACE_TREE_DEFAULT_WIDTH = 300;
|
||||
@@ -103,83 +124,6 @@ function clampWorkspaceTreeWidth(width: number, panelWidth?: number): number {
|
||||
});
|
||||
}
|
||||
|
||||
function entryPath(dir: string, entry: DirEntry): string {
|
||||
const prefix = dir === "" || dir.endsWith("/") ? dir : dir + "/";
|
||||
return prefix + entry.name + (entry.isDir ? "/" : "");
|
||||
}
|
||||
|
||||
function basename(path: string): string {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] ?? "";
|
||||
}
|
||||
|
||||
function parentPath(path: string): string {
|
||||
const clean = path.replace(/\/$/, "");
|
||||
const parts = clean.split("/").filter(Boolean);
|
||||
return parts.slice(0, -1).join("/");
|
||||
}
|
||||
|
||||
function parentDirs(path: string): string[] {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
const dirs: string[] = [""];
|
||||
let acc = "";
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
acc += parts[i] + "/";
|
||||
dirs.push(acc);
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function topLevelDirPath(path: string): string {
|
||||
const first = path.split("/").find(Boolean);
|
||||
return first ? `${first}/` : "";
|
||||
}
|
||||
|
||||
function renderMediaPreview(preview: FilePreview): ReactElement | null {
|
||||
if (!preview.url) return null;
|
||||
if (preview.kind === "image") {
|
||||
return (
|
||||
<div className="workspace-media workspace-media--image">
|
||||
<img src={preview.url} alt={basename(preview.path)} decoding="async" draggable={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (preview.kind === "pdf") {
|
||||
return (
|
||||
<iframe
|
||||
className="workspace-media workspace-media--pdf"
|
||||
src={preview.url}
|
||||
title={basename(preview.path)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function shortCwd(cwd?: string): string {
|
||||
if (!cwd) return "";
|
||||
const parts = cwd.split("/").filter(Boolean);
|
||||
if (parts.length <= 2) return cwd;
|
||||
return "…/" + parts.slice(-2).join("/");
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (n >= 1024) return `${Math.ceil(n / 1024)} KB`;
|
||||
return `${n} B`;
|
||||
}
|
||||
|
||||
function formatCommitDate(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return dateStr;
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
const month = monthNames[d.getMonth()];
|
||||
const year = d.getFullYear();
|
||||
const hours = String(d.getHours()).padStart(2, "0");
|
||||
const minutes = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${day} ${month} ${year} ${hours}:${minutes}`;
|
||||
}
|
||||
interface TreeRow {
|
||||
key: string;
|
||||
path: string;
|
||||
@@ -216,6 +160,9 @@ export function WorkspacePanel({
|
||||
workspaceScopeKey: workspaceScopeKeyProp,
|
||||
workspaceMemoryKey: workspaceMemoryKeyProp,
|
||||
workspaceMemoryVisitId: workspaceMemoryVisitIdProp,
|
||||
dockTreeWidth,
|
||||
dockPreviewWidth,
|
||||
onRestoreDockWidths,
|
||||
creationMode = false,
|
||||
completionSummary,
|
||||
}: {
|
||||
@@ -242,6 +189,9 @@ export function WorkspacePanel({
|
||||
workspaceScopeKey?: string;
|
||||
workspaceMemoryKey?: string;
|
||||
workspaceMemoryVisitId?: number;
|
||||
dockTreeWidth?: number;
|
||||
dockPreviewWidth?: number;
|
||||
onRestoreDockWidths?: (treeWidth: number, previewWidth: number) => void;
|
||||
creationMode?: boolean;
|
||||
completionSummary?: WireCompletionSummary;
|
||||
}) {
|
||||
@@ -252,8 +202,10 @@ export function WorkspacePanel({
|
||||
const workspaceMemoryVisitId = workspaceMemoryVisitIdProp ?? workspaceTreeVisitId(workspaceMemoryKey);
|
||||
const workspaceRefresh = useWorkspaceRefresh(workspaceTabId, workspaceScopeKey, open);
|
||||
const initialWorkspaceMemory = readWorkspaceTreeMemory(workspaceMemoryKey);
|
||||
const legacyTreeWidth = loadOptionalLayoutSize("workspaceTreeWidth");
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const treeRef = useRef<HTMLDivElement>(null);
|
||||
const onWorkspaceTreeScroll = useWorkspaceTreeScrollPersistence({ memoryKey: workspaceMemoryKey, open, scrollRef: treeRef });
|
||||
const filterRef = useRef<HTMLInputElement>(null);
|
||||
const previewBodyRef = useRef<HTMLDivElement>(null);
|
||||
const [entriesByDir, setEntriesByDir] = useState<Record<string, DirEntry[]>>({});
|
||||
@@ -263,21 +215,25 @@ export function WorkspacePanel({
|
||||
const [revealedRootPaths, setRevealedRootPaths] = useState<Set<string> | null>(
|
||||
() => initialWorkspaceMemory && initialWorkspaceMemory.visitId !== workspaceMemoryVisitId ? new Set() : null,
|
||||
);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
// File preview and working-tree diff selection are independent navigation
|
||||
// states. Keeping a single path here used to erase the user's file context
|
||||
// whenever the Changes tab refreshed or became active.
|
||||
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(
|
||||
() => initialWorkspaceMemory?.selectedFilePath ?? null,
|
||||
);
|
||||
const [selectedChangePath, setSelectedChangePath] = useState<string | null>(
|
||||
() => initialWorkspaceMemory?.selectedChangePath ?? null,
|
||||
);
|
||||
const [openTabs, setOpenTabs] = useState<string[]>([]);
|
||||
const [preview, setPreview] = useState<FilePreview | null>(null);
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const [previewResource, setPreviewResource] = useState(() => emptyKeyedResource<FilePreview>());
|
||||
const [viewMode, setViewMode] = useState<"files" | "changed">(initialViewMode);
|
||||
const selectedPath = viewMode === "changed" ? selectedChangePath : selectedFilePath;
|
||||
// Both creation and regular workspaces use the same three-layer change view;
|
||||
// keep the prop in the seam for older callers while making history collapsed
|
||||
// by default everywhere.
|
||||
const groupedChangesLayout = creationMode !== false || viewMode === "changed";
|
||||
const [gitHistory, setGitHistory] = useState<GitCommitView[]>([]);
|
||||
const [workspaceChanges, setWorkspaceChanges] = useState<WorkspaceChangesView | null>(null);
|
||||
const [changeDetail, setChangeDetail] = useState<WorkspaceChangeDetailView | null>(null);
|
||||
const [loadingChangeDetail, setLoadingChangeDetail] = useState(false);
|
||||
const [changeDetailErr, setChangeDetailErr] = useState("");
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
const [gitHistoryResource, setGitHistoryResource] = useState(() => emptyKeyedResource<GitCommitView[]>());
|
||||
const [changeDetailResource, setChangeDetailResource] = useState(() => emptyKeyedResource<WorkspaceChangeDetailView>());
|
||||
const [expandedCommit, setExpandedCommit] = useState<string | null>(null);
|
||||
const [commitDetail, setCommitDetail] = useState<GitCommitDetailView | null>(null);
|
||||
const [loadingCommit, setLoadingCommit] = useState(false);
|
||||
@@ -289,8 +245,10 @@ export function WorkspacePanel({
|
||||
const [scopedFilePaths, setScopedFilePaths] = useState<string[] | null>(null);
|
||||
const [scopedChangeRows, setScopedChangeRows] = useState<WorkspaceChangeListEntry[] | null>(null);
|
||||
const [treeVisible, setTreeVisible] = useState(true);
|
||||
const [treeWidth, setTreeWidth] = useState(WORKSPACE_TREE_DEFAULT_WIDTH);
|
||||
const [treeWidthMode, setTreeWidthMode] = useState<WorkspaceSplitTreeWidthMode>("manual");
|
||||
const [treeWidth, setTreeWidth] = useState(initialWorkspaceMemory?.treeWidth ?? legacyTreeWidth ?? WORKSPACE_TREE_DEFAULT_WIDTH);
|
||||
const [treeWidthMode, setTreeWidthMode] = useState<WorkspaceSplitTreeWidthMode>(
|
||||
initialWorkspaceMemory?.treeWidthMode ?? "manual",
|
||||
);
|
||||
const [treeResizing, setTreeResizing] = useState(false);
|
||||
const [recentOpen, setRecentOpen] = useState(false);
|
||||
const [codeSearchRequestPending, setCodeSearchRequestPending] = useState(false);
|
||||
@@ -308,7 +266,6 @@ export function WorkspacePanel({
|
||||
const dismissedChangeListRequestIdRef = useRef<number | null>(null);
|
||||
const currentWorkspaceScopeKeyRef = useRef(workspaceScopeKey);
|
||||
const lastWorkspaceScopeKeyRef = useRef(workspaceScopeKey);
|
||||
const workspaceChangesRequestIdRef = useRef(0);
|
||||
const changeDetailRequestIdRef = useRef(0);
|
||||
const gitHistoryRequestIdRef = useRef(0);
|
||||
const previewRequestIdRef = useRef(0);
|
||||
@@ -319,6 +276,8 @@ export function WorkspacePanel({
|
||||
const recentAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
const openDirsRef = useRef(openDirs);
|
||||
const pendingTreeRevealPathRef = useRef<string | null>(null);
|
||||
const lastRestoredMemoryKeyRef = useRef(workspaceMemoryKey);
|
||||
const memoryRestorePendingRef = useRef(false);
|
||||
const workingTreeRefreshSchedulerRef = useRef<ReturnType<typeof createWorkspaceRefreshScheduler> | null>(null);
|
||||
const gitMetaRefreshSchedulerRef = useRef<ReturnType<typeof createWorkspaceRefreshScheduler> | null>(null);
|
||||
if (!workingTreeRefreshSchedulerRef.current) {
|
||||
@@ -328,6 +287,27 @@ export function WorkspacePanel({
|
||||
gitMetaRefreshSchedulerRef.current = createWorkspaceRefreshScheduler(750);
|
||||
}
|
||||
currentWorkspaceScopeKeyRef.current = workspaceScopeKey;
|
||||
const previewKey = selectedPath ? `${workspaceScopeKey}\u0000preview\u0000${selectedPath}` : null;
|
||||
const changeDetailKey = selectedPath ? `${workspaceScopeKey}\u0000change\u0000${selectedPath}` : null;
|
||||
const gitHistoryKey = `${workspaceScopeKey}\u0000history\u0000${selectedPath ?? ""}`;
|
||||
const preview = previewKey && previewResource.key === previewKey ? previewResource.data : null;
|
||||
const loadingPreview = previewKey != null && previewResource.key === previewKey && previewResource.status === "refreshing";
|
||||
const previewErr = previewKey && previewResource.key === previewKey ? previewResource.error : "";
|
||||
const changeDetail = changeDetailKey && changeDetailResource.key === changeDetailKey ? changeDetailResource.data : null;
|
||||
const loadingChangeDetail = changeDetailKey != null && changeDetailResource.key === changeDetailKey && changeDetailResource.status === "refreshing";
|
||||
const changeDetailErr = changeDetailKey && changeDetailResource.key === changeDetailKey ? changeDetailResource.error : "";
|
||||
const gitHistory = gitHistoryResource.key === gitHistoryKey ? gitHistoryResource.data ?? [] : [];
|
||||
const loadingHistory = gitHistoryResource.key === gitHistoryKey && gitHistoryResource.status === "refreshing";
|
||||
const gitHistoryErr = gitHistoryResource.key === gitHistoryKey ? gitHistoryResource.error : "";
|
||||
const { workspaceChanges, loadingWorkspaceChanges, workspaceChangesErr, loadWorkspaceChanges, resetWorkspaceChanges } =
|
||||
useWorkspaceChangesResource(workspaceTabId, workspaceScopeKey, workspaceRefresh.revisions.workingTree);
|
||||
const overviewResourceStatus = workspaceChangesErr && workspaceChanges
|
||||
? { error: true, text: `${t("workspace.changesUnavailable")}: ${workspaceChangesErr}` }
|
||||
: gitHistoryErr && gitHistory.length > 0
|
||||
? { error: true, text: `${t("workspace.historyUnavailable")}: ${gitHistoryErr}` }
|
||||
: loadingWorkspaceChanges && workspaceChanges
|
||||
? { error: false, text: t("workspace.loadingChanges") }
|
||||
: loadingHistory && gitHistory.length > 0 ? { error: false, text: t("workspace.loading") } : null;
|
||||
|
||||
useEffect(() => {
|
||||
openDirsRef.current = openDirs;
|
||||
@@ -345,6 +325,7 @@ export function WorkspacePanel({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
memoryRestorePendingRef.current = true;
|
||||
const remembered = readWorkspaceTreeMemory(workspaceMemoryKey);
|
||||
const nextOpenDirs = new Set(remembered?.openDirs ?? [""]);
|
||||
setOpenDirs(nextOpenDirs);
|
||||
@@ -352,7 +333,44 @@ export function WorkspacePanel({
|
||||
setRevealedRootPaths(remembered && remembered.visitId !== workspaceMemoryVisitId ? new Set() : null);
|
||||
if (remembered) touchWorkspaceTreeVisit(workspaceMemoryKey, workspaceMemoryVisitId);
|
||||
else rememberWorkspaceTreeOpenDirs(workspaceMemoryKey, nextOpenDirs, workspaceMemoryVisitId);
|
||||
}, [workspaceMemoryKey, workspaceMemoryVisitId]);
|
||||
onRestoreDockWidths?.(
|
||||
remembered?.dockTreeWidth ?? loadLayoutSize(
|
||||
"rightDockTreeWidth",
|
||||
creationMode ? defaultCreationRightDockTreeWidth() : defaultRightDockTreeWidth(),
|
||||
),
|
||||
remembered?.dockPreviewWidth ?? loadLayoutSize("rightDockPreviewWidth", RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH),
|
||||
);
|
||||
if (lastRestoredMemoryKeyRef.current !== workspaceMemoryKey) {
|
||||
lastRestoredMemoryKeyRef.current = workspaceMemoryKey;
|
||||
setSelectedFilePath(remembered?.selectedFilePath ?? null);
|
||||
setSelectedChangePath(remembered?.selectedChangePath ?? null);
|
||||
setTreeWidth(remembered?.treeWidth ?? legacyTreeWidth ?? WORKSPACE_TREE_DEFAULT_WIDTH);
|
||||
setTreeWidthMode(remembered?.treeWidthMode ?? "manual");
|
||||
requestAnimationFrame(() => {
|
||||
const tree = treeRef.current;
|
||||
if (tree) tree.scrollTop = remembered?.scrollTop ?? 0;
|
||||
});
|
||||
}
|
||||
}, [creationMode, legacyTreeWidth, onRestoreDockWidths, workspaceMemoryKey, workspaceMemoryVisitId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (memoryRestorePendingRef.current) return;
|
||||
rememberWorkspaceTreeState(workspaceMemoryKey, { selectedFilePath, selectedChangePath });
|
||||
}, [selectedChangePath, selectedFilePath, workspaceMemoryKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (memoryRestorePendingRef.current) return;
|
||||
rememberWorkspaceTreeState(workspaceMemoryKey, { treeWidth, treeWidthMode });
|
||||
}, [treeWidth, treeWidthMode, workspaceMemoryKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (memoryRestorePendingRef.current || dockTreeWidth == null || dockPreviewWidth == null) return;
|
||||
rememberWorkspaceTreeState(workspaceMemoryKey, { dockTreeWidth, dockPreviewWidth });
|
||||
}, [dockPreviewWidth, dockTreeWidth, workspaceMemoryKey]);
|
||||
|
||||
useEffect(() => {
|
||||
memoryRestorePendingRef.current = false;
|
||||
});
|
||||
|
||||
const loadDir = useCallback(async (dir: string) => {
|
||||
const requestTabId = workspaceTabId;
|
||||
@@ -374,43 +392,19 @@ export function WorkspacePanel({
|
||||
const requestId = ++gitHistoryRequestIdRef.current;
|
||||
const requestTabId = workspaceTabId;
|
||||
const requestScopeKey = workspaceScopeKey;
|
||||
setLoadingHistory(true);
|
||||
const requestKey = `${requestScopeKey}\u0000history\u0000${selectedPath ?? ""}`;
|
||||
setGitHistoryResource((current) => beginKeyedResourceRequest(current, requestKey, requestId, workspaceRefresh.revisions.gitMeta));
|
||||
try {
|
||||
const result = await app.WorkspaceGitHistory(requestTabId, selectedPath || "");
|
||||
if (gitHistoryRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
|
||||
setGitHistory(result || []);
|
||||
setGitHistoryResource((current) => resolveKeyedResourceRequest(current, requestKey, requestId, result || [], workspaceRefresh.revisions.gitMeta));
|
||||
}
|
||||
} catch (err) {
|
||||
if (gitHistoryRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
|
||||
setGitHistory([]);
|
||||
}
|
||||
} finally {
|
||||
if (gitHistoryRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
|
||||
setLoadingHistory(false);
|
||||
setGitHistoryResource((current) => rejectKeyedResourceRequest(current, requestKey, requestId, String((err as { message?: unknown })?.message ?? err)));
|
||||
}
|
||||
}
|
||||
}, [selectedPath, workspaceScopeKey, workspaceTabId]);
|
||||
|
||||
const loadWorkspaceChanges = useCallback(async () => {
|
||||
const requestId = ++workspaceChangesRequestIdRef.current;
|
||||
const requestTabId = workspaceTabId;
|
||||
const requestScopeKey = workspaceScopeKey;
|
||||
try {
|
||||
const result = await app.WorkspaceChanges(requestTabId);
|
||||
if (workspaceChangesRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
|
||||
setWorkspaceChanges({
|
||||
files: Array.isArray(result?.files) ? result.files : [],
|
||||
gitAvailable: result?.gitAvailable !== false,
|
||||
gitErr: result?.gitErr,
|
||||
gitBranch: result?.gitBranch,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (workspaceChangesRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
|
||||
setWorkspaceChanges({ files: [], gitAvailable: false });
|
||||
}
|
||||
}
|
||||
}, [workspaceScopeKey, workspaceTabId]);
|
||||
}, [selectedPath, workspaceRefresh.revisions.gitMeta, workspaceScopeKey, workspaceTabId]);
|
||||
|
||||
const loadChangeDetail = useCallback(async () => {
|
||||
const requestId = ++changeDetailRequestIdRef.current;
|
||||
@@ -418,29 +412,22 @@ export function WorkspacePanel({
|
||||
const requestScopeKey = workspaceScopeKey;
|
||||
const requestPath = selectedPath;
|
||||
if (!requestPath) {
|
||||
setChangeDetail(null);
|
||||
setChangeDetailErr("");
|
||||
setLoadingChangeDetail(false);
|
||||
setChangeDetailResource(emptyKeyedResource());
|
||||
return;
|
||||
}
|
||||
setLoadingChangeDetail(true);
|
||||
setChangeDetailErr("");
|
||||
const requestKey = `${requestScopeKey}\u0000change\u0000${requestPath}`;
|
||||
setChangeDetailResource((current) => beginKeyedResourceRequest(current, requestKey, requestId, workspaceRefresh.revisions.content));
|
||||
try {
|
||||
const detail = await app.WorkspaceChangeDetail(requestTabId, requestPath);
|
||||
if (changeDetailRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
|
||||
setChangeDetail(detail ?? null);
|
||||
setChangeDetailResource((current) => resolveKeyedResourceRequest(current, requestKey, requestId, detail ?? {}, workspaceRefresh.revisions.content));
|
||||
}
|
||||
} catch (err) {
|
||||
if (changeDetailRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
|
||||
setChangeDetail(null);
|
||||
setChangeDetailErr(String((err as { message?: unknown })?.message ?? err));
|
||||
}
|
||||
} finally {
|
||||
if (changeDetailRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
|
||||
setLoadingChangeDetail(false);
|
||||
setChangeDetailResource((current) => rejectKeyedResourceRequest(current, requestKey, requestId, String((err as { message?: unknown })?.message ?? err)));
|
||||
}
|
||||
}
|
||||
}, [selectedPath, workspaceScopeKey, workspaceTabId]);
|
||||
}, [selectedPath, workspaceRefresh.revisions.content, workspaceScopeKey, workspaceTabId]);
|
||||
|
||||
const toggleCommit = useCallback((hash: string) => {
|
||||
setExpandedCommit((prev) => {
|
||||
@@ -485,7 +472,7 @@ export function WorkspacePanel({
|
||||
}, [expandedCommit, selectedPath, open, workspaceScopeKey, workspaceTabId]);
|
||||
|
||||
const selectFile = useCallback(
|
||||
(path: string) => {
|
||||
(path: string, targetMode: "files" | "changed" = viewMode) => {
|
||||
const initializeSplit = shouldInitializeWorkspaceSplitOnFileSelect({
|
||||
previewVisible: openTabs.length > 0 || selectedPath !== null,
|
||||
treeVisible,
|
||||
@@ -501,7 +488,8 @@ export function WorkspacePanel({
|
||||
setTreeWidthMode("even");
|
||||
}
|
||||
pendingTreeRevealPathRef.current = path;
|
||||
setSelectedPath(path);
|
||||
if (targetMode === "changed") setSelectedChangePath(path);
|
||||
else setSelectedFilePath(path);
|
||||
setScopedFilePaths((current) => {
|
||||
if (current) dismissedFileListRequestIdRef.current = lastFileListRequestIdRef.current;
|
||||
return null;
|
||||
@@ -516,7 +504,7 @@ export function WorkspacePanel({
|
||||
updateOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
|
||||
dirs.forEach((dir) => void loadDir(dir));
|
||||
},
|
||||
[loadDir, openTabs.length, panelWidth, selectedPath, treeVisible, updateOpenDirs],
|
||||
[loadDir, openTabs.length, panelWidth, selectedPath, treeVisible, updateOpenDirs, viewMode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -525,14 +513,11 @@ export function WorkspacePanel({
|
||||
dirLoadRequestIdsRef.current = {};
|
||||
compactProbeInFlightRef.current.clear();
|
||||
setEntriesByDir({});
|
||||
setSelectedPath(null);
|
||||
setOpenTabs([]);
|
||||
setPreview(null);
|
||||
setGitHistory([]);
|
||||
setPreviewResource(emptyKeyedResource());
|
||||
setGitHistoryResource(emptyKeyedResource());
|
||||
changeDetailRequestIdRef.current += 1;
|
||||
setChangeDetail(null);
|
||||
setChangeDetailErr("");
|
||||
setLoadingChangeDetail(false);
|
||||
setChangeDetailResource(emptyKeyedResource());
|
||||
setExpandedCommit(null);
|
||||
setCommitDetail(null);
|
||||
setSelectionMenu(null);
|
||||
@@ -550,15 +535,12 @@ export function WorkspacePanel({
|
||||
lastWorkspaceScopeKeyRef.current = workspaceScopeKey;
|
||||
workingTreeRefreshSchedulerRef.current?.cancel();
|
||||
gitMetaRefreshSchedulerRef.current?.cancel();
|
||||
workspaceChangesRequestIdRef.current += 1;
|
||||
changeDetailRequestIdRef.current += 1;
|
||||
gitHistoryRequestIdRef.current += 1;
|
||||
commitDetailRequestIdRef.current += 1;
|
||||
setWorkspaceChanges(null);
|
||||
setChangeDetail(null);
|
||||
setChangeDetailErr("");
|
||||
setLoadingChangeDetail(false);
|
||||
setGitHistory([]);
|
||||
resetWorkspaceChanges();
|
||||
setChangeDetailResource(emptyKeyedResource());
|
||||
setGitHistoryResource(emptyKeyedResource());
|
||||
setCommitHistoryOpen(false);
|
||||
setExpandedCommit(null);
|
||||
setCommitDetail(null);
|
||||
@@ -568,11 +550,10 @@ export function WorkspacePanel({
|
||||
lastChangeListRequestIdRef.current = null;
|
||||
dismissedChangeListRequestIdRef.current = null;
|
||||
if (viewMode === "changed") {
|
||||
setSelectedPath(null);
|
||||
setOpenTabs([]);
|
||||
setPreview(null);
|
||||
setPreviewResource(emptyKeyedResource());
|
||||
}
|
||||
}, [open, viewMode, workspaceScopeKey]);
|
||||
}, [open, resetWorkspaceChanges, viewMode, workspaceScopeKey]);
|
||||
|
||||
useEffect(() => () => {
|
||||
workingTreeRefreshSchedulerRef.current?.cancel();
|
||||
@@ -596,17 +577,12 @@ export function WorkspacePanel({
|
||||
setExpandedCommit(null);
|
||||
setCommitDetail(null);
|
||||
changeDetailRequestIdRef.current += 1;
|
||||
setChangeDetail(null);
|
||||
setChangeDetailErr("");
|
||||
setLoadingChangeDetail(false);
|
||||
setChangeDetailResource(emptyKeyedResource());
|
||||
setSelectionMenu(null);
|
||||
setTreeMenu(null);
|
||||
setRecentOpen(false);
|
||||
if (initialViewMode === "changed") {
|
||||
setScopedFilePaths(null);
|
||||
setSelectedPath(null);
|
||||
setOpenTabs([]);
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
setScopedChangeRows(null);
|
||||
@@ -638,9 +614,9 @@ export function WorkspacePanel({
|
||||
setViewMode("files");
|
||||
setTreeVisible(true);
|
||||
setScopedFilePaths(paths);
|
||||
setSelectedPath(null);
|
||||
setSelectedFilePath(null);
|
||||
setOpenTabs([]);
|
||||
setPreview(null);
|
||||
setPreviewResource(emptyKeyedResource());
|
||||
setFilter("");
|
||||
setExpandedCommit(null);
|
||||
setCommitDetail(null);
|
||||
@@ -678,9 +654,9 @@ export function WorkspacePanel({
|
||||
setViewMode("changed");
|
||||
setScopedChangeRows(changes);
|
||||
setScopedFilePaths(null);
|
||||
setSelectedPath(null);
|
||||
setSelectedChangePath(null);
|
||||
setOpenTabs([]);
|
||||
setPreview(null);
|
||||
setPreviewResource(emptyKeyedResource());
|
||||
setFilter("");
|
||||
setExpandedCommit(null);
|
||||
setCommitDetail(null);
|
||||
@@ -712,7 +688,7 @@ export function WorkspacePanel({
|
||||
setScopedChangeRows(null);
|
||||
setExpandedCommit(null);
|
||||
setCommitDetail(null);
|
||||
selectFile(revealPathRequest.path);
|
||||
selectFile(revealPathRequest.path, "files");
|
||||
}, [open, revealPathRequest, selectFile, selectedPath, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -736,9 +712,9 @@ export function WorkspacePanel({
|
||||
setViewMode("changed");
|
||||
setScopedFilePaths(null);
|
||||
setScopedChangeRows(null);
|
||||
setSelectedPath(changeRevealRequest.path);
|
||||
setSelectedChangePath(changeRevealRequest.path);
|
||||
setOpenTabs([]);
|
||||
setPreview(null);
|
||||
setPreviewResource(emptyKeyedResource());
|
||||
setFilter("");
|
||||
setExpandedCommit(null);
|
||||
setCommitDetail(null);
|
||||
@@ -754,9 +730,7 @@ export function WorkspacePanel({
|
||||
if (commitHistoryOpen) void loadGitHistory();
|
||||
} else {
|
||||
changeDetailRequestIdRef.current += 1;
|
||||
setChangeDetail(null);
|
||||
setChangeDetailErr("");
|
||||
setLoadingChangeDetail(false);
|
||||
setChangeDetailResource(emptyKeyedResource());
|
||||
}
|
||||
}, [commitHistoryOpen, selectedPath, viewMode, loadChangeDetail, loadGitHistory, loadWorkspaceChanges, open]);
|
||||
|
||||
@@ -774,12 +748,13 @@ export function WorkspacePanel({
|
||||
// mousedown only fires when the user starts another interaction, and FloatingMenu
|
||||
// stops propagation so pressing its buttons never counts as an outside press.
|
||||
window.addEventListener("mousedown", close);
|
||||
window.addEventListener("scroll", close, true);
|
||||
const panel = panelRef.current;
|
||||
panel?.addEventListener("scroll", close, true);
|
||||
window.addEventListener("resize", close);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", close);
|
||||
window.removeEventListener("scroll", close, true);
|
||||
panel?.removeEventListener("scroll", close, true);
|
||||
window.removeEventListener("resize", close);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
@@ -808,32 +783,26 @@ export function WorkspacePanel({
|
||||
if (!selectedPath) return;
|
||||
const requestId = ++previewRequestIdRef.current;
|
||||
const requestScopeKey = workspaceScopeKey;
|
||||
const requestPath = selectedPath;
|
||||
const requestKey = `${requestScopeKey}\u0000preview\u0000${requestPath}`;
|
||||
let live = true;
|
||||
setLoadingPreview(true);
|
||||
setPreviewResource((current) => beginKeyedResourceRequest(current, requestKey, requestId, workspaceRefresh.revisions.content));
|
||||
app
|
||||
.ReadFileForTab(workspaceTabId, selectedPath)
|
||||
.ReadFileForTab(workspaceTabId, requestPath)
|
||||
.then((next) => {
|
||||
if (live && previewRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) setPreview(next);
|
||||
if (live && previewRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
|
||||
setPreviewResource((current) => resolveKeyedResourceRequest(current, requestKey, requestId, next, workspaceRefresh.revisions.content));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (live && previewRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) {
|
||||
setPreview({
|
||||
path: selectedPath,
|
||||
body: "",
|
||||
size: 0,
|
||||
truncated: false,
|
||||
binary: false,
|
||||
err: String(err?.message ?? err),
|
||||
});
|
||||
setPreviewResource((current) => rejectKeyedResourceRequest(current, requestKey, requestId, String(err?.message ?? err)));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (live && previewRequestIdRef.current === requestId && currentWorkspaceScopeKeyRef.current === requestScopeKey) setLoadingPreview(false);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [selectedPath, workspaceScopeKey, workspaceTabId]);
|
||||
}, [selectedPath, workspaceRefresh.revisions.content, workspaceScopeKey, workspaceTabId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !selectedPath) return;
|
||||
@@ -1219,15 +1188,19 @@ export function WorkspacePanel({
|
||||
if (lastChangeRevealRequestIdRef.current === changeRevealRequest?.id) {
|
||||
dismissedChangeRevealRequestIdRef.current = changeRevealRequest.id;
|
||||
}
|
||||
const nextPreviewTabs = closeWorkspacePreviewTab(openTabs, selectedPath);
|
||||
setSelectedPath(nextPreviewTabs.selectedPath);
|
||||
setOpenTabs(nextPreviewTabs.openTabs);
|
||||
setPreview(null);
|
||||
if (viewMode === "changed") {
|
||||
setSelectedChangePath(null);
|
||||
} else {
|
||||
const nextPreviewTabs = closeWorkspacePreviewTab(openTabs, selectedFilePath);
|
||||
setSelectedFilePath(nextPreviewTabs.selectedPath);
|
||||
setOpenTabs(nextPreviewTabs.openTabs);
|
||||
}
|
||||
setPreviewResource(emptyKeyedResource());
|
||||
setSelectionMenu(null);
|
||||
setTreeMenu(null);
|
||||
setRecentOpen(false);
|
||||
setTreeVisible(true);
|
||||
}, [changeRevealRequest, openTabs, revealPathRequest, selectedPath]);
|
||||
}, [changeRevealRequest, openTabs, revealPathRequest, selectedFilePath, viewMode]);
|
||||
|
||||
const setSavedTreeWidth = useCallback(
|
||||
(width: number) => {
|
||||
@@ -1324,7 +1297,7 @@ export function WorkspacePanel({
|
||||
};
|
||||
|
||||
const openSelectionMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!selectedPath || loadingPreview || preview?.err || preview?.binary || preview?.kind) return;
|
||||
if (!selectedPath || (loadingPreview && !preview) || previewErr || preview?.err || preview?.binary || preview?.kind) return;
|
||||
const text = selectedTextFromPreview();
|
||||
if (text.trim() === "") return;
|
||||
event.preventDefault();
|
||||
@@ -1339,7 +1312,7 @@ export function WorkspacePanel({
|
||||
// Mouseup on the floating button bubbles back here through the portal's
|
||||
// React tree; let the button handle it.
|
||||
if ((event.target as HTMLElement | null)?.closest(".floating-menu")) return;
|
||||
if (!selectedPath || loadingPreview || preview?.err || preview?.binary || preview?.kind) return;
|
||||
if (!selectedPath || (loadingPreview && !preview) || previewErr || preview?.err || preview?.binary || preview?.kind) return;
|
||||
const text = selectedTextFromPreview();
|
||||
if (text.trim() === "") return;
|
||||
setSelectionMenu({ x: event.clientX, y: event.clientY + 8, text, path: selectedPath });
|
||||
@@ -1419,7 +1392,7 @@ export function WorkspacePanel({
|
||||
toggleDir(path, compactPaths);
|
||||
} else {
|
||||
if (selectedPath === path) {
|
||||
setSelectedPath(null);
|
||||
setSelectedFilePath(null);
|
||||
} else {
|
||||
selectFile(path);
|
||||
}
|
||||
@@ -1476,7 +1449,7 @@ export function WorkspacePanel({
|
||||
toggleDir(path);
|
||||
} else {
|
||||
if (selectedPath === path) {
|
||||
setSelectedPath(null);
|
||||
setSelectedFilePath(null);
|
||||
} else {
|
||||
selectFile(path);
|
||||
}
|
||||
@@ -1502,7 +1475,8 @@ export function WorkspacePanel({
|
||||
selectedPath &&
|
||||
!changedMode &&
|
||||
preview &&
|
||||
!loadingPreview &&
|
||||
!(loadingPreview && !preview) &&
|
||||
!previewErr &&
|
||||
!preview.err &&
|
||||
!preview.kind &&
|
||||
!preview.binary &&
|
||||
@@ -1523,6 +1497,7 @@ export function WorkspacePanel({
|
||||
];
|
||||
|
||||
return (
|
||||
<MarkdownImageTabContext.Provider value={workspaceTabId}>
|
||||
<aside
|
||||
ref={panelRef}
|
||||
className={`workspace-panel${embeddedDockMode ? " workspace-panel--embedded" : ""}${showTreeRail ? " workspace-panel--with-tree-rail" : ""}${changedMode ? " workspace-panel--detail-only" : ""}${changedMode && !selectedPath ? " workspace-panel--changed-overview" : ""}${previewVisible && actualTreeVisible ? " workspace-panel--split-preview" : ""}${actualTreeVisible ? "" : " workspace-panel--tree-hidden"}${previewVisible ? "" : " workspace-panel--preview-hidden"}${treeResizing ? " workspace-panel--tree-resizing" : ""}`}
|
||||
@@ -1612,7 +1587,7 @@ export function WorkspacePanel({
|
||||
type="button"
|
||||
className={`workspace-recent-menu__item${path === selectedPath ? " workspace-recent-menu__item--active" : ""}`}
|
||||
onClick={() => {
|
||||
setSelectedPath(path);
|
||||
setSelectedFilePath(path);
|
||||
setRecentOpen(false);
|
||||
}}
|
||||
>
|
||||
@@ -1685,7 +1660,7 @@ export function WorkspacePanel({
|
||||
onClick={() => {
|
||||
dismissedChangeListRequestIdRef.current = lastChangeListRequestIdRef.current;
|
||||
setScopedChangeRows(null);
|
||||
setSelectedPath(null);
|
||||
setSelectedChangePath(null);
|
||||
setExpandedCommit(null);
|
||||
setCommitDetail(null);
|
||||
}}
|
||||
@@ -1759,17 +1734,25 @@ export function WorkspacePanel({
|
||||
{workspaceGitWarning}
|
||||
</div>
|
||||
)}
|
||||
{overviewResourceStatus && (
|
||||
<div className={`workspace-resource-status${overviewResourceStatus.error ? " workspace-resource-status--error" : ""}`} role={overviewResourceStatus.error ? "alert" : "status"}>
|
||||
{overviewResourceStatus.text}
|
||||
</div>
|
||||
)}
|
||||
{groupedChangesLayout ? (
|
||||
<>
|
||||
{sessionChanges.length > 0 && renderChangeScope(t("context.sessionChanges"), sessionChanges)}
|
||||
{gitWorkingChanges.length > 0 && renderChangeScope(t("workspace.workingChanges"), gitWorkingChanges)}
|
||||
{!loadingHistory && !hasFileChanges && !workspaceGitWarning && (
|
||||
{!loadingWorkspaceChanges && !workspaceChangesErr && !hasFileChanges && !workspaceGitWarning && (
|
||||
<div className="workspace-empty">{t("context.noChanges")}</div>
|
||||
)}
|
||||
{loadingHistory ? (
|
||||
<div className="workspace-empty">{t("workspace.loading")}</div>
|
||||
) : (
|
||||
<section className={`workspace-commit-history${commitHistoryOpen ? " workspace-commit-history--open" : ""}`}>
|
||||
{loadingWorkspaceChanges && !workspaceChanges && (
|
||||
<div className="workspace-empty">{t("workspace.loadingChanges")}</div>
|
||||
)}
|
||||
{workspaceChangesErr && !workspaceChanges && (
|
||||
<div className="workspace-empty workspace-empty--error">{t("workspace.changesUnavailable")}: {workspaceChangesErr}</div>
|
||||
)}
|
||||
<section className={`workspace-commit-history${commitHistoryOpen ? " workspace-commit-history--open" : ""}`}>
|
||||
<button
|
||||
className="workspace-commit-history__toggle"
|
||||
type="button"
|
||||
@@ -1783,7 +1766,13 @@ export function WorkspacePanel({
|
||||
<span>{t("workspace.commitHistory")}</span>
|
||||
<small>{t("workspace.commitHistoryMeta", { count: gitHistory.length })}</small>
|
||||
</button>
|
||||
{commitHistoryOpen && (
|
||||
{commitHistoryOpen && (loadingHistory && gitHistory.length === 0 ? (
|
||||
<div className="workspace-empty">{t("workspace.loading")}</div>
|
||||
) : gitHistoryErr && gitHistory.length === 0 ? (
|
||||
<div className="workspace-empty workspace-empty--error">{t("workspace.historyUnavailable")}: {gitHistoryErr}</div>
|
||||
) : gitHistory.length === 0 ? (
|
||||
<div className="workspace-empty">{t("workspace.noCommitHistory")}</div>
|
||||
) : (
|
||||
<div className="workspace-git-history__list">
|
||||
{gitHistory.map((commit) => (
|
||||
<div key={commit.hash} className={`workspace-git-history__item${expandedCommit === commit.hash ? " workspace-git-history__item--expanded" : ""}`}>
|
||||
@@ -1828,16 +1817,17 @@ export function WorkspacePanel({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{sessionChanges.length > 0 && renderChangeScope(t("workspace.changedTab"), sessionChanges)}
|
||||
{gitWorkingChanges.length > 0 && renderChangeScope(t("workspace.workingChanges"), gitWorkingChanges)}
|
||||
{loadingHistory ? (
|
||||
{loadingHistory && gitHistory.length === 0 ? (
|
||||
<div className="workspace-empty">{t("workspace.loading")}</div>
|
||||
) : gitHistoryErr && gitHistory.length === 0 ? (
|
||||
<div className="workspace-empty workspace-empty--error">{t("workspace.historyUnavailable")}: {gitHistoryErr}</div>
|
||||
) : gitHistory.length === 0 && !hasFileChanges ? (
|
||||
<div className="workspace-empty">{workspaceGitWarning ? t("workspace.gitChangesUnknown") : t("workspace.noChanges")}</div>
|
||||
) : (
|
||||
@@ -1907,9 +1897,11 @@ export function WorkspacePanel({
|
||||
) : null}
|
||||
</header>
|
||||
<div className="workspace-current-change__body">
|
||||
{loadingChangeDetail ? (
|
||||
{loadingChangeDetail && changeDetail && <div className="workspace-resource-status" role="status">{t("workspace.loading")}</div>}
|
||||
{changeDetailErr && changeDetail && <div className="workspace-resource-status workspace-resource-status--error">{t("workspace.changeDetailUnavailable")}: {changeDetailErr}</div>}
|
||||
{loadingChangeDetail && !changeDetail ? (
|
||||
<div className="workspace-empty">{t("workspace.loading")}</div>
|
||||
) : changeDetailErr ? (
|
||||
) : changeDetailErr && !changeDetail ? (
|
||||
<div className="workspace-empty workspace-empty--error">{t("workspace.changeDetailUnavailable")}: {changeDetailErr}</div>
|
||||
) : changeDetail?.truncated ? (
|
||||
<div className="workspace-empty">{t("workspace.changeDetailTooLarge")}</div>
|
||||
@@ -1938,11 +1930,13 @@ export function WorkspacePanel({
|
||||
>
|
||||
{commitHistoryOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{t("workspace.commitHistory")}</span>
|
||||
<small>{loadingHistory ? t("workspace.loading") : t("workspace.commitHistoryMeta", { count: gitHistory.length })}</small>
|
||||
<small>{loadingHistory && gitHistory.length === 0 ? t("workspace.loading") : t("workspace.commitHistoryMeta", { count: gitHistory.length })}</small>
|
||||
</button>
|
||||
{commitHistoryOpen && (
|
||||
loadingHistory ? (
|
||||
loadingHistory && gitHistory.length === 0 ? (
|
||||
<div className="workspace-empty">{t("workspace.loading")}</div>
|
||||
) : gitHistoryErr && gitHistory.length === 0 ? (
|
||||
<div className="workspace-empty workspace-empty--error">{t("workspace.historyUnavailable")}: {gitHistoryErr}</div>
|
||||
) : gitHistory.length === 0 ? (
|
||||
<div className="workspace-empty">{t("workspace.noCommitHistory")}</div>
|
||||
) : (
|
||||
@@ -1981,18 +1975,20 @@ export function WorkspacePanel({
|
||||
</div>
|
||||
) : !selectedPath ? (
|
||||
<div className="workspace-empty">{t("workspace.pickFile")}</div>
|
||||
) : loadingPreview ? (
|
||||
) : loadingPreview && !preview ? (
|
||||
<div className="workspace-empty">{t("workspace.loading")}</div>
|
||||
) : preview?.err ? (
|
||||
) : preview?.err || (previewErr && !preview) ? (
|
||||
<div className="workspace-empty workspace-empty--error">
|
||||
{/no such file|not found|enoent/i.test(preview.err) ? t("workspace.fileDeleted") : preview.err}
|
||||
{/no such file|not found|enoent/i.test(previewErr || preview?.err || "") ? t("workspace.fileDeleted") : (previewErr || preview?.err)}
|
||||
</div>
|
||||
) : preview?.kind ? (
|
||||
renderMediaPreview(preview)
|
||||
<WorkspaceMediaPreview preview={preview} />
|
||||
) : preview?.binary ? (
|
||||
<div className="workspace-empty">{t("workspace.binary")}</div>
|
||||
) : preview ? (
|
||||
<>
|
||||
{loadingPreview && <div className="workspace-resource-status" role="status">{t("workspace.loading")}</div>}
|
||||
{previewErr && <div className="workspace-resource-status workspace-resource-status--error">{previewErr}</div>}
|
||||
{preview.truncated && <div className="workspace-note">{t("workspace.truncated")}</div>}
|
||||
{isMarkdown ? (
|
||||
<Markdown text={preview.body} />
|
||||
@@ -2000,6 +1996,7 @@ export function WorkspacePanel({
|
||||
<CodeViewer
|
||||
value={preview.body || " "}
|
||||
language={languageFor(selectedPath)}
|
||||
scrollMode="expand"
|
||||
sourceSize={preview.size}
|
||||
showLineNumbers
|
||||
searchRequestPending={codeSearchRequestPending && codeSearchRequestPath === selectedPath}
|
||||
@@ -2144,6 +2141,7 @@ export function WorkspacePanel({
|
||||
className="workspace-tree"
|
||||
ref={treeRef}
|
||||
onContextMenu={openTreeBlankMenu}
|
||||
onScroll={onWorkspaceTreeScroll}
|
||||
style={{
|
||||
height: "100%",
|
||||
overflow: "auto",
|
||||
@@ -2202,5 +2200,6 @@ export function WorkspacePanel({
|
||||
onClose={() => setTreeBlankMenuPoint(null)}
|
||||
/>
|
||||
</aside>
|
||||
</MarkdownImageTabContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
.workspace-resource-status {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: var(--z-workspace-status);
|
||||
padding: 3px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.workspace-resource-status--error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.workspace-git-history,
|
||||
.workspace-current-change__body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.layout--workspace-overlay .workbench-dock--overlay {
|
||||
position: absolute;
|
||||
z-index: var(--z-workspace-float);
|
||||
top: var(--app-chrome-height);
|
||||
right: 0;
|
||||
bottom: var(--statusbar-height);
|
||||
width: min(var(--workspace-width), calc(100vw - 16px));
|
||||
height: auto;
|
||||
grid-area: auto;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-right: 0;
|
||||
border-radius: 10px 0 0 10px;
|
||||
box-shadow: -12px 0 36px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
/* Preserve an opened sidebar beside a 400px chat surface at the native
|
||||
shell's 760px minimum; the right dock floats above both surfaces. */
|
||||
@media (max-width: 820px) {
|
||||
:is(.app, :root[data-theme-style] .app) .layout:not(.layout--sidebar-collapsed) {
|
||||
grid-template-columns: var(--sidebar-expanded-width) minmax(400px, 1fr) !important;
|
||||
}
|
||||
|
||||
:is(.app, :root[data-theme-style] .app) .layout.layout--sidebar-collapsed {
|
||||
grid-template-columns: minmax(400px, 1fr) !important;
|
||||
}
|
||||
|
||||
:is(.app, :root[data-theme-style] .app) .layout:not(.layout--sidebar-collapsed) .sidebar {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
z-index: var(--z-flow);
|
||||
display: flex !important;
|
||||
grid-column: 1;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
:is(.app, :root[data-theme-style] .app) .layout:not(.layout--sidebar-collapsed) .chat-pane {
|
||||
grid-column: 2 !important;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
:is(.app, :root[data-theme-style] .app) .layout.layout--sidebar-collapsed .chat-pane {
|
||||
grid-column: 1 !important;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
:is(.app, :root[data-theme-style] .app) .layout--workspace-overlay .workbench-dock--overlay {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { CopyButton } from "../CopyButton";
|
||||
// the COMPLETE plain text immediately and swap in highlighted HTML from an
|
||||
// idle callback; the skip caps (MAX_HIGHLIGHT_BYTES / MAX_HIGHLIGHT_LINES)
|
||||
// remain the plain-forever policy. Either way nothing is ever truncated.
|
||||
const HljsCode = memo(function HljsCode({ value, language, maxHeight, sourceSize }: EditorProps) {
|
||||
const HljsCode = memo(function HljsCode({ value, language, scrollMode, maxHeight, sourceSize }: EditorProps) {
|
||||
const syntaxHighlight = useMemo(
|
||||
() => shouldHighlightSource(value, sourceSize),
|
||||
[sourceSize, value],
|
||||
@@ -48,13 +48,15 @@ const HljsCode = memo(function HljsCode({ value, language, maxHeight, sourceSize
|
||||
return idleHtml ?? escapeHtml(value);
|
||||
}, [deferToIdle, idleHtml, language, syntaxHighlight, value]);
|
||||
const highlighted = syntaxHighlight && (!deferToIdle || idleHtml !== null);
|
||||
const bounded = scrollMode === "bounded" || (scrollMode !== "expand" && maxHeight != null);
|
||||
return (
|
||||
<div className="code-block__wrap">
|
||||
<pre
|
||||
className="code hljs"
|
||||
className={`code hljs${bounded ? " code--scroll-y" : ""}`}
|
||||
data-nested-scroll={bounded ? "" : undefined}
|
||||
data-highlight-mode={highlighted ? "syntax" : "plain"}
|
||||
data-lang={language}
|
||||
style={maxHeight ? { maxHeight } : undefined}
|
||||
style={maxHeight != null ? { maxHeight } : undefined}
|
||||
>
|
||||
<code dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</pre>
|
||||
|
||||
@@ -157,6 +157,7 @@ export default function LineNumberCode({
|
||||
language,
|
||||
showLineNumbers,
|
||||
maxHeight,
|
||||
scrollMode,
|
||||
sourceSize,
|
||||
searchRequestPending,
|
||||
onSearchRequestConsumed,
|
||||
@@ -333,6 +334,7 @@ export default function LineNumberCode({
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isVirtual = showLineNumbers !== false && lines.length > VIRTUAL_THRESHOLD;
|
||||
const bounded = scrollMode === "bounded" || (scrollMode !== "expand" && maxHeight != null) || isVirtual;
|
||||
const virtualizer = useVirtualizer({
|
||||
count: isVirtual ? lines.length : 0,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
@@ -587,13 +589,14 @@ export default function LineNumberCode({
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="code hljs code--lines"
|
||||
className={`code hljs code--lines${bounded ? " code--scroll-y" : ""}`}
|
||||
data-nested-scroll={bounded ? "" : undefined}
|
||||
data-lang={language}
|
||||
data-highlight-mode={syntaxHighlight ? "syntax" : "plain"}
|
||||
tabIndex={0}
|
||||
style={{
|
||||
maxHeight: maxHeight ?? undefined,
|
||||
overflow: maxHeight != null || isVirtual ? "auto" : undefined,
|
||||
overflow: bounded ? "auto" : undefined,
|
||||
}}
|
||||
>
|
||||
{isVirtual ? (
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { Components } from "react-markdown";
|
||||
import { CodeViewer } from "./CodeViewer";
|
||||
import { RichMarkdownLink } from "./githubLink";
|
||||
import { MarkdownTable } from "./MarkdownTable";
|
||||
import { markdownImageSource } from "../lib/markdownImage";
|
||||
import { MarkdownImage } from "./MarkdownImage";
|
||||
|
||||
const MermaidDiagram = lazy(() => import("./MermaidDiagram"));
|
||||
|
||||
@@ -63,10 +63,10 @@ function PlainMarkdownBlock({ text }: { text: string }) {
|
||||
const asList = statusItems.length >= 2;
|
||||
return (
|
||||
<div className={`md-plain-block${asList ? " md-plain-block--split" : " md-plain-block--pre"}`}>
|
||||
<CodeViewer value={text} maxHeight={360} />
|
||||
<CodeViewer value={text} scrollMode="bounded" maxHeight="min(60vh, 28rem)" />
|
||||
{asList && preText && (
|
||||
<div className="md-plain-block__diagram">
|
||||
<CodeViewer value={preText} maxHeight={360} />
|
||||
<CodeViewer value={preText} scrollMode="bounded" maxHeight="min(60vh, 28rem)" />
|
||||
</div>
|
||||
)}
|
||||
{asList && (
|
||||
@@ -99,25 +99,17 @@ export function createComponents(plainStatusBlocks: boolean): Components {
|
||||
const value = text.replace(/\n$/, "");
|
||||
if (lang === "mermaid") {
|
||||
return (
|
||||
<Suspense fallback={<CodeViewer value={value} language="mermaid" maxHeight={360} />}>
|
||||
<Suspense fallback={<CodeViewer value={value} language="mermaid" scrollMode="bounded" maxHeight="min(60vh, 28rem)" />}>
|
||||
<MermaidDiagram definition={value} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (!match && plainStatusBlocks) return <PlainMarkdownBlock text={text.replace(/\n$/, "")} />;
|
||||
return <CodeViewer value={value} language={lang} maxHeight={360} />;
|
||||
return <CodeViewer value={value} language={lang} scrollMode="bounded" maxHeight="min(60vh, 28rem)" />;
|
||||
}
|
||||
return <code className="md-code">{children}</code>;
|
||||
},
|
||||
a: ({ href, children }) => <RichMarkdownLink href={href}>{children}</RichMarkdownLink>,
|
||||
img: ({ src, alt, title }) => (
|
||||
<img
|
||||
src={markdownImageSource(src)}
|
||||
alt={alt ?? ""}
|
||||
title={title}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
),
|
||||
img: ({ src, alt, title }) => <MarkdownImage src={src} alt={alt} title={title} />,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { lazy, Suspense, useState, type ReactNode } from "react";
|
||||
import type { ToastContextValue } from "../lib/toast";
|
||||
|
||||
const BlankProjectFlow = lazy(() => import("./BlankProjectFlow").then((module) => ({ default: module.BlankProjectFlow })));
|
||||
|
||||
export function useProjectCreation({
|
||||
onAddProject,
|
||||
onRefresh,
|
||||
showToast,
|
||||
}: {
|
||||
onAddProject: (path?: string) => Promise<void>;
|
||||
onRefresh: () => Promise<void>;
|
||||
showToast: ToastContextValue["showToast"];
|
||||
}): {
|
||||
addingProject: boolean;
|
||||
handleAddProject: () => Promise<void>;
|
||||
openBlankProjectFlow: () => void;
|
||||
blankProjectFlow: ReactNode;
|
||||
} {
|
||||
const [addingProject, setAddingProject] = useState(false);
|
||||
const [blankProjectFlowOpen, setBlankProjectFlowOpen] = useState(false);
|
||||
const handleAddProject = async () => {
|
||||
if (addingProject) return;
|
||||
setAddingProject(true);
|
||||
try {
|
||||
await onAddProject();
|
||||
await onRefresh();
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
setAddingProject(false);
|
||||
}
|
||||
};
|
||||
return {
|
||||
addingProject,
|
||||
handleAddProject,
|
||||
openBlankProjectFlow: () => setBlankProjectFlowOpen(true),
|
||||
blankProjectFlow: blankProjectFlowOpen ? (
|
||||
<Suspense fallback={null}>
|
||||
<BlankProjectFlow
|
||||
onOpenProject={onAddProject}
|
||||
onRefresh={onRefresh}
|
||||
onClose={() => setBlankProjectFlowOpen(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface BlankProjectBindings {
|
||||
PickBlankProjectParent(): Promise<string>;
|
||||
CreateBlankProject(parentDir: string, projectName: string): Promise<string>;
|
||||
}
|
||||
|
||||
export function makeMockBlankProjectBindings(): BlankProjectBindings {
|
||||
return {
|
||||
async PickBlankProjectParent() {
|
||||
return "~/projects";
|
||||
},
|
||||
async CreateBlankProject(parentDir: string, projectName: string) {
|
||||
const parent = parentDir.replace(/[\\/]+$/, "");
|
||||
const name = projectName.trim();
|
||||
if (!parent || !name || name === "." || name === ".." || /[\\/]/.test(name)) {
|
||||
throw new Error("project name must be a single folder name");
|
||||
}
|
||||
return `${parent}/${name}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,12 +8,14 @@ import { maybeShare } from "./queryCoalesce";
|
||||
import { makeMockSessionCatalogBindings } from "./sessionCatalogBridge";
|
||||
import { makeMockHistoryCatalogBindings, type HistoryCatalogBindings } from "./historyCatalogBridge";
|
||||
import { makeMockTaskCatalogBindings, type TaskCatalogBindings } from "./taskCatalogBridge";
|
||||
import { makeMockBlankProjectBindings, type BlankProjectBindings } from "./blankProjectBridge";
|
||||
import { t } from "./i18n";
|
||||
import { providerIsConfigured, providerRequiresKey, removeProviderAccessesForMock } from "./providerModels";
|
||||
import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "./statusBarItems";
|
||||
import { registerTrustedThemeBackgroundURLs } from "./themePack";
|
||||
import { modeHasAutoApproveTools, modeWithAutoApproveTools, modeWithPlan, normalizeCollaborationMode, normalizeMode, normalizeTokenMode, normalizeToolApprovalMode } from "./types";
|
||||
import { decisionSurfaceMockFromInput, isLongDecisionOptionsMockInput } from "./decisionSurfaceMock";
|
||||
import { mockWorkspaceFile } from "./mockWorkspaceFile";
|
||||
import type {
|
||||
RemoteHostView,
|
||||
RemoteHostInput,
|
||||
@@ -84,6 +86,9 @@ import type {
|
||||
PluginInstallOptions,
|
||||
PluginView,
|
||||
ProjectNode,
|
||||
RecoveryLineageView,
|
||||
RecoveryCleanupRequest,
|
||||
RecoveryCleanupResult,
|
||||
SessionCatalogBindings,
|
||||
PromptHistoryEntry,
|
||||
PromptHistoryResult,
|
||||
@@ -120,6 +125,7 @@ import type {
|
||||
WorkspaceView,
|
||||
SessionClearResult,
|
||||
} from "./types";
|
||||
import type { MarkdownImageView } from "./markdownImage";
|
||||
|
||||
const GLOBAL_PROJECT_ORDER_KEY = "__global__";
|
||||
|
||||
@@ -138,8 +144,7 @@ function stripLegacyGoalBudgetFlags(arg: string): string {
|
||||
// Run `wails generate module` after adding/renaming a bound method on App, then
|
||||
// `pnpm typecheck` to verify the mock still satisfies the contract.
|
||||
//
|
||||
// Types for the new native-feel bindings — kept inline since they are
|
||||
// bridge-specific and only used in AppBindings / the dev mock.
|
||||
// Types for native-feel bindings, used only by AppBindings and the dev mock.
|
||||
interface NativeConfirmRequest {
|
||||
title: string;
|
||||
message: string;
|
||||
@@ -157,13 +162,12 @@ interface DesktopWindowState {
|
||||
maximised: boolean;
|
||||
}
|
||||
|
||||
// AppBindings is the hand-written contract between the React app and the Go
|
||||
// kernel. It uses local types (types.ts) so components don't import generated
|
||||
// model classes. _CheckGeneratedBindings catches drift: when a Go method is
|
||||
// AppBindings is the hand-written contract between React and Go. Components use
|
||||
// local types instead of generated model classes. _CheckGeneratedBindings catches drift: when a Go method is
|
||||
// added or renamed, the generated types shift, and a key present in GeneratedApp
|
||||
// but missing from AppBindings causes a type error here. Fix: add the new method
|
||||
// to AppBindings, then run `pnpm typecheck` to verify.
|
||||
export interface AppBindings extends SessionCatalogBindings, HistoryCatalogBindings, TaskCatalogBindings {
|
||||
export interface AppBindings extends SessionCatalogBindings, HistoryCatalogBindings, TaskCatalogBindings, BlankProjectBindings {
|
||||
Platform(): Promise<string>;
|
||||
MinimiseMainWindow(): Promise<void>;
|
||||
ToggleMaximiseMainWindow(): Promise<void>;
|
||||
@@ -207,7 +211,7 @@ export interface AppBindings extends SessionCatalogBindings, HistoryCatalogBindi
|
||||
revision: number;
|
||||
paused: boolean;
|
||||
recovered: boolean;
|
||||
recoveredCount?: number;
|
||||
recoveredCount?: number; sessionPath?: string;
|
||||
items: Array<{
|
||||
id: string;
|
||||
intent: string;
|
||||
@@ -246,7 +250,6 @@ export interface AppBindings extends SessionCatalogBindings, HistoryCatalogBindi
|
||||
ResolvePlanDecisionTab(tabID: string, id: string, action: "start_execution" | "revise_plan" | "exit_plan"): Promise<void>;
|
||||
ResolveRecovery(id: string, action: string, feedback: string): Promise<void>;
|
||||
ResolveRecoveryTab(tabID: string, id: string, action: string, feedback: string): Promise<void>;
|
||||
// Legacy no-ops: Auto Guard is always built into Auto.
|
||||
SetRecoveryCheckpointEnabled(enabled: boolean): Promise<void>;
|
||||
SetRecoveryCheckpointEnabledTab(tabID: string, enabled: boolean): Promise<void>;
|
||||
RecoveryCheckpointEnabled(): Promise<boolean>;
|
||||
@@ -254,10 +257,10 @@ export interface AppBindings extends SessionCatalogBindings, HistoryCatalogBindi
|
||||
AnswerQuestion(id: string, answers: QuestionAnswer[]): Promise<void>;
|
||||
AnswerQuestionForTab(tabID: string, id: string, answers: QuestionAnswer[]): Promise<void>;
|
||||
ReplayPendingPrompts(): Promise<void>;
|
||||
ReplayPendingPromptsForTab(tabID: string): Promise<void>;
|
||||
SetPlanMode(on: boolean): Promise<void>;
|
||||
SetMode(mode: string): Promise<void>;
|
||||
// Resolves with the pending approval prompt ids the switch auto-allowed
|
||||
// (drained); prompts not listed are still pending backend-side (#6432).
|
||||
// Returns auto-allowed prompt ids; unlisted prompts remain pending (#6432).
|
||||
SetModeForTab(tabID: string, mode: string): Promise<string[] | void>;
|
||||
SetAutoApproveTools(on: boolean): Promise<void>;
|
||||
SetCollaborationMode(mode: string): Promise<void>;
|
||||
@@ -315,6 +318,9 @@ export interface AppBindings extends SessionCatalogBindings, HistoryCatalogBindi
|
||||
PreviewSession(path: string): Promise<HistoryMessage[]>;
|
||||
DeleteSession(path: string): Promise<void>;
|
||||
DeleteRecoveryCopy(path: string): Promise<void>;
|
||||
GetRecoveryLineage(key: { scope: string; workspaceRoot?: string; topicId: string }): Promise<RecoveryLineageView>;
|
||||
ChooseRecoveryBranch(request: import("./types").RecoveryPreferenceRequest): Promise<void>;
|
||||
CleanRecoveryLineage(request: RecoveryCleanupRequest): Promise<RecoveryCleanupResult>;
|
||||
RestoreSession(path: string): Promise<void>;
|
||||
PurgeTrashedSession(path: string): Promise<void>;
|
||||
PurgeRecoveryCopy(path: string): Promise<void>;
|
||||
@@ -357,7 +363,7 @@ export interface AppBindings extends SessionCatalogBindings, HistoryCatalogBindi
|
||||
CloseTabWithPolicy(tabID: string, policy: "keep_running" | "stop_and_close"): Promise<void>;
|
||||
ToolResultForTab(tabID: string, toolID: string): Promise<{ args: string; output: string; execution?: import("./types").WireShellExecution } | null>;
|
||||
Meta(): Promise<Meta>;
|
||||
MetaForTab(tabID: string): Promise<Meta>;
|
||||
MetaForTab(tabID: string): Promise<Meta>; DismissTodoBatchForTab(tabID: string, batchKey: string): Promise<void>;
|
||||
Commands(): Promise<CommandInfo[]>;
|
||||
Capabilities(): Promise<CapabilitiesView>;
|
||||
MCPServers(): Promise<ServerView[]>;
|
||||
@@ -413,6 +419,7 @@ export interface AppBindings extends SessionCatalogBindings, HistoryCatalogBindi
|
||||
SearchFileRefsForTab(tabID: string, query: string): Promise<DirEntry[]>;
|
||||
ReadFile(rel: string): Promise<FilePreview>;
|
||||
ReadFileForTab(tabID: string, rel: string): Promise<FilePreview>;
|
||||
ResolveMarkdownImageForTab(tabID: string, source: string): Promise<MarkdownImageView>;
|
||||
WorkspaceRevisionForTab(tabID: string): Promise<{ revisions: WorkspaceRevisions; watchState: "active" | "degraded" | "unavailable" }>;
|
||||
WorkspaceChanges(tabID: string): Promise<WorkspaceChangesView>;
|
||||
WorkspaceChangeDetail(tabID: string, path: string): Promise<WorkspaceChangeDetailView>;
|
||||
@@ -527,7 +534,7 @@ export interface AppBindings extends SessionCatalogBindings, HistoryCatalogBindi
|
||||
SetCloseBehavior(mode: string): Promise<void>;
|
||||
SetDisplayMode(mode: string): Promise<void>;
|
||||
SetStatusBarStyle(style: string): Promise<void>;
|
||||
SetStatusBarItems(items: string[]): Promise<void>; SetReasoningDisplayMode(mode: "hidden" | "summary" | "auto"): Promise<void>;
|
||||
SetStatusBarItems(items: string[]): Promise<void>; SetReasoningDisplayMode(mode: "hidden" | "summary" | "auto" | "expanded"): Promise<void>;
|
||||
SetDesktopLanguage(lang: string): Promise<void>;
|
||||
SetDesktopCurrency(currency: string): Promise<void>;
|
||||
SetDesktopAppearance(theme: string, style: string): Promise<void>;
|
||||
@@ -550,6 +557,7 @@ export interface AppBindings extends SessionCatalogBindings, HistoryCatalogBindi
|
||||
SetDesktopZoomFactor(factor: number): Promise<void>;
|
||||
GetDesktopZoomFactor(): Promise<number>;
|
||||
RestartApplication(): Promise<void>;
|
||||
ReportDesktopWebViewReady(): Promise<void>;
|
||||
SetDesktopCheckUpdates(enabled: boolean): Promise<void>;
|
||||
SetDesktopUpdateChannel(channel: string): Promise<void>;
|
||||
SetDesktopTelemetry(enabled: boolean): Promise<void>;
|
||||
@@ -916,7 +924,7 @@ export function onReady(cb: (tabId?: string) => void): () => void {
|
||||
|
||||
export function onProjectTreeChanged(cb: () => void): () => void {
|
||||
if (realApp() && typeof window !== "undefined" && window.runtime) {
|
||||
return window.runtime.EventsOn("project-tree:changed", () => cb());
|
||||
return window.runtime.EventsOn("project-tree:changed", (payload?: unknown) => (payload as { reason?: unknown } | undefined)?.reason !== "runtime" && cb());
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
@@ -1235,7 +1243,7 @@ const mockVercelModels = ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-
|
||||
const mockOllamaCloudModels = ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro", "deepseek-v4-flash", "minimax-m3", "nemotron-3-nano:30b", "qwen3-coder-next"];
|
||||
|
||||
const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
|
||||
mockPreset("deepseek-responses", "DeepSeek Official Responses API", "Official stateless DeepSeek Responses API for Flash with server-side web search.", "DEEPSEEK_API_KEY", mockProviderTemplate({ name: "deepseek-responses", kind: "responses", baseUrl: "https://api.deepseek.com", models: ["deepseek-v4-flash"], default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", balanceUrl: "https://api.deepseek.com/user/balance", webSearch: true, serverWebSearchCapability: true, contextWindow: 1000000, supportedEfforts: ["low", "high", "max"], defaultEffort: "high" })),
|
||||
mockPreset("deepseek-responses", "DeepSeek Official Responses API", "Official stateless DeepSeek Responses API for Flash and Pro with server-side web search.", "DEEPSEEK_API_KEY", mockProviderTemplate({ name: "deepseek-responses", kind: "responses", baseUrl: "https://api.deepseek.com", models: ["deepseek-v4-flash", "deepseek-v4-pro"], default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", balanceUrl: "https://api.deepseek.com/user/balance", webSearch: true, serverWebSearchCapability: true, contextWindow: 1000000, modelOverrides: [{ model: "deepseek-v4-flash", reasoningProtocol: "", supportedEfforts: ["disabled", "low", "high", "max"], defaultEffort: "high" }, { model: "deepseek-v4-pro", reasoningProtocol: "", supportedEfforts: ["disabled", "high", "max"], defaultEffort: "high" }] })),
|
||||
mockPreset("longcat-openai", "LongCat OpenAI", "LongCat Platform OpenAI-compatible endpoint for LongCat-2.0.", "LONGCAT_API_KEY", mockProviderTemplate({ name: "longcat-openai", kind: "openai", baseUrl: "https://api.longcat.chat/openai/v1", modelsUrl: "https://api.longcat.chat/openai/v1/models", models: mockLongCatModels, default: "LongCat-2.0", apiKeyEnv: "LONGCAT_API_KEY", contextWindow: 131072, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("longcat-anthropic", "LongCat Anthropic", "LongCat Platform Anthropic-compatible Messages endpoint for LongCat-2.0.", "LONGCAT_API_KEY", mockProviderTemplate({ name: "longcat-anthropic", kind: "anthropic", baseUrl: "https://api.longcat.chat/anthropic", modelsUrl: "https://api.longcat.chat/anthropic/v1/models", models: mockLongCatModels, default: "LongCat-2.0", apiKeyEnv: "LONGCAT_API_KEY", authHeader: true, contextWindow: 131072, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("token-rhythm", "Token Rhythm", "Token Rhythm (基元律动) multi-model OpenAI-compatible gateway.", "TOKEN_RHYTHM_API_KEY", mockProviderTemplate({ name: "token-rhythm", kind: "openai", baseUrl: "https://tokenrhythm.studio/v1", modelsUrl: "https://tokenrhythm.studio/v1/models", models: mockTokenRhythmModels, visionModels: ["kimi-k2.5", "kimi-k2.6", "kimi-k2.7-code"], default: "deepseek-v4-flash", apiKeyEnv: "TOKEN_RHYTHM_API_KEY", contextWindow: 1000000, modelOverrides: mockTokenRhythmModelOverrides })),
|
||||
@@ -1823,9 +1831,9 @@ function makeMockApp(): AppBindings {
|
||||
root: "~/projects/joyquant-db",
|
||||
projectColor: "blue",
|
||||
children: [
|
||||
{ key: "topic_dev_standard", kind: "topic", label: `● ${t("mock.topicDevStandard")}`, root: "~/projects/joyquant-db", topicId: "topic_dev_standard", projectColor: "blue", turns: 18, lastActivityAt: mockNow - 8 * 60_000, open: true, running: runningMock },
|
||||
{ key: "topic_db_maint", kind: "topic", label: t("mock.topicDbMaint"), root: "~/projects/joyquant-db", topicId: "topic_db_maint", projectColor: "blue", turns: 7, lastActivityAt: mockNow - 2 * 60 * 60_000 },
|
||||
{ key: "topic_env", kind: "topic", label: t("mock.topicEnv"), root: "~/projects/joyquant-db", topicId: "topic_env", projectColor: "blue", turns: 3, lastActivityAt: mockNow - 26 * 60 * 60_000 },
|
||||
{ key: "topic_dev_standard", kind: "topic", label: `● ${t("mock.topicDevStandard")}`, root: "~/projects/joyquant-db", topicId: "topic_dev_standard", projectColor: "blue", turns: 18, createdAt: mockNow - 3 * 24 * 60 * 60_000, lastActivityAt: mockNow - 8 * 60_000, open: true, running: runningMock },
|
||||
{ key: "topic_db_maint", kind: "topic", label: t("mock.topicDbMaint"), root: "~/projects/joyquant-db", topicId: "topic_db_maint", projectColor: "blue", turns: 7, createdAt: mockNow - 2 * 24 * 60 * 60_000, lastActivityAt: mockNow - 2 * 60 * 60_000 },
|
||||
{ key: "topic_env", kind: "topic", label: t("mock.topicEnv"), root: "~/projects/joyquant-db", topicId: "topic_env", projectColor: "blue", turns: 3, createdAt: mockNow - 24 * 60 * 60_000, lastActivityAt: mockNow - 26 * 60 * 60_000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -2099,18 +2107,19 @@ function makeMockApp(): AppBindings {
|
||||
if (turnsOf[i] >= oldestTurn) { lo = i; break; }
|
||||
}
|
||||
}
|
||||
// Entry budget: the real backend keeps the newest suffix within the
|
||||
// entries cap, so oversized turns page toward older history instead of
|
||||
// returning thousands of rows at once.
|
||||
// Keep the newest suffix within the real backend's entry cap.
|
||||
const maxEntries = Math.max(1, Math.floor(req.entries || 120));
|
||||
if (before - lo > maxEntries) lo = before - maxEntries;
|
||||
const entries = messages.slice(lo, before).map((message, index) => ({
|
||||
entryId: `smock-${tabID}:r0:m${lo + index}:o0`,
|
||||
turn: turnsOf[lo + index],
|
||||
order: lo + index,
|
||||
message,
|
||||
refs: [],
|
||||
}));
|
||||
const entries = messages.slice(lo, before).map((message, index) => {
|
||||
const entryId = `smock-${tabID}:r0:m${lo + index}:o0`;
|
||||
const content = message.content ?? "";
|
||||
const lazyContent = benchMock && content.includes("ASYNC LAYOUT EXPANSION COMPLETE");
|
||||
return {
|
||||
entryId, turn: turnsOf[lo + index], order: lo + index,
|
||||
message: lazyContent ? { ...message, content: content.slice(0, 4 * 1024) } : message,
|
||||
refs: lazyContent ? [{ entryId, field: "content", size: content.length, chunks: 1, revision: 0, revKnown: false, digest: "" }] : [],
|
||||
};
|
||||
});
|
||||
const visibleTurns = entries.map((entry) => entry.turn).filter((value) => value > 0);
|
||||
return {
|
||||
entries,
|
||||
@@ -2439,6 +2448,7 @@ function makeMockApp(): AppBindings {
|
||||
};
|
||||
return {
|
||||
...makeMockSessionCatalogBindings(cloneProjectTree),
|
||||
...makeMockBlankProjectBindings(),
|
||||
async MinimiseMainWindow() {
|
||||
console.info("mock MinimiseMainWindow");
|
||||
},
|
||||
@@ -2928,7 +2938,7 @@ function makeMockApp(): AppBindings {
|
||||
return {
|
||||
revision: 0,
|
||||
paused: false,
|
||||
recovered: false,
|
||||
recovered: false, sessionPath: "",
|
||||
items: [],
|
||||
itemsCount: 0,
|
||||
bytes: 0,
|
||||
@@ -3037,6 +3047,7 @@ function makeMockApp(): AppBindings {
|
||||
await withMockTabScope(_tabID, () => this.AnswerQuestion(id, answers));
|
||||
},
|
||||
async ReplayPendingPrompts() {},
|
||||
async ReplayPendingPromptsForTab(_tabID) {},
|
||||
async ConfirmAction(req) {
|
||||
void req;
|
||||
return false;
|
||||
@@ -3253,6 +3264,7 @@ function makeMockApp(): AppBindings {
|
||||
if (!match) return out;
|
||||
const messages = await this.HistoryForTab(tabID);
|
||||
const message = messages[Number(match[1])];
|
||||
if (benchMock && message?.content?.includes("ASYNC LAYOUT EXPANSION COMPLETE")) await delay(1_500);
|
||||
if (!message) return { ...out, stale: true };
|
||||
out.data = mockHistoryContentField(message, ref);
|
||||
out.chunks = 1;
|
||||
@@ -3324,6 +3336,24 @@ function makeMockApp(): AppBindings {
|
||||
async DeleteRecoveryCopy(path: string) {
|
||||
return this.DeleteSession(path);
|
||||
},
|
||||
async GetRecoveryLineage(key) {
|
||||
const topic = findMockTopic(key.topicId);
|
||||
return {
|
||||
groupId: key.topicId,
|
||||
state: topic?.recoveryState ?? "normal",
|
||||
branchCount: topic?.recoveryBranchCount ?? 0,
|
||||
unresolved: topic?.recoveryUnresolvedCount ?? 0,
|
||||
cleanupEligible: topic?.recoveryCleanupEligibleCount ?? 0,
|
||||
members: [],
|
||||
};
|
||||
},
|
||||
async ChooseRecoveryBranch() {},
|
||||
async CleanRecoveryLineage(request) {
|
||||
const topic = findMockTopic(request.topicId);
|
||||
const eligible = topic?.recoveryCleanupEligibleCount ?? 0;
|
||||
if (request.apply && topic) topic.recoveryCleanupEligibleCount = 0;
|
||||
return { eligible, moved: request.apply ? eligible : 0, busy: 0, kept: 0, dryRun: !request.apply, items: [] };
|
||||
},
|
||||
async RestoreSession(path: string) {
|
||||
const i = trashedSessions.findIndex((s) => s.path === path);
|
||||
if (i >= 0) {
|
||||
@@ -3461,7 +3491,7 @@ function makeMockApp(): AppBindings {
|
||||
goal: active?.goal ?? "",
|
||||
goalStatus: active?.goalStatus ?? (active?.goal ? "running" : "stopped"),
|
||||
};
|
||||
},
|
||||
}, async DismissTodoBatchForTab() {},
|
||||
async MetaForTab(tabID) {
|
||||
const tab = mockTabs.find((item) => item.id === tabID) ?? mockTabs.find((item) => item.active) ?? mockTabs[0];
|
||||
const toolApprovalMode = normalizeToolApprovalMode(tab?.toolApprovalMode, tab ? normalizeMode(tab.mode) : "normal", settings.autoApproveTools);
|
||||
@@ -3991,23 +4021,14 @@ function makeMockApp(): AppBindings {
|
||||
return this.SearchFileRefs(query);
|
||||
},
|
||||
async ReadFile(rel: string) {
|
||||
const samples: Record<string, string> = {
|
||||
"README.md": "# Reasonix\n\nBrowser-dev workspace preview.\n\n- Chat in the center\n- Browse files on the right\n- Keep sessions on the left\n",
|
||||
"go.mod": "module reasonix\n\ngo 1.23\n",
|
||||
"desktop/file.go": "package desktop\n\nfunc main() {\n\tprintln(\"workspace preview\")\n}\n",
|
||||
"internal/event.go": "package internal\n\n// mock file used by the browser dev seam\n",
|
||||
};
|
||||
return {
|
||||
path: rel,
|
||||
body: samples[rel] ?? `// ${rel}\n\nMock file body from browser dev.`,
|
||||
size: samples[rel]?.length ?? 42,
|
||||
truncated: false,
|
||||
binary: false,
|
||||
};
|
||||
return mockWorkspaceFile(rel);
|
||||
},
|
||||
async ReadFileForTab(_tabID: string, rel: string) {
|
||||
return this.ReadFile(rel);
|
||||
},
|
||||
async ResolveMarkdownImageForTab(_tabID: string, source: string) {
|
||||
return { url: source, openHref: source };
|
||||
},
|
||||
async WorkspaceRevisionForTab(_tabID: string) {
|
||||
return { revisions: { content: 0, tree: 0, workingTree: 0, gitMeta: 0, session: 0 }, watchState: "active" as const };
|
||||
},
|
||||
@@ -4860,6 +4881,9 @@ function makeMockApp(): AppBindings {
|
||||
async RestartApplication() {
|
||||
// no-op in mock
|
||||
},
|
||||
async ReportDesktopWebViewReady() {
|
||||
// no-op in mock
|
||||
},
|
||||
async SetDesktopCheckUpdates(enabled: boolean) {
|
||||
settings.checkUpdates = enabled;
|
||||
},
|
||||
@@ -4874,7 +4898,7 @@ function makeMockApp(): AppBindings {
|
||||
settings.metrics = enabled;
|
||||
},
|
||||
async SetDesktopConversationWidth(width: string) { settings.conversationWidth = width; },
|
||||
async SetReasoningDisplayMode(mode: "hidden" | "summary" | "auto") { if (!(["hidden", "summary", "auto"] as string[]).includes(mode)) throw new Error("invalid reasoning display mode"); settings.reasoningDisplayMode = mode; settings.reasoningDisplayModeExplicit = true; },
|
||||
async SetReasoningDisplayMode(mode: "hidden" | "summary" | "auto" | "expanded") { if (!(["hidden", "summary", "auto", "expanded"] as string[]).includes(mode)) throw new Error("invalid reasoning display mode"); settings.reasoningDisplayMode = mode; settings.reasoningDisplayModeExplicit = true; },
|
||||
async SetExpandThinking(on: boolean) { settings.reasoningDisplayMode = on ? "auto" : "summary"; settings.reasoningDisplayModeExplicit = true; },
|
||||
async MigrateDesktopPreferences(language: string, theme: string, style: string) {
|
||||
if (!settings.desktopLanguage) settings.desktopLanguage = language === "en" || language === "zh" || language === "zh-TW" ? language : "";
|
||||
|
||||
@@ -113,7 +113,16 @@ const benchMarkdownHeavyHistory = (): HistoryMessage[] => {
|
||||
const benchSmallHistory = (): HistoryMessage[] => {
|
||||
// 6 visible turns × 78 messages = 468 provider messages (nominal 473).
|
||||
const messages: HistoryMessage[] = [];
|
||||
for (let turn = 1; turn <= 6; turn += 1) messages.push(...benchToolTurn(turn, 38, `Batch ${turn} summary.`));
|
||||
for (let turn = 1; turn <= 6; turn += 1) {
|
||||
const answer = turn === 6
|
||||
? [
|
||||
"# Asynchronously hydrated verification appendix",
|
||||
...Array.from({ length: 1_200 }, (_, row) => `- package-${row % 42}: verified row ${row} with stable virtual measurements`),
|
||||
"ASYNC LAYOUT EXPANSION COMPLETE",
|
||||
].join("\n")
|
||||
: `Batch ${turn} summary.`;
|
||||
messages.push(...benchToolTurn(turn, 38, answer));
|
||||
}
|
||||
return messages;
|
||||
};
|
||||
const benchGiantTurnHistory = (): HistoryMessage[] => {
|
||||
|
||||
@@ -2,6 +2,16 @@ export function guidanceNeedsRetry(state?: string): boolean {
|
||||
return state === "uncertain" || state === "blocked";
|
||||
}
|
||||
|
||||
export function guidanceIsInFlight(state?: string): boolean {
|
||||
return state === "running" || state === "steer_accepted" || state === "steer_consumed";
|
||||
}
|
||||
|
||||
export function guidanceIsEditable(item: { id?: string; state?: string; source?: string; paused?: boolean }): boolean {
|
||||
if (!item.id || item.id.startsWith("local-")) return false;
|
||||
if (item.paused || guidanceIsInFlight(item.state) || guidanceNeedsRetry(item.state)) return false;
|
||||
return item.state === "queued" || item.state === undefined || item.state === "";
|
||||
}
|
||||
|
||||
export function markGuidanceQueued<T extends { id: string; state?: string; paused?: boolean }>(items: T[], id: string): T[] {
|
||||
return items.map((item) => item.id === id ? { ...item, state: "queued", paused: false } : item);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { PendingGuidance } from "../components/ComposerGuidanceShelf";
|
||||
|
||||
export type InboxSnapshotLike = {
|
||||
paused?: boolean;
|
||||
recovered?: boolean;
|
||||
recoveredCount?: number;
|
||||
sessionPath?: string;
|
||||
items?: Array<{
|
||||
id: string;
|
||||
preview?: string;
|
||||
state?: string;
|
||||
intent?: string;
|
||||
source?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function inboxScopeKey(sessionPath?: string, workspaceScopeKey?: string): string {
|
||||
return (sessionPath || "").trim() || workspaceScopeKey || "";
|
||||
}
|
||||
|
||||
export function inboxSnapshotBelongsToScope(snapshotPath: string | undefined, scopeKey: string): boolean {
|
||||
const path = (snapshotPath || "").trim();
|
||||
if (!path || !scopeKey) return true;
|
||||
if (scopeKey === path) return true;
|
||||
return scopeKey.split("\u0000").includes(path);
|
||||
}
|
||||
|
||||
export function localGuidanceFallback(previewKey: string): PendingGuidance[] {
|
||||
return previewKey
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((text, i) => ({ id: `local-${i}`, text, submitText: text }));
|
||||
}
|
||||
|
||||
export function guidanceFromInboxSnapshot(snap: InboxSnapshotLike | null | undefined): PendingGuidance[] {
|
||||
return (snap?.items ?? []).map((it) => ({
|
||||
id: it.id,
|
||||
text: it.preview || "",
|
||||
submitText: "",
|
||||
state: it.state,
|
||||
intent: it.intent,
|
||||
source: it.source,
|
||||
paused: Boolean(snap?.paused),
|
||||
recoveredCount: snap?.paused && snap?.recovered
|
||||
? (snap.recoveredCount || snap.items?.length || 0)
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function hydrateEmptyGuidancePreviews(
|
||||
items: PendingGuidance[],
|
||||
readItem: (id: string) => Promise<{ displayText?: string; submitText?: string; rawText?: string }>,
|
||||
): Promise<PendingGuidance[]> {
|
||||
const missing = items.filter((item) => !item.text.trim() && !item.id.startsWith("local-"));
|
||||
if (missing.length === 0) return items;
|
||||
await Promise.all(missing.map(async (item) => {
|
||||
try {
|
||||
const env = await readItem(item.id);
|
||||
const text = (env.displayText || env.submitText || env.rawText || "").trim();
|
||||
if (text) item.text = text;
|
||||
} catch {
|
||||
// Preview-only snapshot remains if the body cannot be read.
|
||||
}
|
||||
}));
|
||||
return items;
|
||||
}
|
||||
|
||||
export function mergeGuidanceSnapshot(durable: PendingGuidance[], fallback: PendingGuidance[]): PendingGuidance[] {
|
||||
return durable.length > 0 ? durable : fallback;
|
||||
}
|
||||
Loaded 100 of 776 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user