mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
+41
-59
@@ -3761,25 +3761,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
|
||||
@@ -5274,13 +5256,14 @@ 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"`
|
||||
ServerSearch []provider.ServerSearchCall `json:"serverSearch,omitempty"`
|
||||
}
|
||||
|
||||
type HistoryToolCall struct {
|
||||
@@ -5680,13 +5663,8 @@ func (state *historyMessageConvertState) convertHistoryMessage(
|
||||
})
|
||||
}
|
||||
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 := historySteerRows(agent.UserMessageText(m), true); handled {
|
||||
return append(out, rows...)
|
||||
}
|
||||
}
|
||||
if state.suppressCanonicalTurn {
|
||||
@@ -5704,8 +5682,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 +5719,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 +6700,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 +6801,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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,12 +52,100 @@ try {
|
||||
await page.click('.project-tree__topic-main:has-text("bench:tools-38t")');
|
||||
await page.waitForFunction(() => document.querySelectorAll(".transcript__row").length > 4, undefined, { timeout: 30_000 });
|
||||
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) => {
|
||||
|
||||
@@ -18,6 +18,96 @@ function assert(condition, 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 === "logical-selecting",
|
||||
undefined,
|
||||
{ timeout },
|
||||
).then(() => true, () => false);
|
||||
}
|
||||
|
||||
async function waitForServer() {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -78,62 +168,11 @@ try {
|
||||
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(() => {
|
||||
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;
|
||||
});
|
||||
if (visibleSelectable) break;
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
@@ -335,53 +374,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,38 +406,24 @@ 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,
|
||||
|
||||
@@ -60,9 +60,11 @@ 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.
|
||||
assertBudget("initial JavaScript gzip", initialJSGzip, 424.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 +77,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }]),
|
||||
|
||||
@@ -2107,12 +2107,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 +2139,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 +2149,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);
|
||||
|
||||
@@ -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,
|
||||
/scrollToBottom\(\);\n \}, \[footerHeight, scrollToBottom, stick\]\)/,
|
||||
"footer re-pin is not tied to transcript 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);
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import {
|
||||
projectTreeFolderDisclosure,
|
||||
mergeProjectTopicPage,
|
||||
projectTreeWithoutTopic,
|
||||
projectTreeShellChildren,
|
||||
projectTreeEventAffectsFolder,
|
||||
projectTreeRevisionIsFresh,
|
||||
projectTreeShouldApplyShellSnapshot,
|
||||
@@ -12,6 +14,8 @@ import {
|
||||
projectTreeShouldSuppressOpenForRename,
|
||||
projectTreeReadActivityKey,
|
||||
projectTreeTopicHasUnreadActivity,
|
||||
topicIsActive,
|
||||
topicStatusLabel,
|
||||
projectTreeTopicArchiveBlocked,
|
||||
projectTreeShouldRenderTopicActions,
|
||||
projectTreeTopicMetaLine,
|
||||
@@ -24,6 +28,9 @@ import {
|
||||
} from "../components/ProjectTree";
|
||||
import { projectTreeTrashingTopics } from "../lib/projectTreeArchive";
|
||||
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;
|
||||
@@ -211,6 +218,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 +254,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 +262,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,
|
||||
@@ -657,6 +703,42 @@ 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_archive", kind: "topic", label: "Archive me", topicId: "topic_archive" }],
|
||||
{ keepLoadedTopics: false },
|
||||
),
|
||||
[],
|
||||
"a mutation refresh does not restore stale topic children from the previous page",
|
||||
);
|
||||
|
||||
const projectTreeSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../components/ProjectTree.tsx"), "utf8");
|
||||
eq(projectTreeSource.includes("projectTreeWithoutTopic("), true, "TrashTopic removes the archived row before the shell refresh");
|
||||
eq(
|
||||
projectTreeSource.includes("keepLoadedTopics: false") || projectTreeSource.includes("reloadTopics"),
|
||||
true,
|
||||
"archive refresh reloads topic pages instead of preserving stale children",
|
||||
);
|
||||
|
||||
eq(
|
||||
[
|
||||
projectTreeEventAffectsFolder({ key: "global", kind: "global_folder", label: "Global" }, [""]),
|
||||
|
||||
@@ -23,5 +23,7 @@ assert.doesNotMatch(
|
||||
/event\.revision\s*<=\s*latestRevisionRef\.current/,
|
||||
"equal-revision tombstone overlays are not discarded",
|
||||
);
|
||||
assert.match(panel, /setIndexingDone\(Boolean\(snapshot\.indexingDone\)\)/, "shell snapshot records first-scan completion");
|
||||
assert.match(panel, /if \(!indexingDone\) return;/, "first completed scan reloads expanded topic pages");
|
||||
|
||||
console.log(" PASS project tree shell race contract");
|
||||
@@ -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`);
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -53,6 +53,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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
// 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.
|
||||
// not wait 500ms. A LAST undershoot on a history session must finish at the
|
||||
// native extent so wheel/jump-bottom can reveal the last rows.
|
||||
|
||||
import {
|
||||
isPinnedTranscriptLayoutGrowth,
|
||||
TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX,
|
||||
isPinnedTranscriptViewportChange,
|
||||
nativeTranscriptBottomTop,
|
||||
nativeTranscriptDistanceFromBottom,
|
||||
shouldClearBottomRequestOnAtBottomTrue,
|
||||
shouldClearBottomRequestOnWriteOffset,
|
||||
shouldFinishTailOnAtBottomFalse,
|
||||
shouldFinishTailOnBottomRequestTimer,
|
||||
shouldKeepPinnedOnAtBottomFalse,
|
||||
shouldReleaseBottomRequestOnAtBottomFalse,
|
||||
shouldRemeasureMountedRowsForTailFinish,
|
||||
shouldSnapPinnedWheelToNativeBottom,
|
||||
} from "../lib/useTranscriptVirtuosoScroll";
|
||||
|
||||
let passed = 0;
|
||||
@@ -29,15 +39,19 @@ function mockAtBottomStateChange(
|
||||
atBottom: boolean,
|
||||
bottomRequestActive: boolean,
|
||||
scrollElement: { scrollHeight: number; scrollTop: number; clientHeight: number } | null,
|
||||
previousScrollTop = 9397,
|
||||
) {
|
||||
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
|
||||
}
|
||||
if (scrollElement && shouldReleaseBottomRequestOnAtBottomFalse({
|
||||
distanceFromBottom: nativeTranscriptDistanceFromBottom(scrollElement),
|
||||
scrollTop: scrollElement.scrollTop,
|
||||
previousScrollTop,
|
||||
scrollHeight: scrollElement.scrollHeight,
|
||||
clientHeight: scrollElement.clientHeight,
|
||||
})) {
|
||||
return "manual";
|
||||
}
|
||||
return "suppressed"; // Old behavior: early return, no mode change
|
||||
return "suppressed";
|
||||
}
|
||||
return atBottom ? "tail-follow" : "manual";
|
||||
}
|
||||
@@ -47,10 +61,10 @@ function mockAtBottomStateChange(
|
||||
const s1 = mockAtBottomStateChange(false, true, { scrollHeight: 10000, scrollTop: 9397, clientHeight: 600 });
|
||||
check(s1 === "suppressed", "physically at bottom during request window: intent preserved");
|
||||
|
||||
// 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");
|
||||
// Scenario 2: LAST after a native snap pulls scrollTop back from the native max.
|
||||
// Expected: suppressed (undershoot; remasure + snap, do not unpin).
|
||||
const s2 = mockAtBottomStateChange(false, true, { scrollHeight: 10000, scrollTop: 9000, clientHeight: 600 }, 9400);
|
||||
check(s2 === "suppressed", "LAST pullback from the native max during request window: keep tail intent");
|
||||
|
||||
// Scenario 3: atBottom=false, no active request.
|
||||
// Expected: manual (ordinary path).
|
||||
@@ -70,6 +84,18 @@ const layoutGrowth = isPinnedTranscriptLayoutGrowth({
|
||||
scrollTop: 9714,
|
||||
});
|
||||
check(layoutGrowth, "pinned row growth preserves tail-follow through callback reordering");
|
||||
check(
|
||||
shouldKeepPinnedOnAtBottomFalse({
|
||||
pinned: true,
|
||||
previousScrollHeight: 10000,
|
||||
previousScrollTop: 9400,
|
||||
previousClientHeight: 600,
|
||||
scrollHeight: 10320,
|
||||
scrollTop: 9714,
|
||||
clientHeight: 600,
|
||||
}),
|
||||
"pinned row growth still keeps the tail through the combined policy",
|
||||
);
|
||||
|
||||
const userScrollDuringGrowth = isPinnedTranscriptLayoutGrowth({
|
||||
pinned: true,
|
||||
@@ -80,5 +106,201 @@ const userScrollDuringGrowth = isPinnedTranscriptLayoutGrowth({
|
||||
});
|
||||
check(!userScrollDuringGrowth, "upward user movement is not mistaken for pinned row growth");
|
||||
|
||||
const dismissTodoViewport = {
|
||||
pinned: true,
|
||||
previousScrollHeight: 10000,
|
||||
previousScrollTop: 9400,
|
||||
previousClientHeight: 600,
|
||||
scrollHeight: 10000,
|
||||
// Virtuoso can reset scrollTop to the start of the loaded window when the
|
||||
// composer/todo footer shrinks and the transcript viewport grows.
|
||||
scrollTop: 0,
|
||||
clientHeight: 760,
|
||||
};
|
||||
check(
|
||||
isPinnedTranscriptViewportChange(dismissTodoViewport),
|
||||
"dismissing the todo footer is a pinned viewport change",
|
||||
);
|
||||
check(
|
||||
shouldKeepPinnedOnAtBottomFalse(dismissTodoViewport),
|
||||
"todo dismiss must not treat a viewport-grow remasure as leaving the tail",
|
||||
);
|
||||
|
||||
const openTodoViewport = {
|
||||
...dismissTodoViewport,
|
||||
previousClientHeight: 760,
|
||||
clientHeight: 600,
|
||||
scrollTop: 9400,
|
||||
};
|
||||
check(
|
||||
isPinnedTranscriptViewportChange(openTodoViewport),
|
||||
"opening the todo footer is a pinned viewport change",
|
||||
);
|
||||
check(
|
||||
shouldKeepPinnedOnAtBottomFalse(openTodoViewport),
|
||||
"todo open must keep tail-follow while the composer chrome grows",
|
||||
);
|
||||
|
||||
const readingHistory = {
|
||||
...dismissTodoViewport,
|
||||
pinned: false,
|
||||
};
|
||||
check(
|
||||
!isPinnedTranscriptViewportChange(readingHistory),
|
||||
"an unpinned reader is not forced back to the tail when the footer resizes",
|
||||
);
|
||||
check(
|
||||
!shouldKeepPinnedOnAtBottomFalse(readingHistory),
|
||||
"unpinned history reading survives todo dismiss",
|
||||
);
|
||||
|
||||
console.log("\ntranscript history native bottom");
|
||||
|
||||
const lastItemUndershoot = {
|
||||
scrollHeight: 10000,
|
||||
scrollTop: 9320,
|
||||
clientHeight: 600,
|
||||
};
|
||||
check(
|
||||
nativeTranscriptDistanceFromBottom(lastItemUndershoot) === 80,
|
||||
"an underestimated last row leaves a native gap below Virtuoso LAST",
|
||||
);
|
||||
check(
|
||||
nativeTranscriptBottomTop(lastItemUndershoot) === 9400,
|
||||
"jump-bottom and tail-follow finish at the native scroll extent",
|
||||
);
|
||||
check(
|
||||
!shouldReleaseBottomRequestOnAtBottomFalse({
|
||||
distanceFromBottom: nativeTranscriptDistanceFromBottom(lastItemUndershoot),
|
||||
scrollTop: lastItemUndershoot.scrollTop,
|
||||
previousScrollTop: 9320,
|
||||
scrollHeight: lastItemUndershoot.scrollHeight,
|
||||
clientHeight: lastItemUndershoot.clientHeight,
|
||||
}),
|
||||
"LAST undershoot during jump-bottom is not the reader leaving the tail",
|
||||
);
|
||||
check(
|
||||
!shouldReleaseBottomRequestOnAtBottomFalse({
|
||||
distanceFromBottom: 400,
|
||||
scrollTop: 9000,
|
||||
previousScrollTop: 9400,
|
||||
previousScrollHeight: 10000,
|
||||
previousClientHeight: 600,
|
||||
scrollHeight: 10000,
|
||||
clientHeight: 600,
|
||||
}),
|
||||
"LAST pullback from the native max is not the reader leaving",
|
||||
);
|
||||
check(
|
||||
!shouldReleaseBottomRequestOnAtBottomFalse({
|
||||
distanceFromBottom: 1200,
|
||||
scrollTop: 9000,
|
||||
previousScrollTop: 9400,
|
||||
previousScrollHeight: 10000,
|
||||
previousClientHeight: 600,
|
||||
scrollHeight: 10800,
|
||||
clientHeight: 600,
|
||||
}),
|
||||
"LAST pullback after last-row growth is not the reader leaving",
|
||||
);
|
||||
check(
|
||||
!shouldClearBottomRequestOnAtBottomTrue(),
|
||||
"a native snap at-bottom report must not cancel the jump-bottom timer",
|
||||
);
|
||||
check(
|
||||
shouldReleaseBottomRequestOnAtBottomFalse({
|
||||
distanceFromBottom: 900,
|
||||
scrollTop: 7500,
|
||||
previousScrollTop: 8500,
|
||||
scrollHeight: 10000,
|
||||
clientHeight: 600,
|
||||
}),
|
||||
"an upward leave that did not start at the native max still releases",
|
||||
);
|
||||
check(
|
||||
shouldRemeasureMountedRowsForTailFinish({ remeasuredThisCommand: false }),
|
||||
"the first tail finish remasures mounted rows",
|
||||
);
|
||||
check(
|
||||
!shouldRemeasureMountedRowsForTailFinish({ remeasuredThisCommand: true }),
|
||||
"a post-success tail finish does not remasure again",
|
||||
);
|
||||
check(
|
||||
!shouldRemeasureMountedRowsForTailFinish({ remeasuredThisCommand: false, allowRemeasure: false }),
|
||||
"a pinned growth finish snaps without remasuring",
|
||||
);
|
||||
check(
|
||||
shouldFinishTailOnBottomRequestTimer({ pinned: true, bottomRequestWasActive: true }),
|
||||
"the bottom-request timer still finishes a pinned tail after LAST overwrite",
|
||||
);
|
||||
check(
|
||||
!shouldFinishTailOnBottomRequestTimer({ pinned: false, bottomRequestWasActive: true }),
|
||||
"an unpinned leave during the jump window is not force-snapped back",
|
||||
);
|
||||
check(
|
||||
!shouldFinishTailOnBottomRequestTimer({ pinned: false, bottomRequestWasActive: false }),
|
||||
"the timer does not re-pin after an explicit release cleared the request",
|
||||
);
|
||||
check(
|
||||
shouldClearBottomRequestOnWriteOffset("custom-scrollbar"),
|
||||
"creation-mode scrollbar drag cancels the jump-bottom window",
|
||||
);
|
||||
check(
|
||||
shouldClearBottomRequestOnWriteOffset("rewind"),
|
||||
"a programmatic rewind leave cancels the jump-bottom window",
|
||||
);
|
||||
check(
|
||||
!shouldClearBottomRequestOnWriteOffset("jump-bottom"),
|
||||
"jump-bottom itself still opens the request window",
|
||||
);
|
||||
check(
|
||||
!shouldClearBottomRequestOnWriteOffset("selection-edge-scroll"),
|
||||
"selection edge scroll does not cancel a jump window",
|
||||
);
|
||||
check(
|
||||
shouldFinishTailOnAtBottomFalse({ pinned: true, bottomRequestActive: false }),
|
||||
"a pinned LAST overwrite after the jump window still finishes the tail",
|
||||
);
|
||||
check(
|
||||
shouldFinishTailOnAtBottomFalse({ pinned: false, bottomRequestActive: true }),
|
||||
"a jump-bottom request still finishes the tail even if LAST briefly unpinned",
|
||||
);
|
||||
check(
|
||||
!shouldFinishTailOnAtBottomFalse({ pinned: false, bottomRequestActive: false }),
|
||||
"an unpinned reader without a jump request keeps ordinary at-bottom updates",
|
||||
);
|
||||
check(
|
||||
shouldSnapPinnedWheelToNativeBottom({
|
||||
pinned: true,
|
||||
deltaY: 120,
|
||||
distanceFromBottom: 80,
|
||||
}),
|
||||
"wheel-down while pinned to a false tail consumes the native gap",
|
||||
);
|
||||
check(
|
||||
!shouldSnapPinnedWheelToNativeBottom({
|
||||
pinned: true,
|
||||
deltaY: 120,
|
||||
distanceFromBottom: 0,
|
||||
}),
|
||||
"wheel-down at the physical bottom stays in ordinary tail-follow",
|
||||
);
|
||||
check(
|
||||
!shouldSnapPinnedWheelToNativeBottom({
|
||||
pinned: true,
|
||||
deltaY: -120,
|
||||
distanceFromBottom: 80,
|
||||
}),
|
||||
"wheel-up still leaves tail-follow instead of snapping back down",
|
||||
);
|
||||
check(
|
||||
!shouldSnapPinnedWheelToNativeBottom({
|
||||
pinned: false,
|
||||
deltaY: 120,
|
||||
distanceFromBottom: 80,
|
||||
}),
|
||||
"an unpinned reader keeps ordinary wheel scrolling",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -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;
|
||||
@@ -248,5 +251,31 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -201,22 +201,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 +264,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 +274,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 +394,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 +413,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 +425,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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -131,12 +131,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 +168,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;
|
||||
}
|
||||
|
||||
@@ -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,7 +10,7 @@ import { asArray } from "../lib/array";
|
||||
import { useToast } from "../lib/toast";
|
||||
import { app } from "../lib/bridge";
|
||||
import { onProjectTreeChangedV2 } from "../lib/sessionCatalogBridge";
|
||||
import { isRuntimeSessionNode, isTopicNode, 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, mergeProjectTopicPage, projectTreeDedupedExactTime, projectTreeEventAffectsFolder, projectTreeFolderDisclosure, projectTreeReadActivityKey, projectTreeRevisionIsFresh, projectTreeShellChildren, projectTreeShouldApplyShellSnapshot, projectTreeShouldRenderTopicActions, projectTreeShouldSuppressOpenForRename, projectTreeTopicArchiveBlocked, projectTreeTopicHasUnreadActivity, projectTreeTopicHoverCardModel, projectTreeTopicMenuOffersPin, projectTreeTopicMetaLine, projectTreeTopicOpenRequest, projectTreeWithoutTopic, topicActivityAt, topicActivityDateLabel, topicActivityLabel, topicIsActive, topicStatus, topicStatusLabel, topicUnknownTimeLabel, type ProjectTreePendingTopicOpen, type ProjectTreeReadActivity, type ProjectTreeTopicHoverCard, type ProjectTreeVariant } from "../lib/projectTreeTopic";
|
||||
export * from "../lib/projectTreeTopic";
|
||||
import type { ProjectNode, SessionCatalogStatus } from "../lib/types";
|
||||
import { topicActivityTime } from "../lib/session";
|
||||
@@ -77,7 +77,7 @@ 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,6 +102,18 @@ function saveReadActivity(readActivity: ProjectTreeReadActivity) {
|
||||
}
|
||||
}
|
||||
|
||||
function loadReadActivityBaselineAt(): number {
|
||||
try {
|
||||
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 {
|
||||
return Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
function loadWorkbenchOrganizeMode(): WorkbenchOrganizeMode {
|
||||
try {
|
||||
const value = localStorage.getItem(WORKBENCH_ORGANIZE_KEY);
|
||||
@@ -398,6 +410,7 @@ export function ProjectTree({
|
||||
const [catalogStatus, setCatalogStatus] = useState<SessionCatalogStatus>({
|
||||
state: "opening", revision: 0, indexed: 0, total: 0, repairPending: 0,
|
||||
});
|
||||
const [indexingDone, setIndexingDone] = useState(false);
|
||||
const [topicPageState, setTopicPageState] = useState<Record<string, { nextCursor?: string; loading: boolean }>>({});
|
||||
const topicPageStateRef = useRef(topicPageState);
|
||||
const updateTopicPageState = useCallback((key: string, next: { nextCursor?: string; loading: boolean }) => {
|
||||
@@ -431,6 +444,7 @@ export function ProjectTree({
|
||||
const [workbenchOrganizeMode, setWorkbenchOrganizeMode] = useState<WorkbenchOrganizeMode>(loadWorkbenchOrganizeMode);
|
||||
const [workbenchSortMode, setWorkbenchSortMode] = useState<WorkbenchSortMode>(loadWorkbenchSortMode);
|
||||
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);
|
||||
@@ -486,6 +500,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;
|
||||
@@ -523,9 +538,10 @@ export function ProjectTree({
|
||||
updateTopicPageState(key, { ...topicPageStateRef.current[key], loading: false });
|
||||
}
|
||||
}, [query, timeFilter, updateTopicPageState]);
|
||||
loadProjectTopicsRef.current = loadProjectTopics;
|
||||
// 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 () => {
|
||||
const refresh = useCallback(async (options?: { reloadTopics?: boolean }) => {
|
||||
try {
|
||||
const snapshot = await app.GetProjectTreeSnapshot();
|
||||
const rev = snapshot.revision ?? 0, empty = treeRef.current.length === 0;
|
||||
@@ -533,10 +549,17 @@ export function ProjectTree({
|
||||
if (projectTreeRevisionIsFresh(latestRevisionRef.current, rev)) latestRevisionRef.current = Math.max(latestRevisionRef.current, rev);
|
||||
const projects = asArray(snapshot.projects);
|
||||
setCatalogStatus(snapshot.catalog);
|
||||
setIndexingDone(Boolean(snapshot.indexingDone));
|
||||
const keepLoadedTopics = !options?.reloadTopics;
|
||||
setTree((current) => projects.map((project) => {
|
||||
const previous = current.find((node) => node.key === project.key);
|
||||
return { ...project, children: asArray(previous?.children) };
|
||||
return { ...project, children: projectTreeShellChildren(previous?.children, { keepLoadedTopics }) };
|
||||
}));
|
||||
if (options?.reloadTopics) {
|
||||
for (const project of projects) {
|
||||
void loadProjectTopicsRef.current(project);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* bridge unavailable */
|
||||
}
|
||||
@@ -587,6 +610,17 @@ export function ProjectTree({
|
||||
return () => clearTimeout(timer);
|
||||
}, [expanded, loadProjectTopics, query, timeFilter, tree]);
|
||||
|
||||
// First catalog scan can finish before the frontend subscribed to v2 events.
|
||||
// Reload expanded folders once indexingDone flips so the metadata fallback
|
||||
// is replaced by the scanned projection without requiring New Chat.
|
||||
useEffect(() => {
|
||||
if (!indexingDone) return;
|
||||
for (const project of treeRef.current) {
|
||||
const key = projectNodeKey(project, 0);
|
||||
if (expanded.has(key)) void loadProjectTopics(project);
|
||||
}
|
||||
}, [indexingDone, expanded, loadProjectTopics]);
|
||||
|
||||
// Following the active topic is a view concern over the tree already held.
|
||||
useEffect(() => {
|
||||
const collapsed = manuallyCollapsedRef.current;
|
||||
@@ -601,52 +635,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) {
|
||||
@@ -914,14 +910,16 @@ export function ProjectTree({
|
||||
const trashTopic = async (topicId: string) => {
|
||||
if (!beginTrashingTopic(topicId)) return;
|
||||
try {
|
||||
setTree((current) => projectTreeWithoutTopic(current, topicId));
|
||||
await app.TrashTopic(topicId);
|
||||
setMenuTopic(null);
|
||||
setMenuPoint(null);
|
||||
setConfirmAction(null);
|
||||
await refresh();
|
||||
await refresh({ reloadTopics: true });
|
||||
await onTopicsChanged?.();
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
await refresh({ reloadTopics: true });
|
||||
} finally {
|
||||
endTrashingTopic(topicId);
|
||||
}
|
||||
@@ -1172,7 +1170,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 +1189,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 +1320,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 +1331,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>
|
||||
|
||||
@@ -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 ?? []) !== "";
|
||||
}
|
||||
@@ -7,7 +7,7 @@ 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";
|
||||
@@ -307,7 +307,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
|
||||
@@ -446,7 +446,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;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,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 +92,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";
|
||||
|
||||
@@ -396,11 +396,11 @@ export function Transcript({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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.
|
||||
// Footer chrome resize only. Item growth stays on followGrowingTail.
|
||||
useEffect(() => {
|
||||
if (items.length > 0 && virtuosoReadyRef.current) followGrowingTail();
|
||||
}, [followGrowingTail, footerHeight, items.length]);
|
||||
if (!virtuosoReadyRef.current || !stick.current) return;
|
||||
scrollToBottom();
|
||||
}, [footerHeight, scrollToBottom, 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.
|
||||
@@ -721,7 +721,9 @@ export function Transcript({
|
||||
components={hasOlderHistory ? TRANSCRIPT_VIRTUOSO_COMPONENTS_WITH_HEADER : TRANSCRIPT_VIRTUOSO_COMPONENTS}
|
||||
computeItemKey={(_index, row) => `${tabId ?? ""}:${String(row.key)}`}
|
||||
firstItemIndex={firstItemIndex}
|
||||
alignToBottom
|
||||
// Do not set alignToBottom: Virtuoso's margin-top:auto plus
|
||||
// firstItemIndex paints a ghost first-user bubble and empty band
|
||||
// in short chats. Tail pin stays followOutput + scrollToBottom.
|
||||
followOutput={(atBottom) => atBottom ? "auto" : false}
|
||||
atBottomThreshold={TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX}
|
||||
atBottomStateChange={atBottomStateChange}
|
||||
|
||||
@@ -84,6 +84,9 @@ import type {
|
||||
PluginInstallOptions,
|
||||
PluginView,
|
||||
ProjectNode,
|
||||
RecoveryLineageView,
|
||||
RecoveryCleanupRequest,
|
||||
RecoveryCleanupResult,
|
||||
SessionCatalogBindings,
|
||||
PromptHistoryEntry,
|
||||
PromptHistoryResult,
|
||||
@@ -207,7 +210,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;
|
||||
@@ -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[]>;
|
||||
@@ -1235,7 +1241,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 })),
|
||||
@@ -2928,7 +2934,7 @@ function makeMockApp(): AppBindings {
|
||||
return {
|
||||
revision: 0,
|
||||
paused: false,
|
||||
recovered: false,
|
||||
recovered: false, sessionPath: "",
|
||||
items: [],
|
||||
itemsCount: 0,
|
||||
bytes: 0,
|
||||
@@ -3324,6 +3330,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 +3485,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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Model-facing Auto Guard policy. Must not render as a user-side steer. */
|
||||
const HOST_RECOVERY_PREFIXES = [
|
||||
"A tool failed. Use read-only diagnosis as needed",
|
||||
"The tool timed out or hit a transient execution limit.",
|
||||
];
|
||||
|
||||
export function isHostRecoveryGuidance(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
const body = trimmed.startsWith("↪ ") ? trimmed.slice("↪ ".length).trimStart() : trimmed;
|
||||
return HOST_RECOVERY_PREFIXES.some((prefix) => body.startsWith(prefix));
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/** Live-turn markers that a lagging history snapshot must not replace. */
|
||||
export type HydrateLiveState = {
|
||||
running?: boolean;
|
||||
turnActive?: boolean;
|
||||
live?: unknown;
|
||||
currentAssistant?: unknown;
|
||||
pendingUser?: unknown;
|
||||
historyTotalTurns?: number;
|
||||
items: ReadonlyArray<{ kind: string; streaming?: boolean; status?: string }>;
|
||||
historyRevision?: number;
|
||||
historyDigest?: string;
|
||||
};
|
||||
|
||||
export type HydratedHistoryApplyMode = "replace" | "prepend" | "skip";
|
||||
|
||||
export type HydrateProjection = {
|
||||
items: ReadonlyArray<unknown>;
|
||||
revision?: number;
|
||||
digest?: string;
|
||||
};
|
||||
|
||||
export function shouldPreferResidentHistory(reset: boolean, preserveCachedHistory?: boolean): boolean {
|
||||
return !reset && preserveCachedHistory !== false;
|
||||
}
|
||||
|
||||
function sameHydrateFingerprint(state: HydrateLiveState | undefined, projection: HydrateProjection | undefined): boolean {
|
||||
if (!state || !projection) return false;
|
||||
const revision = projection.revision ?? 0;
|
||||
const digest = (projection.digest ?? "").trim();
|
||||
if (revision > 0 && state.historyRevision === revision) return true;
|
||||
if (digest !== "" && (state.historyDigest ?? "") === digest) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isStaleResidentProjection(
|
||||
state: HydrateLiveState | undefined,
|
||||
projection: HydrateProjection | undefined,
|
||||
): boolean {
|
||||
if (!state || !projection || state.items.length === 0) return false;
|
||||
if (projection.items.length >= state.items.length) return false;
|
||||
return sameHydrateFingerprint(state, projection);
|
||||
}
|
||||
|
||||
// A live turn is only "cached" once a history page has landed behind it.
|
||||
// Without that, a session opened mid-stream reports a cached turn, skips the
|
||||
// fetch, and streams over a blank transcript.
|
||||
export function hasCachedLiveTurn(state: HydrateLiveState | undefined): boolean {
|
||||
if (!state?.running && !state?.turnActive) return false;
|
||||
if ((state.historyTotalTurns ?? 0) === 0) return false;
|
||||
if (state.live || state.currentAssistant || state.pendingUser !== undefined) return true;
|
||||
return state.items.some((item) =>
|
||||
(item.kind === "assistant" && item.streaming) ||
|
||||
(item.kind === "tool" && item.status === "running"),
|
||||
);
|
||||
}
|
||||
|
||||
// An empty surface has to apply history or switch-back shows Welcome. A turn
|
||||
// that has already streamed rows keeps them — but a tab with no history page
|
||||
// behind it still gets one, prepended, instead of a blank transcript above the
|
||||
// live turn. Only an already-hydrated live turn is left alone. An idle
|
||||
// same-fingerprint resident page that is shorter than the visible transcript
|
||||
// is skipped so Retry/clear cannot roll the chat back.
|
||||
export function hydratedHistoryApplyMode(
|
||||
skipHistory: boolean,
|
||||
hasProjection: boolean,
|
||||
foregroundTurnActive: boolean,
|
||||
state: HydrateLiveState | undefined,
|
||||
projection?: HydrateProjection,
|
||||
): HydratedHistoryApplyMode {
|
||||
if (skipHistory || !hasProjection) return "skip";
|
||||
if (!foregroundTurnActive) return isStaleResidentProjection(state, projection) ? "skip" : "replace";
|
||||
if ((state?.items.length ?? 0) === 0 && !hasCachedLiveTurn(state)) return "replace";
|
||||
return (state?.historyTotalTurns ?? 0) === 0 ? "prepend" : "skip";
|
||||
}
|
||||
|
||||
type SignatureItem = {
|
||||
kind: string;
|
||||
id: string;
|
||||
text?: string;
|
||||
reasoning?: string;
|
||||
name?: string;
|
||||
level?: string;
|
||||
trigger?: string;
|
||||
messages?: number;
|
||||
surfaceKey?: string;
|
||||
generation?: number;
|
||||
};
|
||||
|
||||
function itemSignature(item: SignatureItem): string {
|
||||
switch (item.kind) {
|
||||
case "tool": return `tool|${item.id}|${item.name ?? ""}`;
|
||||
case "extension": return `extension|${item.surfaceKey ?? ""}|${item.generation ?? 0}`;
|
||||
case "compaction": return `compaction|${item.trigger ?? ""}|${item.messages ?? 0}`;
|
||||
default: return `${item.kind}|${item.level ?? ""}|${item.text ?? ""}|${item.reasoning ?? ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
// A page read while its turn is live can already carry rows the live stream
|
||||
// rendered. Only a suffix of the page can overlap a prefix of the live rows, so
|
||||
// the longest such match is the duplicate set.
|
||||
export function duplicateLiveItemIds(
|
||||
pageItems: readonly SignatureItem[],
|
||||
liveItems: readonly SignatureItem[],
|
||||
): string[] {
|
||||
for (let k = Math.min(pageItems.length, liveItems.length); k > 0; k -= 1) {
|
||||
let same = true;
|
||||
for (let i = 0; i < k && same; i += 1) {
|
||||
same = itemSignature(pageItems[pageItems.length - k + i]) === itemSignature(liveItems[i]);
|
||||
}
|
||||
if (same) return liveItems.slice(0, k).map((item) => item.id);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function sameSessionPlaceholderItems<T>(
|
||||
targetSessionPath: string | undefined,
|
||||
prev: { meta?: { sessionPath?: string }; items?: T[] } | undefined,
|
||||
): T[] | undefined {
|
||||
const target = (targetSessionPath ?? "").trim();
|
||||
const current = (prev?.meta?.sessionPath ?? "").trim();
|
||||
if (!target || !current || target !== current) return undefined;
|
||||
return prev?.items;
|
||||
}
|
||||
@@ -116,6 +116,11 @@ export function inboxSteerQueuedMessage(locale: Locale): string {
|
||||
return ERROR_COPY[locale][STEER_QUEUED_INDEX];
|
||||
}
|
||||
|
||||
export function isInboxItemMissing(error: unknown): boolean {
|
||||
const raw = errorText(error);
|
||||
return raw === `${CODE_PREFIX}inbox_item_not_found` || raw === "inbox item not found";
|
||||
}
|
||||
|
||||
export function formatInboxCancelError(error: unknown, locale: Locale): string {
|
||||
return ERROR_COPY[locale][CANCEL_FAILED_INDEX].replace("{error}", formatInboxError(error, locale));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function onInboxChanged(cb: (tabId: string) => void): () => void {
|
||||
if (typeof window !== "undefined" && window.runtime && window.go?.main?.App) {
|
||||
return window.runtime.EventsOn("InboxChanged", (payload?: unknown) => {
|
||||
const tabId = payload && typeof payload === "object" && "tabId" in payload
|
||||
? String((payload as { tabId?: unknown }).tabId ?? "")
|
||||
: "";
|
||||
cb(tabId);
|
||||
});
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AppBindings } from "./bridge";
|
||||
import type { StructuredInvocationSubmit } from "./invocationDisplay";
|
||||
|
||||
type InboxEnqueueBindings = Pick<AppBindings, "EnqueueInboxFollowup" | "EnqueueInboxFollowupWithInvocations">;
|
||||
type InboxEnqueueBindings = Pick<AppBindings, "EnqueueInboxFollowup" | "EnqueueInboxFollowupWithInvocations" | "EnqueueInboxSteer">;
|
||||
|
||||
export function enqueueInboxGuidance(
|
||||
binding: InboxEnqueueBindings,
|
||||
@@ -9,6 +9,7 @@ export function enqueueInboxGuidance(
|
||||
display: string,
|
||||
submit: string,
|
||||
structured?: StructuredInvocationSubmit,
|
||||
opts?: { steer?: boolean },
|
||||
) {
|
||||
if (structured) {
|
||||
return binding.EnqueueInboxFollowupWithInvocations(
|
||||
@@ -19,5 +20,8 @@ export function enqueueInboxGuidance(
|
||||
"",
|
||||
);
|
||||
}
|
||||
if (opts?.steer && typeof binding.EnqueueInboxSteer === "function") {
|
||||
return binding.EnqueueInboxSteer(tabId, display, submit || display, "");
|
||||
}
|
||||
return binding.EnqueueInboxFollowup(tabId, display, submit || display, "");
|
||||
}
|
||||
@@ -44,6 +44,25 @@ export function mergeProjectTopicPage(current: ProjectNode[], incoming: ProjectN
|
||||
return next;
|
||||
}
|
||||
|
||||
// After archive, drop that topic immediately so a shell-only refresh cannot
|
||||
// resurrect it from the previously loaded children.
|
||||
export function projectTreeWithoutTopic(tree: ProjectNode[], topicId: string): ProjectNode[] {
|
||||
const id = topicId.trim();
|
||||
if (!id) return tree;
|
||||
return tree.map((node) => {
|
||||
if (node.kind !== "project" && node.kind !== "global_folder") return node;
|
||||
return { ...node, children: asArray(node.children).filter((child) => child.topicId !== id) };
|
||||
});
|
||||
}
|
||||
|
||||
export function projectTreeShellChildren(
|
||||
previous: ProjectNode[] | undefined,
|
||||
options: { keepLoadedTopics: boolean },
|
||||
): ProjectNode[] {
|
||||
if (options.keepLoadedTopics) return asArray(previous);
|
||||
return [];
|
||||
}
|
||||
|
||||
export function projectTreeEventAffectsFolder(project: ProjectNode, roots: string[]): boolean {
|
||||
if (roots.length === 0) return true;
|
||||
const root = project.kind === "global_folder" ? "" : project.root ?? "";
|
||||
@@ -105,16 +124,21 @@ export function projectTreeFolderDisclosure(hasChildren: boolean, isExpanded: bo
|
||||
};
|
||||
}
|
||||
|
||||
function topicMatchesActiveIdentity(node: ProjectNode, activeScope?: string, activeWorkspaceRoot?: string, activeTopicId?: string): boolean {
|
||||
if (!node.topicId || !activeTopicId) return false;
|
||||
const scope = node.kind === "global_topic" || node.kind === "global_session" ? "global" : "project";
|
||||
if (scope === "global") return activeScope === "global" && activeTopicId === node.topicId;
|
||||
return activeScope === "project" && activeTopicId === node.topicId && activeWorkspaceRoot === node.root;
|
||||
}
|
||||
|
||||
export function topicIsActive(node: ProjectNode, activeScope?: string, activeWorkspaceRoot?: string, activeTopicId?: string, activeSessionPath?: string): boolean {
|
||||
if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false;
|
||||
if (node.sessionPath) return Boolean(activeSessionPath && activeSessionPath === node.sessionPath);
|
||||
if (isRuntimeSessionNode(node)) {
|
||||
return Boolean(node.sessionPath && activeSessionPath && activeSessionPath === node.sessionPath);
|
||||
}
|
||||
if (!isTopicNode(node)) return false;
|
||||
if (activeSessionPath && asArray(node.children).some(isRuntimeSessionNode)) return false;
|
||||
const scope = node.kind === "global_topic" ? "global" : "project";
|
||||
return (
|
||||
activeTopicId === node.topicId &&
|
||||
activeScope === scope &&
|
||||
(scope === "global" || activeWorkspaceRoot === node.root)
|
||||
);
|
||||
if (topicMatchesActiveIdentity(node, activeScope, activeWorkspaceRoot, activeTopicId)) return true;
|
||||
return Boolean(node.sessionPath && activeSessionPath && activeSessionPath === node.sessionPath);
|
||||
}
|
||||
|
||||
export function projectTreeTopicMetaLine(node: ProjectNode, t: Translator, compact = false): string {
|
||||
@@ -167,33 +191,33 @@ const topicStatusLabels: Record<ProjectTopicStatus, DictKey> = {
|
||||
waiting_confirmation: "projectTree.status.waitingConfirmation",
|
||||
background_job: "projectTree.status.backgroundJob",
|
||||
paused: "projectTree.status.paused",
|
||||
awaiting_delivery: "projectTree.status.awaitingDelivery",
|
||||
error: "projectTree.status.error",
|
||||
diverged_recovery: "projectTree.status.divergedRecovery",
|
||||
};
|
||||
|
||||
export function normalizeTopicStatus(status?: string): ProjectTopicStatus | "" {
|
||||
if (!status) return "";
|
||||
if (status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job" || status === "paused" || status === "error" || status === "diverged_recovery") {
|
||||
if (status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job" || status === "paused" || status === "awaiting_delivery" || status === "error" || status === "diverged_recovery") {
|
||||
return status;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function topicStatus(node: ProjectNode): ProjectTopicStatus | "" {
|
||||
// Active runtime states take priority over the informational diverged-recovery
|
||||
// marker so a topic that is simultaneously running and has unresolved branches
|
||||
// surfaces the live state first.
|
||||
// Ordinary list never surfaces recovery-branch status. Active runtime states
|
||||
// only: thinking/streaming/waiting/etc. History owns other saved versions.
|
||||
const live = node.running ? "streaming" : "";
|
||||
const stored = normalizeTopicStatus(node.status);
|
||||
if (stored && stored !== "diverged_recovery") return stored;
|
||||
return live || stored;
|
||||
return live;
|
||||
}
|
||||
|
||||
export function projectTreeTopicArchiveBlocked(node: ProjectNode): boolean {
|
||||
if (asArray(node.children).some(projectTreeTopicArchiveBlocked)) return true;
|
||||
const status = normalizeTopicStatus(node.status);
|
||||
if (status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job") return true;
|
||||
if (status === "paused" || status === "error" || status === "diverged_recovery") return false;
|
||||
if (status === "paused" || status === "awaiting_delivery" || status === "error" || status === "diverged_recovery") return false;
|
||||
return Boolean(node.running);
|
||||
}
|
||||
|
||||
@@ -209,12 +233,7 @@ export function topicActivityAt(node: ProjectNode): number {
|
||||
export function projectTreeReadActivityKey(node: ProjectNode): string | null {
|
||||
const request = projectTreeTopicOpenRequest(node);
|
||||
if (!request?.topicId) return null;
|
||||
return [
|
||||
request.scope,
|
||||
request.workspaceRoot,
|
||||
request.topicId,
|
||||
request.sessionPath ?? "",
|
||||
].join("\u001f");
|
||||
return [request.scope, request.workspaceRoot, request.topicId].join("\u001f");
|
||||
}
|
||||
|
||||
export type ProjectTreeReadActivity = Record<string, number>;
|
||||
@@ -226,13 +245,16 @@ export function projectTreeTopicHasUnreadActivity(
|
||||
activeWorkspaceRoot?: string,
|
||||
activeTopicId?: string,
|
||||
activeSessionPath?: string,
|
||||
baselineAt = 0,
|
||||
): boolean {
|
||||
if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false;
|
||||
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) return false;
|
||||
if (topicMatchesActiveIdentity(node, activeScope, activeWorkspaceRoot, activeTopicId)) return false;
|
||||
if (topicStatus(node) !== "") return false;
|
||||
const key = projectTreeReadActivityKey(node);
|
||||
const activityAt = topicActivityAt(node);
|
||||
return Boolean(key && activityAt > 0 && (readActivity[key] ?? 0) < activityAt);
|
||||
if (!key || activityAt <= 0) return false;
|
||||
return Math.max(readActivity[key] ?? 0, baselineAt) < activityAt;
|
||||
}
|
||||
|
||||
export function projectTreeShouldRenderTopicActions(isSessionNode: boolean, variant: ProjectTreeVariant, unread: boolean): boolean {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
export type SearchSource = {
|
||||
title?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export interface HistoryServerSearch {
|
||||
id: string;
|
||||
query?: string;
|
||||
results?: { title?: string; url?: string }[];
|
||||
}
|
||||
|
||||
export function isSafeHttpUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sourceKey(source: SearchSource): string {
|
||||
return `${source.url ?? ""}\n${source.title ?? ""}`;
|
||||
}
|
||||
|
||||
export function mergeSearchSources(dst: SearchSource[] | undefined, add: SearchSource[]): SearchSource[] {
|
||||
const out = dst ? dst.slice() : [];
|
||||
const seen = new Set(out.map(sourceKey));
|
||||
for (const hit of add) {
|
||||
if (!hit.title && !hit.url) continue;
|
||||
const key = sourceKey(hit);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({ title: hit.title, url: hit.url });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function searchSourcesFromHistory(searches: { results?: { title?: string; url?: string }[] }[] | undefined): SearchSource[] {
|
||||
const add: SearchSource[] = [];
|
||||
for (const search of searches ?? []) {
|
||||
for (const hit of search.results ?? []) {
|
||||
if (hit.title || hit.url) add.push({ title: hit.title, url: hit.url });
|
||||
}
|
||||
}
|
||||
return mergeSearchSources(undefined, add);
|
||||
}
|
||||
|
||||
export function parseSearchSources(output: string): SearchSource[] {
|
||||
const lines = output.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
const out: SearchSource[] = [];
|
||||
for (const line of lines) {
|
||||
if (/^https?:\/\//i.test(line)) {
|
||||
const last = out[out.length - 1];
|
||||
if (last && !last.url) last.url = line;
|
||||
else out.push({ url: line });
|
||||
continue;
|
||||
}
|
||||
out.push({ title: line });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Same title + autolink list the old answer dump used, rendered after the reply. */
|
||||
export function formatSearchFootnotesMarkdown(sources: SearchSource[]): string {
|
||||
const lines: string[] = [];
|
||||
for (const source of sources) {
|
||||
if (!source.title && !source.url) continue;
|
||||
lines.push(`- **${source.title ?? ""}**`);
|
||||
if (source.url && isSafeHttpUrl(source.url)) lines.push(` <${source.url}>`);
|
||||
}
|
||||
return lines.length > 0 ? `\n${lines.join("\n")}\n` : "";
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { mergeSearchSources, parseSearchSources, searchSourcesFromHistory, type SearchSource } from "./searchSources";
|
||||
import type { Item } from "./useController";
|
||||
|
||||
type SearchState = {
|
||||
currentAssistant?: string;
|
||||
pendingSearchSources?: SearchSource[];
|
||||
items: Item[];
|
||||
};
|
||||
|
||||
export function historySearchCards(
|
||||
searches: { id?: string; query?: string; results?: { title?: string; url?: string }[] }[] | undefined,
|
||||
): Extract<Item, { kind: "tool" }>[] {
|
||||
const cards: Extract<Item, { kind: "tool" }>[] = [];
|
||||
for (const search of searches ?? []) {
|
||||
if (!search.id) continue;
|
||||
const lines = (search.results ?? []).flatMap((hit) => [hit.title, hit.url].filter(Boolean));
|
||||
cards.push({
|
||||
kind: "tool",
|
||||
id: search.id,
|
||||
name: "web_search",
|
||||
args: search.query ? JSON.stringify({ query: search.query }) : "",
|
||||
readOnly: true,
|
||||
status: "done",
|
||||
output: lines.join("\n"),
|
||||
});
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
export function historySearchSources(
|
||||
searches: { results?: { title?: string; url?: string }[] }[] | undefined,
|
||||
): SearchSource[] | undefined {
|
||||
const sources = searchSourcesFromHistory(searches);
|
||||
return sources.length > 0 ? sources : undefined;
|
||||
}
|
||||
|
||||
export function historySearchAndAnswer(
|
||||
id: string,
|
||||
m: { content: string; reasoning?: string; workDurationMs?: number; memoryCitations?: Extract<Item, { kind: "assistant" }>["memoryCitations"]; serverSearch?: { id?: string; query?: string; results?: { title?: string; url?: string }[] }[] },
|
||||
): Item[] {
|
||||
const out: Item[] = historySearchCards(m.serverSearch);
|
||||
const searchSources = historySearchSources(m.serverSearch);
|
||||
if (m.content.trim() !== "" || (m.reasoning ?? "").trim() !== "" || searchSources) {
|
||||
out.push({
|
||||
kind: "assistant",
|
||||
id,
|
||||
text: m.content,
|
||||
reasoning: m.reasoning ?? "",
|
||||
streaming: false,
|
||||
workDurationMs: m.workDurationMs,
|
||||
memoryCitations: m.memoryCitations,
|
||||
searchSources,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function attachSearchSources<T extends SearchState>(s: T, sources: SearchSource[]): T {
|
||||
if (sources.length === 0) return s;
|
||||
const pendingSearchSources = mergeSearchSources(s.pendingSearchSources, sources);
|
||||
if (!s.currentAssistant) return { ...s, pendingSearchSources };
|
||||
return {
|
||||
...s,
|
||||
pendingSearchSources,
|
||||
items: s.items.map((it) =>
|
||||
it.kind === "assistant" && it.id === s.currentAssistant
|
||||
? { ...it, searchSources: mergeSearchSources(it.searchSources, sources) }
|
||||
: it,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function isBatchedReadOnlyTool(name: string, readOnly: boolean): boolean {
|
||||
return readOnly && name !== "todo_write" && name !== "web_search";
|
||||
}
|
||||
|
||||
export function attachWebSearchOutput<T extends SearchState>(s: T, name: string, output?: string, err?: string): T {
|
||||
if (name !== "web_search" || !output || err) return s;
|
||||
return attachSearchSources(s, parseSearchSources(output));
|
||||
}
|
||||
@@ -95,10 +95,21 @@ export function shouldShowTodoPanel(
|
||||
todoKey: string | null | undefined,
|
||||
dismissedTodoKey: string | null,
|
||||
todos: Todo[],
|
||||
persisted?: { batchKey?: string | null; batches?: readonly string[] | null },
|
||||
): boolean {
|
||||
if (!todoKey || todos.length === 0) return false;
|
||||
if (hasIncompleteTodos(todos)) return true;
|
||||
return todoKey !== dismissedTodoKey;
|
||||
if (todoKey === dismissedTodoKey) return false;
|
||||
const batchKey = String(persisted?.batchKey ?? "").trim();
|
||||
if (batchKey && persisted?.batches?.includes(batchKey)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function sameStringList(a?: readonly string[] | null, b?: readonly string[] | null): boolean {
|
||||
if (a === b) return true;
|
||||
const left = Array.isArray(a) ? a : [];
|
||||
const right = Array.isArray(b) ? b : [];
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
export function shouldOpenTodoPanelByDefault(): boolean {
|
||||
|
||||
@@ -64,6 +64,8 @@ export function subjectOf(name: string, args: string): string {
|
||||
return str(a, "pattern") || str(a, "path");
|
||||
case "web_fetch":
|
||||
return str(a, "url");
|
||||
case "web_search":
|
||||
return str(a, "query");
|
||||
case "task":
|
||||
return str(a, "description") || str(a, "prompt");
|
||||
case "run_skill":
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
// auto-close on completion (unless the user toggled or the preference is
|
||||
// "expanded"), and preference switches applying to folds already on screen.
|
||||
|
||||
import { isSteerNoticeText, type ExtensionItem, type Item } from "./useController";
|
||||
import { isHostRecoveryGuidance } from "./hostRecoverySteer";
|
||||
import { isBatchedReadOnlyTool, isSteerNoticeText, type ExtensionItem, type Item } from "./useController";
|
||||
import { appendTurnActionCopyText } from "./turnActionCopy";
|
||||
import { isCreationGroupableTool, toolGroupKind, type ToolGroupKind } from "../components/ToolGroup";
|
||||
import type { ProcessFoldPreference } from "./processFoldPreference";
|
||||
@@ -79,6 +80,9 @@ export function partitionTurnItems(items: readonly Item[], live: TranscriptLiveF
|
||||
for (const item of items) {
|
||||
if (item.kind === "user") continue;
|
||||
if (item.kind === "notice") {
|
||||
if (isHostRecoveryGuidance(item.text)) {
|
||||
continue;
|
||||
}
|
||||
if (isSteerNoticeText(item.text)) {
|
||||
current.outsideItems.push(item);
|
||||
currentHasConversation = true;
|
||||
@@ -428,7 +432,7 @@ function processBodyRows(segment: SegmentModel, creationMode: boolean): Transcri
|
||||
flushToolBatch();
|
||||
flushRO();
|
||||
}
|
||||
if (!creationMode && it.kind === "tool" && it.status !== "running" && it.readOnly) {
|
||||
if (!creationMode && it.kind === "tool" && it.status !== "running" && isBatchedReadOnlyTool(it.name, it.readOnly)) {
|
||||
roBatch.push(it as ToolItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { app } from "./bridge";
|
||||
import { noteHistoryPage, registerTranscriptCacheDiagnostics } from "./sessionDiagnostics";
|
||||
import { TranscriptMarkdownCache, type ParsedMarkdownValue } from "./transcriptMarkdownCache";
|
||||
export type { ParsedMarkdownValue } from "./transcriptMarkdownCache";
|
||||
import { historySearchAndAnswer } from "./searchTranscript";
|
||||
import { fileDiffFromWire, summarizeFileDiff } from "./tools";
|
||||
import {
|
||||
historyToolError,
|
||||
@@ -278,19 +279,14 @@ function convertRecord(
|
||||
}
|
||||
|
||||
if (m.role === "assistant") {
|
||||
const hasText = m.content.trim() !== "" || (m.reasoning ?? "").trim() !== "";
|
||||
if (hasText) {
|
||||
const memoryCitations = asArray<MemoryCitation>(m.memoryCitations);
|
||||
items.push({
|
||||
kind: "assistant",
|
||||
id,
|
||||
text: m.content,
|
||||
reasoning: m.reasoning ?? "",
|
||||
streaming: false,
|
||||
workDurationMs: m.workDurationMs,
|
||||
memoryCitations: memoryCitations.length > 0 ? memoryCitations : undefined,
|
||||
});
|
||||
}
|
||||
const memoryCitations = asArray<MemoryCitation>(m.memoryCitations);
|
||||
items.push(...historySearchAndAnswer(id, {
|
||||
content: m.content,
|
||||
reasoning: m.reasoning,
|
||||
workDurationMs: m.workDurationMs,
|
||||
memoryCitations: memoryCitations.length > 0 ? memoryCitations : undefined,
|
||||
serverSearch: m.serverSearch,
|
||||
}));
|
||||
const toolCalls = m.toolCalls ?? [];
|
||||
// Positional scan cursor: id-less calls consume the following unconsumed
|
||||
// id-less tool rows in order, stopping at the first non-tool record —
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Wire contract — mirrors desktop/wire.go (itself mirroring internal/serve/wire.go).
|
||||
// One event channel carries every kind; `kind` discriminates the payload.
|
||||
import type { HistoryServerSearch } from "./searchSources";
|
||||
import type { Todo } from "./tools";
|
||||
import type { ContextMaintenanceInfo, WireContextMaintenance } from "./contextMaintenanceTypes";
|
||||
export type { ContextMaintenanceInfo, ContextMaintenanceReceipt, WireContextMaintenance } from "./contextMaintenanceTypes";
|
||||
@@ -512,10 +513,61 @@ export interface ProjectNode {
|
||||
recoveryReason?: string;
|
||||
recoveryDigest?: string;
|
||||
recoveryParentId?: string;
|
||||
recoveryState?: "normal" | "repairing" | "adopted" | "preferred" | "diverged" | "recovery_only" | string;
|
||||
recoveryBranchCount?: number;
|
||||
recoveryUnresolvedCount?: number;
|
||||
recoveryCleanupEligibleCount?: number;
|
||||
isolatedWorktree?: boolean;
|
||||
children?: ProjectNode[];
|
||||
}
|
||||
|
||||
export interface RecoveryLineageMember {
|
||||
path: string;
|
||||
role: "normal" | "covered_copy" | "adopted" | "preferred" | "diverged" | string;
|
||||
canonical: boolean;
|
||||
turns: number;
|
||||
open: boolean;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export interface RecoveryLineageView {
|
||||
groupId: string;
|
||||
state: string;
|
||||
branchCount: number;
|
||||
unresolved: number;
|
||||
cleanupEligible: number;
|
||||
members: RecoveryLineageMember[];
|
||||
}
|
||||
|
||||
export interface RecoveryCleanupRequest {
|
||||
scope: string;
|
||||
workspaceRoot?: string;
|
||||
topicId: string;
|
||||
apply: boolean;
|
||||
}
|
||||
|
||||
export interface RecoveryPreferenceRequest {
|
||||
scope: string;
|
||||
workspaceRoot?: string;
|
||||
topicId: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface RecoveryCleanupItem {
|
||||
path: string;
|
||||
status: "eligible" | "moved" | "busy" | "kept" | string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RecoveryCleanupResult {
|
||||
eligible: number;
|
||||
moved: number;
|
||||
busy: number;
|
||||
kept: number;
|
||||
dryRun: boolean;
|
||||
items: RecoveryCleanupItem[];
|
||||
}
|
||||
|
||||
export interface DeliveryWorktreeAvailability {
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
@@ -533,7 +585,7 @@ export interface DeliveryWorktreeOpenResult {
|
||||
tab: TabMeta;
|
||||
}
|
||||
|
||||
export type ProjectTopicStatus = "thinking" | "streaming" | "waiting_confirmation" | "background_job" | "paused" | "error" | "diverged_recovery";
|
||||
export type ProjectTopicStatus = "thinking" | "streaming" | "waiting_confirmation" | "background_job" | "paused" | "awaiting_delivery" | "error" | "diverged_recovery";
|
||||
|
||||
export interface TopicMeta {
|
||||
id: string;
|
||||
@@ -646,6 +698,7 @@ export interface HistoryMessage {
|
||||
summary?: string;
|
||||
archive?: string;
|
||||
decisionReceipt?: WireDecisionReceipt;
|
||||
serverSearch?: HistoryServerSearch[];
|
||||
}
|
||||
|
||||
export interface HistoryToolCall {
|
||||
@@ -884,7 +937,7 @@ export interface Meta {
|
||||
goal?: string;
|
||||
goalStatus?: GoalStatus;
|
||||
goalRuntime?: GoalRuntime;
|
||||
canonicalTodos?: Todo[];
|
||||
canonicalTodos?: Todo[]; dismissedTodoBatches?: string[];
|
||||
}
|
||||
|
||||
export type CollaborationMode = "normal" | "plan" | "goal";
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { app } from "./bridge";
|
||||
import {
|
||||
guidanceFromInboxSnapshot,
|
||||
hydrateEmptyGuidancePreviews,
|
||||
inboxSnapshotBelongsToScope,
|
||||
localGuidanceFallback,
|
||||
mergeGuidanceSnapshot,
|
||||
} from "./composerInboxQueue";
|
||||
import { onInboxChanged } from "./inboxEvents";
|
||||
import type { PendingGuidance } from "../components/ComposerGuidanceShelf";
|
||||
|
||||
export function useComposerInboxRefresh(
|
||||
tabId: string | undefined,
|
||||
draftKey: string,
|
||||
guidanceDraftKey: string,
|
||||
inboxSessionKey: string,
|
||||
previewKey: string,
|
||||
retryNonce: number,
|
||||
running: boolean,
|
||||
applyQueue: (items: PendingGuidance[]) => void,
|
||||
collapse: () => void,
|
||||
bump: () => void,
|
||||
) {
|
||||
const inboxSessionKeyRef = useRef(inboxSessionKey);
|
||||
const clearQueue = useCallback(() => applyQueue([]), [applyQueue]);
|
||||
useLayoutEffect(() => {
|
||||
if (inboxSessionKeyRef.current === inboxSessionKey) return;
|
||||
inboxSessionKeyRef.current = inboxSessionKey;
|
||||
clearQueue();
|
||||
}, [draftKey, inboxSessionKey, clearQueue]);
|
||||
useEffect(() => onInboxChanged((changedTab) => {
|
||||
if (changedTab && tabId && changedTab !== tabId) return;
|
||||
bump();
|
||||
}), [tabId, bump]);
|
||||
useEffect(() => {
|
||||
if (guidanceDraftKey !== draftKey) return;
|
||||
let live = true;
|
||||
const fallback = localGuidanceFallback(previewKey);
|
||||
if (typeof app.InboxSnapshot !== "function") {
|
||||
applyQueue(fallback);
|
||||
collapse();
|
||||
return;
|
||||
}
|
||||
void app.InboxSnapshot(tabId || "").then((snap) => {
|
||||
if (!live || !inboxSnapshotBelongsToScope(snap?.sessionPath, inboxSessionKey)) return;
|
||||
const durable = guidanceFromInboxSnapshot(snap);
|
||||
applyQueue(mergeGuidanceSnapshot(durable, fallback));
|
||||
collapse();
|
||||
void hydrateEmptyGuidancePreviews(durable, (id) => app.ReadInboxItem(tabId || "", id)).then((hydrated) => {
|
||||
if (live && hydrated.some((item) => item.text.trim())) {
|
||||
applyQueue(mergeGuidanceSnapshot(hydrated, fallback));
|
||||
}
|
||||
});
|
||||
}).catch(() => {
|
||||
if (live) applyQueue(localGuidanceFallback(previewKey));
|
||||
});
|
||||
return () => { live = false; };
|
||||
}, [draftKey, guidanceDraftKey, previewKey, running, tabId, retryNonce, inboxSessionKey, applyQueue, collapse]);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { asArray } from "./array";
|
||||
import { addBreadcrumb } from "./breadcrumbs";
|
||||
import { app, onEvent, onReady, onRuntimeRebuilt, onTabMeta, onTopicActivation } from "./bridge";
|
||||
import { invalidateCache } from "./composerHistory";
|
||||
import { formatInboxCancelError, inboxSteerQueuedMessage } from "./inboxError";
|
||||
import { formatInboxCancelError } from "./inboxError";
|
||||
import { formatContextMaintenanceNotice, isNewMaintenanceOperation, rememberMaintenanceOperation } from "./contextMaintenanceTypes";
|
||||
import { formatGuardianAssessmentNotice } from "./guardianEvents";
|
||||
import { completionSummaryNeedsAttention, completionSummaryNotice, normalizeCompletionSummary } from "./completionSummary";
|
||||
@@ -18,8 +18,12 @@ import { getTranscriptStore } from "./transcriptStore";
|
||||
import { uiPerfTracker } from "./uiPerf";
|
||||
import { getLocale, t, type DictKey } from "./i18n";
|
||||
import { applyHydrateErrorState, hydratePlaceholderItems as resolveHydratePlaceholders } from "./hydrateErrorState";
|
||||
import { isHostRecoveryGuidance } from "./hostRecoverySteer";
|
||||
import { duplicateLiveItemIds, hasCachedLiveTurn, hydratedHistoryApplyMode, sameSessionPlaceholderItems, shouldPreferResidentHistory } from "./hydrateHistoryApply";
|
||||
import { hydrateIdentityCurrent } from "./sessionIdentity";
|
||||
import { sameTodoList } from "./todoVisibility";
|
||||
import { sameStringList, sameTodoList } from "./todoVisibility";
|
||||
import type { SearchSource } from "./searchSources";
|
||||
import { attachWebSearchOutput, historySearchAndAnswer } from "./searchTranscript";
|
||||
import { fileDiffFromWire, summarize, summarizeFileDiff, type ToolFileDiff } from "./tools";
|
||||
import { modeHasAutoApproveTools, normalizeMode, normalizeToolApprovalMode } from "./types";
|
||||
import type {
|
||||
@@ -227,7 +231,7 @@ const HISTORY_PAGE_TURNS = 60;
|
||||
export type TurnPhaseName = "working" | "checking" | "verifying" | "reviewing" | string;
|
||||
export type Item =
|
||||
| { kind: "user"; id: string; submissionId?: string; text: string; submitText?: string; failed?: boolean; createdAt?: number; checkpointTurn?: number }
|
||||
| { kind: "assistant"; id: string; text: string; reasoning: string; streaming: boolean; reasoningComplete?: boolean; reasoningDurationMs?: number; workDurationMs?: number; memoryCitations?: MemoryCitation[] }
|
||||
| { kind: "assistant"; id: string; text: string; reasoning: string; streaming: boolean; reasoningComplete?: boolean; reasoningDurationMs?: number; workDurationMs?: number; memoryCitations?: MemoryCitation[]; searchSources?: SearchSource[] }
|
||||
| { kind: "phase"; id: string; text: string }
|
||||
| { kind: "notice"; id: string; level: "info" | "warn"; text: string; detail?: string; title?: string; variant?: "delivery" | "completion"; action?: "continue_delivery" | "open_changes"; decisionReceipt?: WireDecisionReceipt }
|
||||
| {
|
||||
@@ -358,6 +362,7 @@ interface State {
|
||||
backendActivationPending: boolean;
|
||||
messageAction?: MessageActionState;
|
||||
currentAssistant?: string;
|
||||
pendingSearchSources?: SearchSource[];
|
||||
live?: LiveStream;
|
||||
pendingUser?: string;
|
||||
pendingSubmissionId?: string;
|
||||
@@ -588,7 +593,7 @@ export function metaFromTab(tab: TabMeta, existing?: Meta): Meta {
|
||||
tokenMode: tab.tokenMode ?? existing?.tokenMode ?? "full",
|
||||
goal: tab.goal ?? existing?.goal,
|
||||
goalStatus: tab.goalStatus ?? existing?.goalStatus,
|
||||
canonicalTodos: existing?.canonicalTodos,
|
||||
canonicalTodos: existing?.canonicalTodos, dismissedTodoBatches: (tab.sessionPath !== undefined ? tab.sessionPath : existing?.sessionPath) === existing?.sessionPath ? existing?.dismissedTodoBatches : undefined,
|
||||
};
|
||||
}
|
||||
function countsTowardCurrentTurn(state: State): boolean {
|
||||
@@ -628,7 +633,7 @@ export function sameMeta(a?: Meta, b?: Meta): boolean {
|
||||
a.tokenMode === b.tokenMode &&
|
||||
a.goal === b.goal &&
|
||||
a.goalStatus === b.goalStatus &&
|
||||
sameTodoList(a.canonicalTodos, b.canonicalTodos)
|
||||
sameTodoList(a.canonicalTodos, b.canonicalTodos) && sameStringList(a.dismissedTodoBatches, b.dismissedTodoBatches)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -672,8 +677,8 @@ export function composerProfileApplicationKey(
|
||||
}
|
||||
|
||||
function metaWithoutCanonicalTodos(meta?: Meta): Meta | undefined {
|
||||
if (!meta || meta.canonicalTodos === undefined) return meta;
|
||||
return { ...meta, canonicalTodos: undefined };
|
||||
if (!meta || (meta.canonicalTodos === undefined && meta.dismissedTodoBatches === undefined)) return meta;
|
||||
return { ...meta, canonicalTodos: undefined, dismissedTodoBatches: undefined };
|
||||
}
|
||||
|
||||
const STALE_TURN_RECONCILE_MS = 30_000;
|
||||
@@ -696,15 +701,6 @@ export function shouldReconcileStaleTurn(
|
||||
return Math.max(0, now - lastTurnActivityAt) >= timeoutMs;
|
||||
}
|
||||
|
||||
function hasCachedLiveTurn(state: State | undefined): boolean {
|
||||
if (!state?.running && !state?.turnActive) return false;
|
||||
if (state.live || state.currentAssistant || state.pendingUser !== undefined) return true;
|
||||
return state.items.some((item) =>
|
||||
(item.kind === "assistant" && item.streaming) ||
|
||||
(item.kind === "tool" && item.status === "running")
|
||||
);
|
||||
}
|
||||
|
||||
function hasReusableCachedTranscript(state: State | undefined, sessionPath?: string, revision?: number, digest?: string): boolean {
|
||||
if (!state || state.items.length === 0) return false;
|
||||
const expectedSessionPath = (sessionPath ?? "").trim();
|
||||
@@ -737,6 +733,7 @@ export function isReadOnlyTool(name: string): boolean {
|
||||
case "grep":
|
||||
case "glob":
|
||||
case "web_fetch":
|
||||
case "web_search":
|
||||
case "code_index":
|
||||
case "bash_output":
|
||||
case "waitJob":
|
||||
@@ -748,6 +745,8 @@ export function isReadOnlyTool(name: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export { isBatchedReadOnlyTool } from "./searchTranscript";
|
||||
|
||||
const ARCHIVED_TOOL_ARG_LIMIT = 200;
|
||||
|
||||
function archivedToolArgs(_name: string, args: string): string {
|
||||
@@ -891,18 +890,17 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
|
||||
continue;
|
||||
}
|
||||
if (m.role === "assistant") {
|
||||
const hasText = m.content.trim() !== "" || (m.reasoning ?? "").trim() !== "";
|
||||
if (hasText) {
|
||||
const memoryCitations = asArray<MemoryCitation>(m.memoryCitations);
|
||||
items.push({
|
||||
kind: "assistant",
|
||||
id: `${idPrefix}${seq}`,
|
||||
text: m.content,
|
||||
reasoning: m.reasoning ?? "",
|
||||
streaming: false,
|
||||
workDurationMs: m.workDurationMs,
|
||||
memoryCitations: memoryCitations.length > 0 ? memoryCitations : undefined,
|
||||
});
|
||||
const memoryCitations = asArray<MemoryCitation>(m.memoryCitations);
|
||||
const built = historySearchAndAnswer(`${idPrefix}${seq}`, {
|
||||
content: m.content,
|
||||
reasoning: m.reasoning,
|
||||
workDurationMs: m.workDurationMs,
|
||||
memoryCitations: memoryCitations.length > 0 ? memoryCitations : undefined,
|
||||
serverSearch: m.serverSearch,
|
||||
});
|
||||
for (const item of built) {
|
||||
if (item.kind === "assistant") item.id = `${idPrefix}${seq}`;
|
||||
items.push(item);
|
||||
seq++;
|
||||
}
|
||||
const toolCalls = m.toolCalls ?? [];
|
||||
@@ -1028,7 +1026,14 @@ function ensureAssistant(s: State): { items: Item[]; id: string; seq: number } {
|
||||
if (exists) return { items: s.items, id: s.currentAssistant, seq: s.seq };
|
||||
}
|
||||
const id = `a${s.seq}`;
|
||||
const item: Item = { kind: "assistant", id, text: "", reasoning: "", streaming: true };
|
||||
const item: Item = {
|
||||
kind: "assistant",
|
||||
id,
|
||||
text: "",
|
||||
reasoning: "",
|
||||
streaming: true,
|
||||
searchSources: s.pendingSearchSources?.length ? s.pendingSearchSources : undefined,
|
||||
};
|
||||
return { items: [...s.items, item], id, seq: s.seq + 1 };
|
||||
}
|
||||
|
||||
@@ -1426,9 +1431,10 @@ function applyEvent(s: State, e: WireEvent): State {
|
||||
// immediately so the user sees their message + a blinking cursor the
|
||||
// instant the backend acknowledges the turn — no dead gap waiting for
|
||||
// the first text/reasoning token.
|
||||
const { items, id, seq } = ensureAssistant(s);
|
||||
const fresh = { ...s, pendingSearchSources: undefined };
|
||||
const { items, id, seq } = ensureAssistant(fresh);
|
||||
return {
|
||||
...s,
|
||||
...fresh,
|
||||
items,
|
||||
currentAssistant: id,
|
||||
seq,
|
||||
@@ -1482,8 +1488,10 @@ function applyEvent(s: State, e: WireEvent): State {
|
||||
const text = e.text ?? s.live?.text ?? existingAssistant?.text ?? "";
|
||||
const reasoning = e.reasoning ?? s.live?.reasoning ?? existingAssistant?.reasoning ?? "";
|
||||
if (text.trim() === "" && reasoning.trim() === "") {
|
||||
const keepEmpty =
|
||||
Boolean(existingAssistant?.memoryCitations?.length) || Boolean(existingAssistant?.searchSources?.length);
|
||||
const items =
|
||||
existingAssistant && existingAssistant.text.trim() === "" && existingAssistant.reasoning.trim() === "" && !existingAssistant.memoryCitations?.length
|
||||
existingAssistant && existingAssistant.text.trim() === "" && existingAssistant.reasoning.trim() === "" && !keepEmpty
|
||||
? s.items.filter((it) => !(it.kind === "assistant" && it.id === existingAssistant.id))
|
||||
: s.items;
|
||||
return { ...endTurnModelActivity(s, Date.now(), true), items, live: undefined, currentAssistant: undefined, turnOutputCharsAtUsage: 0 };
|
||||
@@ -1509,6 +1517,7 @@ function applyEvent(s: State, e: WireEvent): State {
|
||||
reasoningDurationMs: reasoningDurationMs ?? it.reasoningDurationMs,
|
||||
workDurationMs: Math.max(it.workDurationMs ?? 0, workDurationMs ?? 0) || undefined,
|
||||
memoryCitations: memoryCitations.length > 0 ? memoryCitations : undefined,
|
||||
searchSources: it.searchSources,
|
||||
};
|
||||
})()
|
||||
: it,
|
||||
@@ -1637,7 +1646,7 @@ function applyEvent(s: State, e: WireEvent): State {
|
||||
}
|
||||
// A nested result refreshes its sub-agent parent's recent activity.
|
||||
if (t.parentId) touchSubagentParent(next, t.parentId);
|
||||
return { ...s, items: compactArchivedToolItems(next) };
|
||||
return attachWebSearchOutput({ ...s, items: compactArchivedToolItems(next) }, t.name, t.output, t.err);
|
||||
}
|
||||
case "tool_progress": {
|
||||
const t = e.tool;
|
||||
@@ -1720,6 +1729,7 @@ function applyEvent(s: State, e: WireEvent): State {
|
||||
return { ...s, running: s.turnActive ? s.running : false, seq: s.seq + 1, items };
|
||||
}
|
||||
case "steer":
|
||||
if (isHostRecoveryGuidance(e.text ?? "")) return s;
|
||||
return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `s${s.seq}`, level: "info", text: `${STEER_NOTICE_PREFIX}${e.text ?? ""}` }] };
|
||||
case "approval_request": {
|
||||
if (s.cancelRequested) return s;
|
||||
@@ -2925,13 +2935,10 @@ export function useController() {
|
||||
let projection = skipHistory
|
||||
? undefined
|
||||
: await loadTimed("history", () =>
|
||||
// Windowed slice load through the transcript store. A resident
|
||||
// session (LRU hit) projects synchronously with no backend round
|
||||
// trip; reset loads always re-fetch so a rebound session can never
|
||||
// serve the previous session's rows.
|
||||
// Resident LRU only when the caller keeps cache; reset/no-cache re-fetch.
|
||||
getTranscriptStore().loadLatest(tabId, sessionPath, {
|
||||
turns: HISTORY_PAGE_TURNS,
|
||||
preferResident: !reset,
|
||||
preferResident: shouldPreferResidentHistory(reset, options.preserveCachedHistory),
|
||||
expectedRevision: sessionRevision,
|
||||
expectedDigest: sessionDigest,
|
||||
}),
|
||||
@@ -2944,17 +2951,23 @@ export function useController() {
|
||||
dispatchTo(tabId, { type: "local_notice", level: "warn", text: errText });
|
||||
addBreadcrumb("tab.hydrate", `history failed ${tabId} ms=${Date.now() - historyStartedAt}`); return;
|
||||
}
|
||||
if (!skipHistory && projection !== undefined && !foregroundTurnActive()) {
|
||||
if (deferResetUntilHistory && stillCurrent()) dispatchTo(tabId, { type: "reset" });
|
||||
dispatchTo(tabId, {
|
||||
type: "history_replace",
|
||||
const applyProj = projection && {
|
||||
items: projection.items, revision: projection.revisionKnown ? projection.revision : undefined, digest: projection.digest || undefined,
|
||||
};
|
||||
const applyMode = hydratedHistoryApplyMode(skipHistory, projection !== undefined, foregroundTurnActive(), statesRef.current.get(tabId), applyProj);
|
||||
if (projection !== undefined && applyMode !== "skip") {
|
||||
if (deferResetUntilHistory && stillCurrent() && !foregroundTurnActive()) dispatchTo(tabId, { type: "reset" });
|
||||
const page = {
|
||||
items: projection.items,
|
||||
startTurn: projection.startTurn,
|
||||
totalTurns: projection.totalTurns,
|
||||
hasOlder: projection.hasOlder,
|
||||
revision: projection.revisionKnown ? projection.revision : undefined,
|
||||
digest: projection.digest || undefined,
|
||||
});
|
||||
};
|
||||
dispatchTo(tabId, applyMode === "prepend"
|
||||
? { type: "history_prepend", ...page, removeIds: duplicateLiveItemIds(projection.items, statesRef.current.get(tabId)?.items ?? []) }
|
||||
: { type: "history_replace", ...page });
|
||||
addBreadcrumb(
|
||||
"tab.hydrate",
|
||||
`history page ${tabId} items=${projection.items.length} turns=${projection.startTurn}-${projection.endTurn}/${projection.totalTurns} ms=${Date.now() - historyStartedAt}`,
|
||||
@@ -3716,9 +3729,8 @@ export function useController() {
|
||||
// become follow-ups automatically (disposition queued_followup).
|
||||
const receipt = await app.EnqueueInboxSteer(tabId, text, text, "");
|
||||
if (receipt?.error) throw new Error(receipt.error);
|
||||
if (receipt?.disposition && receipt.disposition !== "steer_accepted") {
|
||||
throw new Error(inboxSteerQueuedMessage(getLocale()));
|
||||
}
|
||||
// queued_followup is success: the instruction is durable and will run at
|
||||
// the next idle/tool-boundary kick. Do not surface it as a send failure.
|
||||
}, []);
|
||||
|
||||
const steer = useCallback(async (text: string) => {
|
||||
@@ -4039,7 +4051,7 @@ export function useController() {
|
||||
try {
|
||||
cleared = tabId ? await app.ClearSessionForTab(tabId) : await app.ClearSession();
|
||||
} catch {
|
||||
if (tabId) void loadSessionDataForTab(tabId);
|
||||
if (tabId) void loadSessionDataForTab(tabId, false, "startup", { preserveCachedHistory: true });
|
||||
return;
|
||||
}
|
||||
if (tabId) bumpSessionLoadSeq(tabId);
|
||||
@@ -4614,9 +4626,10 @@ export function useController() {
|
||||
await reassertVisibleTabAfterStaleNavigation("topic.activate", meta.id);
|
||||
return meta;
|
||||
}
|
||||
// Save previous tab's items so the new tab can use them as a placeholder
|
||||
// during loading, avoiding a blank/Welcome flash before history arrives.
|
||||
const prevItems = activeTabIdRef.current ? statesRef.current.get(activeTabIdRef.current)?.items : undefined;
|
||||
const prevItems = sameSessionPlaceholderItems(
|
||||
meta.sessionPath,
|
||||
activeTabIdRef.current ? statesRef.current.get(activeTabIdRef.current) : undefined,
|
||||
);
|
||||
pending.placeholderItems = prevItems;
|
||||
for (const id of Array.from(statesRef.current.keys())) {
|
||||
if (id !== meta.id) {
|
||||
@@ -4632,9 +4645,7 @@ export function useController() {
|
||||
noteActivationStarted(pending.requestId, meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
dispatchRuntimeStatusForTab(meta.id, meta, snapshotAt);
|
||||
// The hydrate is driven by the activation's terminal "ready" event; until
|
||||
// then keep the loading surface up with the previous tab's items as the
|
||||
// placeholder (same no-flash behavior the immediate hydrate had).
|
||||
// Ready hydrates; only same-session items are a safe placeholder.
|
||||
dispatchTo(meta.id, { type: "hydrate_start", reason: "open-topic", placeholderItems: prevItems });
|
||||
if (pending.terminal && pendingTopicActivationRef.current === pending) {
|
||||
// The terminal event beat the ticket resolution; process it now.
|
||||
|
||||
@@ -23,6 +23,8 @@ declare global {
|
||||
const SCROLL_UP_KEYS = new Set(["ArrowUp", "PageUp", "Home"]);
|
||||
const SCROLL_DOWN_KEYS = new Set(["ArrowDown", "PageDown", "End", " ", "Spacebar"]);
|
||||
export const TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX = 4;
|
||||
// Thumb/middle-button leaves jump farther than a last-row LAST undershoot.
|
||||
const BOTTOM_REQUEST_LEAVE_PX = 160;
|
||||
|
||||
export function isPinnedTranscriptLayoutGrowth({
|
||||
pinned,
|
||||
@@ -43,12 +45,158 @@ export function isPinnedTranscriptLayoutGrowth({
|
||||
&& scrollTop >= previousScrollTop - 1;
|
||||
}
|
||||
|
||||
export function isPinnedTranscriptViewportChange({
|
||||
pinned,
|
||||
previousClientHeight,
|
||||
clientHeight,
|
||||
}: {
|
||||
pinned: boolean;
|
||||
previousClientHeight: number;
|
||||
clientHeight: number;
|
||||
}) {
|
||||
// Composer chrome such as the todo shelf changes the transcript viewport
|
||||
// without a user scroll. Virtuoso can then publish atBottom=false and even
|
||||
// reset scrollTop to the start of the loaded window.
|
||||
return pinned
|
||||
&& previousClientHeight > 0
|
||||
&& Math.abs(clientHeight - previousClientHeight) > 1;
|
||||
}
|
||||
|
||||
export function shouldKeepPinnedOnAtBottomFalse({
|
||||
pinned,
|
||||
previousScrollHeight,
|
||||
previousScrollTop,
|
||||
previousClientHeight,
|
||||
scrollHeight,
|
||||
scrollTop,
|
||||
clientHeight,
|
||||
}: {
|
||||
pinned: boolean;
|
||||
previousScrollHeight: number;
|
||||
previousScrollTop: number;
|
||||
previousClientHeight: number;
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
clientHeight: number;
|
||||
}) {
|
||||
return isPinnedTranscriptLayoutGrowth({
|
||||
pinned,
|
||||
previousScrollHeight,
|
||||
previousScrollTop,
|
||||
scrollHeight,
|
||||
scrollTop,
|
||||
}) || isPinnedTranscriptViewportChange({
|
||||
pinned,
|
||||
previousClientHeight,
|
||||
clientHeight,
|
||||
});
|
||||
}
|
||||
|
||||
export function nativeTranscriptDistanceFromBottom(element: {
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
clientHeight: number;
|
||||
}) {
|
||||
return element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
}
|
||||
|
||||
export function nativeTranscriptBottomTop(element: {
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
}) {
|
||||
return Math.max(0, element.scrollHeight - element.clientHeight);
|
||||
}
|
||||
|
||||
export function isPhysicallyAtTranscriptBottom(
|
||||
element: { scrollHeight: number; scrollTop: number; clientHeight: number },
|
||||
threshold = TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX,
|
||||
) {
|
||||
return nativeTranscriptDistanceFromBottom(element) <= threshold;
|
||||
}
|
||||
|
||||
// Virtuoso LAST after a native snap writes a shorter size-tree top. That drop
|
||||
// is undershoot, not the reader leaving. Pointer/key leave already unpins.
|
||||
export function shouldReleaseBottomRequestOnAtBottomFalse({
|
||||
distanceFromBottom,
|
||||
scrollTop,
|
||||
previousScrollTop,
|
||||
previousScrollHeight,
|
||||
previousClientHeight,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
}: {
|
||||
distanceFromBottom: number;
|
||||
scrollTop: number;
|
||||
previousScrollTop: number;
|
||||
previousScrollHeight?: number;
|
||||
previousClientHeight?: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
}) {
|
||||
if (distanceFromBottom <= TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX) return false;
|
||||
const previousNativeMax = nativeTranscriptBottomTop({
|
||||
scrollHeight: previousScrollHeight ?? scrollHeight,
|
||||
clientHeight: previousClientHeight ?? clientHeight,
|
||||
});
|
||||
if (previousScrollTop >= previousNativeMax - TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX) return false;
|
||||
return previousScrollTop - scrollTop > BOTTOM_REQUEST_LEAVE_PX;
|
||||
}
|
||||
|
||||
export function shouldClearBottomRequestOnAtBottomTrue() {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldRemeasureMountedRowsForTailFinish({
|
||||
remeasuredThisCommand,
|
||||
allowRemeasure = true,
|
||||
}: {
|
||||
remeasuredThisCommand: boolean;
|
||||
allowRemeasure?: boolean;
|
||||
}) {
|
||||
return allowRemeasure && !remeasuredThisCommand;
|
||||
}
|
||||
|
||||
export function shouldFinishTailOnBottomRequestTimer({
|
||||
pinned,
|
||||
bottomRequestWasActive,
|
||||
}: {
|
||||
pinned: boolean;
|
||||
bottomRequestWasActive: boolean;
|
||||
}) {
|
||||
return pinned && bottomRequestWasActive;
|
||||
}
|
||||
|
||||
export function shouldClearBottomRequestOnWriteOffset(owner: TranscriptScrollOwner) {
|
||||
return owner !== "jump-bottom" && owner !== "selection-edge-scroll";
|
||||
}
|
||||
|
||||
export function shouldFinishTailOnAtBottomFalse({
|
||||
pinned,
|
||||
bottomRequestActive,
|
||||
}: {
|
||||
pinned: boolean;
|
||||
bottomRequestActive: boolean;
|
||||
}) {
|
||||
return pinned || bottomRequestActive;
|
||||
}
|
||||
|
||||
export function shouldSnapPinnedWheelToNativeBottom({
|
||||
pinned,
|
||||
deltaY,
|
||||
distanceFromBottom,
|
||||
}: {
|
||||
pinned: boolean;
|
||||
deltaY: number;
|
||||
distanceFromBottom: number;
|
||||
}) {
|
||||
return pinned && deltaY > 0 && distanceFromBottom > TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Product-level scroll intent around React Virtuoso.
|
||||
*
|
||||
* Virtuoso exclusively owns measurement and anchor compensation. This hook
|
||||
* only records whether the reader follows the tail and exposes explicit
|
||||
* navigation commands; it never tries to correct measured row positions.
|
||||
* Virtuoso owns row measurement and anchors. This hook records tail-follow
|
||||
* vs manual reading and finishes LAST undershoot at scrollHeight-clientHeight.
|
||||
*/
|
||||
export function useTranscriptVirtuosoScroll() {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
@@ -56,11 +204,13 @@ export function useTranscriptVirtuosoScroll() {
|
||||
const pinnedRef = useRef(true);
|
||||
const bottomRequestRef = useRef(false);
|
||||
const bottomRequestTimerRef = useRef<number | null>(null);
|
||||
const pinnedMetricsRef = useRef({ scrollHeight: 0, scrollTop: 0 });
|
||||
const pinnedMetricsRef = useRef({ scrollHeight: 0, scrollTop: 0, clientHeight: 0 });
|
||||
const modeRef = useRef<TranscriptScrollMode>("tail-follow");
|
||||
const touchStartYRef = useRef<number | null>(null);
|
||||
const nativeScrollbarDragRef = useRef(false);
|
||||
const remeasureForTailRef = useRef(false);
|
||||
const [nativeScrollbarDragging, setNativeScrollbarDragging] = useState(false);
|
||||
const [measureGeneration, setMeasureGeneration] = useState(0);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -77,26 +227,69 @@ export function useTranscriptVirtuosoScroll() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const snapToNativeBottom = useCallback(() => {
|
||||
const element = scrollRef.current;
|
||||
if (!element || isTranscriptSelectionMode(modeRef.current)) return false;
|
||||
const max = nativeTranscriptBottomTop(element);
|
||||
virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" });
|
||||
if (element.scrollTop < max) element.scrollTop = max;
|
||||
const atBottom = isPhysicallyAtTranscriptBottom(element);
|
||||
if (atBottom) {
|
||||
pinnedMetricsRef.current = {
|
||||
scrollHeight: element.scrollHeight,
|
||||
scrollTop: element.scrollTop,
|
||||
clientHeight: element.clientHeight,
|
||||
};
|
||||
}
|
||||
return atBottom;
|
||||
}, []);
|
||||
|
||||
const finishTailAtNativeBottom = useCallback((opts?: { force?: boolean; allowRemeasure?: boolean }) => {
|
||||
if (isTranscriptSelectionMode(modeRef.current)) return;
|
||||
if (!opts?.force && !pinnedRef.current && !bottomRequestRef.current) return;
|
||||
|
||||
const applySnap = () => {
|
||||
if (isTranscriptSelectionMode(modeRef.current)) return;
|
||||
if (!opts?.force && !pinnedRef.current && !bottomRequestRef.current) return;
|
||||
const atBottom = snapToNativeBottom();
|
||||
if (atBottom) {
|
||||
pinnedRef.current = true;
|
||||
setIsAtBottom(true);
|
||||
publishMode("tail-follow");
|
||||
return;
|
||||
}
|
||||
setIsAtBottom(false);
|
||||
};
|
||||
|
||||
if (shouldRemeasureMountedRowsForTailFinish({
|
||||
remeasuredThisCommand: remeasureForTailRef.current,
|
||||
allowRemeasure: opts?.allowRemeasure,
|
||||
})) {
|
||||
remeasureForTailRef.current = true;
|
||||
setMeasureGeneration((generation) => generation + 1);
|
||||
requestAnimationFrame(applySnap);
|
||||
return;
|
||||
}
|
||||
applySnap();
|
||||
}, [publishMode, snapToNativeBottom]);
|
||||
|
||||
const beginBottomRequest = useCallback(() => {
|
||||
remeasureForTailRef.current = false;
|
||||
clearBottomRequest();
|
||||
bottomRequestRef.current = true;
|
||||
// Virtuoso can briefly report `false` while its LAST-item request is
|
||||
// converging. Keep tail intent through that window, then derive the final
|
||||
// state from the native scroller so a stale request can never mask later
|
||||
// user scrolling.
|
||||
// LAST can overwrite a native snap. Keep the window so a still-pinned
|
||||
// reader finishes at the native extent. An explicit leave cancels this.
|
||||
bottomRequestTimerRef.current = window.setTimeout(() => {
|
||||
const bottomRequestWasActive = bottomRequestRef.current;
|
||||
bottomRequestTimerRef.current = null;
|
||||
bottomRequestRef.current = false;
|
||||
const element = scrollRef.current;
|
||||
const atBottom = element != null
|
||||
&& element.scrollHeight - element.scrollTop - element.clientHeight <= TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX;
|
||||
pinnedRef.current = atBottom;
|
||||
setIsAtBottom(atBottom);
|
||||
if (!isTranscriptSelectionMode(modeRef.current)) {
|
||||
publishMode(atBottom ? "tail-follow" : "manual");
|
||||
}
|
||||
if (!shouldFinishTailOnBottomRequestTimer({
|
||||
pinned: pinnedRef.current,
|
||||
bottomRequestWasActive,
|
||||
})) return;
|
||||
finishTailAtNativeBottom();
|
||||
}, 500);
|
||||
}, [clearBottomRequest, publishMode]);
|
||||
}, [clearBottomRequest, finishTailAtNativeBottom]);
|
||||
|
||||
const setMode = useCallback((mode: TranscriptScrollMode, _reason?: string) => {
|
||||
publishMode(mode);
|
||||
@@ -126,8 +319,9 @@ export function useTranscriptVirtuosoScroll() {
|
||||
const itemSize = useCallback<SizeFunction>((element, field) => {
|
||||
// The drag state intentionally changes this callback identity on release.
|
||||
// Virtuoso then re-observes and records the real mounted row sizes.
|
||||
// measureGeneration does the same after a tail command undershoots.
|
||||
return measureTranscriptVirtuosoItem(element, field, nativeScrollbarDragRef.current || nativeScrollbarDragging);
|
||||
}, [nativeScrollbarDragging]);
|
||||
}, [measureGeneration, nativeScrollbarDragging]);
|
||||
|
||||
const scrollerRef = useCallback((node: HTMLElement | Window | null) => {
|
||||
const element = node instanceof HTMLElement ? node as HTMLDivElement : null;
|
||||
@@ -136,7 +330,11 @@ export function useTranscriptVirtuosoScroll() {
|
||||
if (element) {
|
||||
element.dataset.scrollMode = modeRef.current;
|
||||
if (pinnedRef.current) {
|
||||
pinnedMetricsRef.current = { scrollHeight: element.scrollHeight, scrollTop: element.scrollTop };
|
||||
pinnedMetricsRef.current = {
|
||||
scrollHeight: element.scrollHeight,
|
||||
scrollTop: element.scrollTop,
|
||||
clientHeight: element.clientHeight,
|
||||
};
|
||||
}
|
||||
}
|
||||
setScrollElement((current) => current === element ? current : element);
|
||||
@@ -157,46 +355,62 @@ export function useTranscriptVirtuosoScroll() {
|
||||
requestAnimationFrame(() => {
|
||||
if (!pinnedRef.current || isTranscriptSelectionMode(modeRef.current)) return;
|
||||
handle?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" });
|
||||
finishTailAtNativeBottom({ allowRemeasure: false });
|
||||
});
|
||||
}, []);
|
||||
}, [finishTailAtNativeBottom]);
|
||||
|
||||
const atBottomStateChange = useCallback((atBottom: boolean) => {
|
||||
const element = scrollRef.current;
|
||||
if (!atBottom && element && isPinnedTranscriptLayoutGrowth({
|
||||
if (!atBottom && element && shouldKeepPinnedOnAtBottomFalse({
|
||||
pinned: pinnedRef.current,
|
||||
previousScrollHeight: pinnedMetricsRef.current.scrollHeight,
|
||||
previousScrollTop: pinnedMetricsRef.current.scrollTop,
|
||||
previousClientHeight: pinnedMetricsRef.current.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
scrollTop: element.scrollTop,
|
||||
clientHeight: element.clientHeight,
|
||||
})) {
|
||||
// A mounted row grew while the reader still owned the tail. Virtuoso can
|
||||
// publish `false` before totalListHeightChanged asks us to follow the new
|
||||
// extent; preserve intent through that callback ordering race.
|
||||
pinnedMetricsRef.current = { scrollHeight: element.scrollHeight, scrollTop: element.scrollTop };
|
||||
// A mounted row grew, or composer chrome resized the viewport, while the
|
||||
// reader still owned the tail. Virtuoso can publish `false` and even jump
|
||||
// scrollTop to the loaded-window start before we follow the new extent.
|
||||
pinnedMetricsRef.current = {
|
||||
scrollHeight: element.scrollHeight,
|
||||
scrollTop: element.scrollTop,
|
||||
clientHeight: element.clientHeight,
|
||||
};
|
||||
followGrowingTail();
|
||||
return;
|
||||
}
|
||||
// Even inside a bottomRequest window, honor a state change if the reader
|
||||
// has genuinely scrolled away from the bottom. Virtuoso fires this callback
|
||||
// based on its measured threshold; double-check the native scroll position
|
||||
// so non-wheel upward scrolls (native scrollbar drag, middle-button autoscroll)
|
||||
// release tail-follow immediately rather than waiting for the timer.
|
||||
if (!atBottom && bottomRequestRef.current) {
|
||||
if (element) {
|
||||
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
if (distanceFromBottom > TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX) {
|
||||
clearBottomRequest();
|
||||
pinnedRef.current = false;
|
||||
setIsAtBottom(false);
|
||||
if (!isTranscriptSelectionMode(modeRef.current)) publishMode("manual");
|
||||
}
|
||||
if (!atBottom && shouldFinishTailOnAtBottomFalse({
|
||||
pinned: pinnedRef.current,
|
||||
bottomRequestActive: bottomRequestRef.current,
|
||||
})) {
|
||||
if (element && shouldReleaseBottomRequestOnAtBottomFalse({
|
||||
distanceFromBottom: nativeTranscriptDistanceFromBottom(element),
|
||||
scrollTop: element.scrollTop,
|
||||
previousScrollTop: pinnedMetricsRef.current.scrollTop,
|
||||
previousScrollHeight: pinnedMetricsRef.current.scrollHeight,
|
||||
previousClientHeight: pinnedMetricsRef.current.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight,
|
||||
})) {
|
||||
clearBottomRequest();
|
||||
pinnedRef.current = false;
|
||||
setIsAtBottom(false);
|
||||
if (!isTranscriptSelectionMode(modeRef.current)) publishMode("manual");
|
||||
return;
|
||||
}
|
||||
finishTailAtNativeBottom();
|
||||
return;
|
||||
}
|
||||
if (atBottom) {
|
||||
clearBottomRequest();
|
||||
if (shouldClearBottomRequestOnAtBottomTrue()) clearBottomRequest();
|
||||
if (element) {
|
||||
pinnedMetricsRef.current = { scrollHeight: element.scrollHeight, scrollTop: element.scrollTop };
|
||||
pinnedMetricsRef.current = {
|
||||
scrollHeight: element.scrollHeight,
|
||||
scrollTop: element.scrollTop,
|
||||
clientHeight: element.clientHeight,
|
||||
};
|
||||
}
|
||||
}
|
||||
pinnedRef.current = atBottom;
|
||||
@@ -204,10 +418,11 @@ export function useTranscriptVirtuosoScroll() {
|
||||
if (!isTranscriptSelectionMode(modeRef.current)) {
|
||||
publishMode(atBottom ? "tail-follow" : "manual");
|
||||
}
|
||||
}, [clearBottomRequest, followGrowingTail, publishMode]);
|
||||
}, [clearBottomRequest, finishTailAtNativeBottom, followGrowingTail, publishMode]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
clearBottomRequest();
|
||||
remeasureForTailRef.current = false;
|
||||
pinnedRef.current = true;
|
||||
setIsAtBottom(true);
|
||||
publishMode("tail-follow");
|
||||
@@ -222,7 +437,8 @@ export function useTranscriptVirtuosoScroll() {
|
||||
pinnedRef.current = true;
|
||||
setIsAtBottom(true);
|
||||
publishMode("tail-follow");
|
||||
} else if (owner !== "selection-edge-scroll") {
|
||||
} else if (shouldClearBottomRequestOnWriteOffset(owner)) {
|
||||
clearBottomRequest();
|
||||
pinnedRef.current = false;
|
||||
setIsAtBottom(false);
|
||||
publishMode("programmatic");
|
||||
@@ -230,7 +446,7 @@ export function useTranscriptVirtuosoScroll() {
|
||||
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__?.(owner, top);
|
||||
virtuosoRef.current?.scrollTo({ top, behavior });
|
||||
return true;
|
||||
}, [beginBottomRequest, publishMode]);
|
||||
}, [beginBottomRequest, clearBottomRequest, publishMode]);
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
|
||||
if (isTranscriptSelectionMode(modeRef.current)) return;
|
||||
@@ -240,12 +456,11 @@ export function useTranscriptVirtuosoScroll() {
|
||||
publishMode("tail-follow");
|
||||
const handle = virtuosoRef.current;
|
||||
handle?.scrollToIndex({ index: "LAST", align: "end", behavior: behavior === "smooth" ? "smooth" : "auto" });
|
||||
// `align: end` positions the last item, but theme spacing on the list can
|
||||
// leave a few native scroll pixels below it. Finish at the actual scroll
|
||||
// extent so at-bottom state and the visual position agree exactly.
|
||||
handle?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior });
|
||||
requestAnimationFrame(() => handle?.autoscrollToBottom());
|
||||
}, [beginBottomRequest, publishMode]);
|
||||
// LAST mounts the last rows. Remeasure those sizes, then finish at the
|
||||
// native extent. Do not autoscrollToBottom — that LAST listener overwrites
|
||||
// the native snap.
|
||||
requestAnimationFrame(() => finishTailAtNativeBottom());
|
||||
}, [beginBottomRequest, finishTailAtNativeBottom, publishMode]);
|
||||
|
||||
const scrollToDataIndex = useCallback((firstItemIndex: number, dataIndex: number, behavior: "auto" | "smooth" = "auto") => {
|
||||
if (isTranscriptSelectionMode(modeRef.current)) return;
|
||||
@@ -267,8 +482,17 @@ export function useTranscriptVirtuosoScroll() {
|
||||
releaseTailFollow();
|
||||
return true;
|
||||
}
|
||||
const element = scrollRef.current;
|
||||
if (element && shouldSnapPinnedWheelToNativeBottom({
|
||||
pinned: pinnedRef.current,
|
||||
deltaY: event.deltaY,
|
||||
distanceFromBottom: nativeTranscriptDistanceFromBottom(element),
|
||||
})) {
|
||||
finishTailAtNativeBottom();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [releaseTailFollow]);
|
||||
}, [finishTailAtNativeBottom, releaseTailFollow]);
|
||||
|
||||
const onTouchStartIntent = useCallback((event: ReactTouchEvent<HTMLElement>) => {
|
||||
touchStartYRef.current = event.touches[0]?.clientY ?? null;
|
||||
@@ -291,8 +515,17 @@ export function useTranscriptVirtuosoScroll() {
|
||||
releaseTailFollow();
|
||||
return true;
|
||||
}
|
||||
const element = scrollRef.current;
|
||||
if (SCROLL_DOWN_KEYS.has(event.key) && element && shouldSnapPinnedWheelToNativeBottom({
|
||||
pinned: pinnedRef.current,
|
||||
deltaY: 1,
|
||||
distanceFromBottom: nativeTranscriptDistanceFromBottom(element),
|
||||
})) {
|
||||
finishTailAtNativeBottom();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [releaseTailFollow]);
|
||||
}, [finishTailAtNativeBottom, releaseTailFollow]);
|
||||
|
||||
const onPointerDownIntent = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||
const element = scrollRef.current;
|
||||
@@ -315,8 +548,17 @@ export function useTranscriptVirtuosoScroll() {
|
||||
releaseTailFollow();
|
||||
return true;
|
||||
}
|
||||
const element = scrollRef.current;
|
||||
if (element && shouldSnapPinnedWheelToNativeBottom({
|
||||
pinned: pinnedRef.current,
|
||||
deltaY,
|
||||
distanceFromBottom: nativeTranscriptDistanceFromBottom(element),
|
||||
})) {
|
||||
finishTailAtNativeBottom();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [releaseTailFollow]);
|
||||
}, [finishTailAtNativeBottom, releaseTailFollow]);
|
||||
|
||||
return {
|
||||
virtuosoRef,
|
||||
|
||||
@@ -794,6 +794,10 @@ export const en = {
|
||||
"composer.guidanceRetryMode": "Retry",
|
||||
"composer.guidanceRetry": "Retry this guidance",
|
||||
"composer.guidanceDismiss": "Dismiss queued guidance",
|
||||
"composer.guidanceEdit": "Edit this queued guidance",
|
||||
"composer.guidanceSaveEdit": "Save guidance edits",
|
||||
"composer.guidanceCancelEdit": "Cancel guidance edits",
|
||||
"composer.guidanceEmptyPreview": "No preview text",
|
||||
"composer.guidanceMore": "More guidance options",
|
||||
"composer.shellMode": "Shell mode: prefix input with !",
|
||||
"composer.shellModeOn": "Shell mode on: click to remove !",
|
||||
@@ -1243,13 +1247,29 @@ export const en = {
|
||||
"history.historySessionActions": "History session actions",
|
||||
"history.trashActions": "Trash actions",
|
||||
|
||||
// session recovery
|
||||
"recovery.branch": "Conflict copy",
|
||||
"recovery.badge": "Copy",
|
||||
"recovery.noticeSavedCopy": "The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
|
||||
"recovery.noticeKeptCurrent": "Repeated save conflicts were detected, so the current conflict copy was saved in an isolated recovery branch.",
|
||||
// session recovery (advanced History language — avoid exposing internal terms)
|
||||
"recovery.branch": "Saved version",
|
||||
"recovery.badge": "Version",
|
||||
"recovery.noticeSavedCopy": "The session changed on disk, so the unsaved local transcript was kept as another saved version.",
|
||||
"recovery.noticeKeptCurrent": "Repeated save conflicts were detected, so the current version was saved separately.",
|
||||
"recovery.noticeAdopted": "The session changed on disk, so Reasonix adopted the newer transcript.",
|
||||
"recovery.noticeAdoptedCovered": "The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
|
||||
"recovery.inspectLineage": "Other saved versions",
|
||||
"recovery.lineageTitle": "Other saved versions",
|
||||
"recovery.lineageSummary": "{branches} saved versions · {unresolved} with unique content",
|
||||
"recovery.lineageEmpty": "No other saved versions.",
|
||||
"recovery.previewCleanup": "Review safe cleanup",
|
||||
"recovery.applyCleanup": "Move covered versions to Trash",
|
||||
"recovery.cleanupConfirm": "{count} covered versions can be moved to Trash. Versions with unique content or that are in use will be kept.",
|
||||
"recovery.cleanupResult": "Moved {moved}; busy {busy}; kept {kept}.",
|
||||
"recovery.inUse": "in use",
|
||||
"recovery.chooseBranch": "Set as default version",
|
||||
"recovery.openVersion": "Open this version",
|
||||
"recovery.role.covered_copy": "covered by a fuller version",
|
||||
"recovery.role.adopted": "full version",
|
||||
"recovery.role.preferred": "default version",
|
||||
"recovery.role.diverged": "unique content",
|
||||
"recovery.role.normal": "original",
|
||||
|
||||
// project tree
|
||||
"projectTree.workspaceTitle": "Projects",
|
||||
@@ -1280,6 +1300,7 @@ export const en = {
|
||||
"projectTree.status.waitingConfirmation": "awaiting",
|
||||
"projectTree.status.backgroundJob": "background job",
|
||||
"projectTree.status.paused": "paused",
|
||||
"projectTree.status.awaitingDelivery": "need checks",
|
||||
"projectTree.status.error": "error",
|
||||
"projectTree.status.divergedRecovery": "multiple recovery branches",
|
||||
"projectTree.removeProject": "Remove from sidebar",
|
||||
@@ -2588,7 +2609,7 @@ export const en = {
|
||||
"settings.addProvider.official.deepseek": "DeepSeek Official (Recommended)",
|
||||
"settings.addProvider.official.deepseekDesc": "Default Anthropic Messages API for Flash and Pro; server-side web search is enabled.",
|
||||
"settings.addProvider.preset.deepseekResponsesLabel": "DeepSeek Official Responses API",
|
||||
"settings.addProvider.preset.deepseekResponsesDesc": "Stateless Responses API for Flash; server-side web search is enabled and may add cost.",
|
||||
"settings.addProvider.preset.deepseekResponsesDesc": "Stateless Responses API for Flash and Pro; server-side web search is enabled and may add cost.",
|
||||
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat Platform OpenAI-compatible endpoint with LongCat-2.0 default.",
|
||||
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat Platform Anthropic-compatible Messages endpoint using Bearer auth.",
|
||||
"settings.addProvider.preset.tokenRhythmLabel": "Token Rhythm",
|
||||
|
||||
@@ -591,6 +591,10 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"composer.guidanceRetryMode": "重試",
|
||||
"composer.guidanceRetry": "重試這條引導",
|
||||
"composer.guidanceDismiss": "移除這條引導提示",
|
||||
"composer.guidanceEdit": "編輯這條待處理引導",
|
||||
"composer.guidanceSaveEdit": "儲存引導修改",
|
||||
"composer.guidanceCancelEdit": "取消編輯引導",
|
||||
"composer.guidanceEmptyPreview": "沒有預覽正文",
|
||||
"composer.guidanceMore": "更多引導選項",
|
||||
"composer.stop": "停止(Esc)",
|
||||
"composer.stopShort": "停止",
|
||||
@@ -987,13 +991,29 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"history.historySessionActions": "歷史會話操作",
|
||||
"history.trashActions": "回收站操作",
|
||||
|
||||
// 會話恢復
|
||||
"recovery.branch": "衝突副本",
|
||||
"recovery.badge": "副本",
|
||||
"recovery.noticeSavedCopy": "偵測到會話在磁碟上發生變化,未儲存的本機記錄已保留為衝突副本。",
|
||||
"recovery.noticeKeptCurrent": "偵測到重複儲存衝突,目前衝突副本已繼續儲存。",
|
||||
// 會話恢復(歷史進階入口用詞,避免暴露內部術語)
|
||||
"recovery.branch": "其他儲存版本",
|
||||
"recovery.badge": "版本",
|
||||
"recovery.noticeSavedCopy": "偵測到會話在磁碟上發生變化,未儲存的本機記錄已保留為其他儲存版本。",
|
||||
"recovery.noticeKeptCurrent": "偵測到重複儲存衝突,目前版本已單獨儲存。",
|
||||
"recovery.noticeAdopted": "偵測到會話在磁碟上發生變化,已採用較新的記錄。",
|
||||
"recovery.noticeAdoptedCovered": "偵測到會話在磁碟上發生變化,已採用較新的記錄;本機改動已包含其中。",
|
||||
"recovery.inspectLineage": "其他儲存版本",
|
||||
"recovery.lineageTitle": "其他儲存版本",
|
||||
"recovery.lineageSummary": "{branches} 個儲存版本 · {unresolved} 個含獨有內容",
|
||||
"recovery.lineageEmpty": "沒有其他儲存版本。",
|
||||
"recovery.previewCleanup": "檢查可安全清理項",
|
||||
"recovery.applyCleanup": "將已覆蓋版本移到垃圾桶",
|
||||
"recovery.cleanupConfirm": "{count} 個已被完整版本覆蓋的副本可以移到垃圾桶;含獨有內容或正在使用的版本會保留。",
|
||||
"recovery.cleanupResult": "已移動 {moved} 個;佔用 {busy} 個;保留 {kept} 個。",
|
||||
"recovery.inUse": "正在使用",
|
||||
"recovery.chooseBranch": "設為預設版本",
|
||||
"recovery.openVersion": "開啟此版本",
|
||||
"recovery.role.covered_copy": "已被更完整版本覆蓋",
|
||||
"recovery.role.adopted": "完整版本",
|
||||
"recovery.role.preferred": "預設版本",
|
||||
"recovery.role.diverged": "含獨有內容",
|
||||
"recovery.role.normal": "原始會話",
|
||||
|
||||
// 專案樹
|
||||
"projectTree.workspaceTitle": "專案",
|
||||
@@ -1749,7 +1769,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.addProvider.official.deepseek": "DeepSeek 官方(推薦)",
|
||||
"settings.addProvider.official.deepseekDesc": "Anthropic Messages API,支援 Flash / Pro,預設開啟網頁搜尋。",
|
||||
"settings.addProvider.preset.deepseekResponsesLabel": "DeepSeek 官方 Responses API",
|
||||
"settings.addProvider.preset.deepseekResponsesDesc": "無狀態 Responses API,僅支援 Flash,預設開啟網頁搜尋。",
|
||||
"settings.addProvider.preset.deepseekResponsesDesc": "無狀態 Responses API,支援 Flash / Pro,預設開啟網頁搜尋。",
|
||||
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat 開放平台 OpenAI-compatible 端點,預設 LongCat-2.0。",
|
||||
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat 開放平台 Anthropic-compatible Messages 端點,使用 Bearer 認證。",
|
||||
"settings.addProvider.preset.tokenRhythmLabel": "基元律动",
|
||||
@@ -2547,6 +2567,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"projectTree.status.waitingConfirmation": "待確認",
|
||||
"projectTree.status.backgroundJob": "背景任務",
|
||||
"projectTree.status.paused": "已暫停",
|
||||
"projectTree.status.awaitingDelivery": "待交付檢查",
|
||||
"projectTree.status.error": "異常",
|
||||
"projectTree.status.divergedRecovery": "多個復原分支",
|
||||
"projectTree.collapseAllTooltip": "收起所有專案",
|
||||
|
||||
@@ -795,6 +795,10 @@ export const zh: Record<DictKey, string> = {
|
||||
"composer.guidanceRetryMode": "重试",
|
||||
"composer.guidanceRetry": "重试这条引导",
|
||||
"composer.guidanceDismiss": "移除这条引导提示",
|
||||
"composer.guidanceEdit": "编辑这条待处理引导",
|
||||
"composer.guidanceSaveEdit": "保存引导修改",
|
||||
"composer.guidanceCancelEdit": "取消编辑引导",
|
||||
"composer.guidanceEmptyPreview": "没有预览正文",
|
||||
"composer.guidanceMore": "更多引导选项",
|
||||
"composer.shellMode": "Shell 模式:给输入前加 !",
|
||||
"composer.shellModeOn": "Shell 模式已开:点击移除 !",
|
||||
@@ -1245,12 +1249,28 @@ export const zh: Record<DictKey, string> = {
|
||||
"history.trashActions": "回收站操作",
|
||||
|
||||
// 会话恢复
|
||||
"recovery.branch": "冲突副本",
|
||||
"recovery.badge": "副本",
|
||||
"recovery.noticeSavedCopy": "检测到会话在磁盘上发生变化,未保存的本地记录已保留为冲突副本。",
|
||||
"recovery.noticeKeptCurrent": "检测到重复保存冲突,当前冲突副本已继续保存。",
|
||||
"recovery.branch": "其他保存版本",
|
||||
"recovery.badge": "版本",
|
||||
"recovery.noticeSavedCopy": "检测到会话在磁盘上发生变化,未保存的本地记录已保留为其他保存版本。",
|
||||
"recovery.noticeKeptCurrent": "检测到重复保存冲突,当前版本已单独保存。",
|
||||
"recovery.noticeAdopted": "检测到会话在磁盘上发生变化,已采用较新的记录。",
|
||||
"recovery.noticeAdoptedCovered": "检测到会话在磁盘上发生变化,已采用较新的记录;本地改动已包含其中。",
|
||||
"recovery.inspectLineage": "其他保存版本",
|
||||
"recovery.lineageTitle": "其他保存版本",
|
||||
"recovery.lineageSummary": "{branches} 个保存版本 · {unresolved} 个含独有内容",
|
||||
"recovery.lineageEmpty": "没有其他保存版本。",
|
||||
"recovery.previewCleanup": "检查可安全清理项",
|
||||
"recovery.applyCleanup": "将已覆盖版本移到回收站",
|
||||
"recovery.cleanupConfirm": "{count} 个已被完整版本覆盖的副本可以移到回收站;含独有内容或正在使用的版本会保留。",
|
||||
"recovery.cleanupResult": "已移动 {moved} 个;占用 {busy} 个;保留 {kept} 个。",
|
||||
"recovery.inUse": "正在使用",
|
||||
"recovery.chooseBranch": "设为默认版本",
|
||||
"recovery.openVersion": "打开此版本",
|
||||
"recovery.role.covered_copy": "已被更完整版本覆盖",
|
||||
"recovery.role.adopted": "完整版本",
|
||||
"recovery.role.preferred": "默认版本",
|
||||
"recovery.role.diverged": "含独有内容",
|
||||
"recovery.role.normal": "原始会话",
|
||||
|
||||
// 项目树
|
||||
"projectTree.workspaceTitle": "项目",
|
||||
@@ -1281,6 +1301,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"projectTree.status.waitingConfirmation": "待确认",
|
||||
"projectTree.status.backgroundJob": "后台任务",
|
||||
"projectTree.status.paused": "已暂停",
|
||||
"projectTree.status.awaitingDelivery": "待交付检查",
|
||||
"projectTree.status.error": "异常",
|
||||
"projectTree.status.divergedRecovery": "多个恢复分支",
|
||||
"projectTree.removeProject": "移出侧边栏",
|
||||
@@ -2591,7 +2612,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.addProvider.official.deepseek": "DeepSeek 官方(推荐)",
|
||||
"settings.addProvider.official.deepseekDesc": "Anthropic Messages API,支持 Flash / Pro,默认开启网页搜索。",
|
||||
"settings.addProvider.preset.deepseekResponsesLabel": "DeepSeek 官方 Responses API",
|
||||
"settings.addProvider.preset.deepseekResponsesDesc": "无状态 Responses API,仅支持 Flash,默认开启网页搜索。",
|
||||
"settings.addProvider.preset.deepseekResponsesDesc": "无状态 Responses API,支持 Flash / Pro,默认开启网页搜索。",
|
||||
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat 开放平台 OpenAI-compatible 端点,默认 LongCat-2.0。",
|
||||
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat 开放平台 Anthropic-compatible Messages 端点,使用 Bearer 认证。",
|
||||
"settings.addProvider.preset.tokenRhythmLabel": "基元律动",
|
||||
|
||||
@@ -3372,13 +3372,18 @@ a[href] {
|
||||
}
|
||||
}
|
||||
.transcript {
|
||||
/* Virtuoso sizes rows from the scroller clientWidth (padding included) but
|
||||
lays them out in the content box. Horizontal padding here therefore makes
|
||||
right-aligned bubbles extend under the workspace dock. Keep only vertical
|
||||
inset on the scroller; rows own --transcript-inline-pad. */
|
||||
--transcript-inline-pad: 32px;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-y: overlay;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
padding: 24px 32px 32px;
|
||||
padding: 24px 0 32px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
@@ -3415,11 +3420,17 @@ a[href] {
|
||||
.transcript__row {
|
||||
position: relative;
|
||||
display: flow-root;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding-left: var(--transcript-inline-pad, 32px);
|
||||
padding-right: var(--transcript-inline-pad, 32px);
|
||||
}
|
||||
.transcript__header {
|
||||
display: flow-root;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding-left: var(--transcript-inline-pad, 32px);
|
||||
padding-right: var(--transcript-inline-pad, 32px);
|
||||
}
|
||||
.transcript__header > * {
|
||||
max-width: var(--maxw);
|
||||
@@ -3458,7 +3469,8 @@ a[href] {
|
||||
.transcript__row .tool,
|
||||
.transcript__row .process-card,
|
||||
.transcript__row .readonly-batch,
|
||||
.transcript__row .tool-group {
|
||||
.transcript__row .tool-group,
|
||||
.transcript__row .md > * {
|
||||
content-visibility: visible;
|
||||
contain-intrinsic-size: none;
|
||||
}
|
||||
@@ -4727,6 +4739,16 @@ button.reasoning-summary:focus-visible {
|
||||
color: color-mix(in srgb, var(--fg-faint) 88%, transparent);
|
||||
}
|
||||
|
||||
.msg-search-sources {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
.msg-search-sources .md {
|
||||
color: var(--fg);
|
||||
}
|
||||
.msg-search-sources .md a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.msg-memory-citations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -14877,6 +14899,62 @@ body > .mermaid-diagram--fullscreen {
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recovery-lineage-dialog {
|
||||
width: min(680px, calc(100vw - 48px));
|
||||
height: auto;
|
||||
max-height: min(720px, calc(100vh - 64px));
|
||||
}
|
||||
.recovery-lineage-dialog .management-modal__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.recovery-lineage-dialog__body {
|
||||
min-height: 120px;
|
||||
overflow: auto;
|
||||
padding: 12px 18px;
|
||||
}
|
||||
.recovery-lineage-dialog__member {
|
||||
padding: 10px 2px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.recovery-lineage-dialog__member-name {
|
||||
color: var(--fg);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.recovery-lineage-dialog__member-meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
color: var(--fg-faint);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.recovery-lineage-dialog__choose {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.recovery-lineage-dialog__choose:disabled { opacity: 0.5; cursor: default; }
|
||||
.recovery-lineage-dialog__notice {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.recovery-lineage-dialog__actions {
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
@keyframes modal-in {
|
||||
0% {
|
||||
transform: translate3d(0, 10px, 0) scale(0.982);
|
||||
@@ -15073,7 +15151,8 @@ body > .mermaid-diagram--fullscreen {
|
||||
font-size: var(--font-control);
|
||||
}
|
||||
.history-preview__body .transcript {
|
||||
padding: 14px 14px 18px;
|
||||
--transcript-inline-pad: 14px;
|
||||
padding: 14px 0 18px;
|
||||
}
|
||||
.history-preview__body .transcript > * {
|
||||
max-width: none;
|
||||
@@ -20873,7 +20952,7 @@ body > .mermaid-diagram--fullscreen {
|
||||
.composer-guidance-item {
|
||||
min-height: 32px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
grid-template-columns: auto minmax(0, 1fr) repeat(3, auto);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 6px 5px 8px;
|
||||
@@ -20902,6 +20981,19 @@ body > .mermaid-diagram--fullscreen {
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.composer-guidance-item__editor {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.composer-guidance-item__guide {
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
@@ -21306,7 +21398,8 @@ body > .mermaid-diagram--fullscreen {
|
||||
display: flex;
|
||||
}
|
||||
.transcript {
|
||||
padding: 18px 16px 28px;
|
||||
--transcript-inline-pad: 16px;
|
||||
padding: 18px 0 28px;
|
||||
}
|
||||
.jump-bar {
|
||||
display: none;
|
||||
@@ -24312,6 +24405,12 @@ body > .mermaid-diagram--fullscreen {
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
|
||||
.project-tree__topic-status--awaiting_delivery {
|
||||
max-width: 80px;
|
||||
background: color-mix(in srgb, var(--warn) 13%, transparent);
|
||||
color: color-mix(in srgb, var(--warn) 82%, var(--fg));
|
||||
}
|
||||
|
||||
.project-tree__topic-status--error {
|
||||
background: color-mix(in srgb, var(--err) 12%, transparent);
|
||||
color: color-mix(in srgb, var(--err) 84%, var(--fg));
|
||||
@@ -26663,7 +26762,7 @@ body > .mermaid-diagram--fullscreen {
|
||||
}
|
||||
|
||||
.transcript {
|
||||
padding: 24px 32px 28px;
|
||||
padding: 24px 0 28px;
|
||||
}
|
||||
|
||||
.transcript > * {
|
||||
@@ -27745,6 +27844,13 @@ body > .mermaid-diagram--fullscreen {
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg-faint) 18%, transparent);
|
||||
}
|
||||
|
||||
:root[data-theme-style] .project-tree__topic-status--awaiting_delivery {
|
||||
max-width: 80px;
|
||||
background: color-mix(in srgb, var(--warn) 12%, var(--bg-elev));
|
||||
color: color-mix(in srgb, var(--warn) 82%, var(--fg));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--warn) 20%, transparent);
|
||||
}
|
||||
|
||||
:root[data-theme-style] .project-tree__topic-status--error {
|
||||
background: color-mix(in srgb, var(--err) 11%, var(--bg-elev));
|
||||
color: color-mix(in srgb, var(--err) 85%, var(--fg));
|
||||
@@ -27942,7 +28048,7 @@ body > .mermaid-diagram--fullscreen {
|
||||
}
|
||||
|
||||
:root[data-theme-style] .transcript {
|
||||
padding: 24px 32px 28px;
|
||||
padding: 24px 0 28px;
|
||||
}
|
||||
|
||||
:root[data-theme-style] .transcript--empty {
|
||||
@@ -28549,7 +28655,8 @@ body > .mermaid-diagram--fullscreen {
|
||||
}
|
||||
|
||||
:root[data-theme-style] .history-preview__body .transcript {
|
||||
padding: 14px;
|
||||
--transcript-inline-pad: 14px;
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
:root[data-theme-style] .settings-center__nav {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
func TestHistoryMessagesHideHostRecoverySteer(t *testing.T) {
|
||||
userPrompt := "请继续完成 PPT 任务,按昨天的素材清单生成。"
|
||||
userSteer := "改用 Pillow 10 验证"
|
||||
msgs := []provider.Message{
|
||||
{Role: provider.RoleUser, Content: userPrompt},
|
||||
{Role: provider.RoleAssistant, Content: "下一步:安装 pptxgenjs 并确认 Pillow。"},
|
||||
{Role: provider.RoleUser, Content: agent.MidTurnSteerPrefix + "\n" + agent.HostRecoveryGuidanceToolFailedPrefix + ", continue unrelated work automatically."},
|
||||
{Role: provider.RoleUser, Content: agent.MidTurnSteerPrefix + "\n" + userSteer},
|
||||
{Role: provider.RoleAssistant, Content: "pptxgenjs 已安装成功"},
|
||||
}
|
||||
got := historyMessages(msgs, func(content string) string { return content })
|
||||
var roles []string
|
||||
var contents []string
|
||||
for _, row := range got {
|
||||
roles = append(roles, row.Role)
|
||||
contents = append(contents, row.Content)
|
||||
if strings.Contains(row.Content, "A tool failed") || strings.Contains(row.Content, "Use read-only diagnosis") {
|
||||
t.Fatalf("host recovery guidance leaked into history: %+v", row)
|
||||
}
|
||||
}
|
||||
if strings.Join(roles, ",") != "user,assistant,notice,assistant" {
|
||||
t.Fatalf("history roles = %s, want user,assistant,notice,assistant", strings.Join(roles, ","))
|
||||
}
|
||||
if contents[0] != userPrompt {
|
||||
t.Fatalf("user prompt = %q, want original task prompt", contents[0])
|
||||
}
|
||||
if contents[2] != "↪ "+userSteer {
|
||||
t.Fatalf("user steer = %q, want visible mid-turn steer", contents[2])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import "reasonix/internal/provider"
|
||||
|
||||
// historyServerSearch copies card-visible search fields and drops encrypted
|
||||
// replay payloads. Those stay on the session message for the next provider turn.
|
||||
func historyServerSearch(in []provider.ServerSearchCall) []provider.ServerSearchCall {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]provider.ServerSearchCall, 0, len(in))
|
||||
for _, search := range in {
|
||||
copied := provider.ServerSearchCall{ID: search.ID, Query: search.Query}
|
||||
if len(search.Results) > 0 {
|
||||
copied.Results = append([]provider.ServerSearchHit(nil), search.Results...)
|
||||
}
|
||||
out = append(out, copied)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
func TestHistoryMessagesIncludeServerSearch(t *testing.T) {
|
||||
got := historyMessages([]provider.Message{{
|
||||
Role: provider.RoleAssistant,
|
||||
Content: "answer only",
|
||||
ServerSearch: []provider.ServerSearchCall{{
|
||||
ID: "s1", Query: "bitcoin",
|
||||
Results: []provider.ServerSearchHit{{Title: "新闻本文", URL: "https://example.com/a"}},
|
||||
Raw: json.RawMessage(`[{"encrypted_content":"xxx"}]`),
|
||||
}},
|
||||
}}, func(content string) string { return content })
|
||||
if len(got) != 1 || got[0].Content != "answer only" {
|
||||
t.Fatalf("history = %+v", got)
|
||||
}
|
||||
if len(got[0].ServerSearch) != 1 || got[0].ServerSearch[0].ID != "s1" || got[0].ServerSearch[0].Query != "bitcoin" {
|
||||
t.Fatalf("server search = %+v", got[0].ServerSearch)
|
||||
}
|
||||
if len(got[0].ServerSearch[0].Raw) != 0 {
|
||||
t.Fatalf("history view should omit encrypted replay payload, got %s", got[0].ServerSearch[0].Raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
func historySteerRows(content string, unapplied bool) ([]HistoryMessage, bool) {
|
||||
text, handled := agent.ReplaySteerText(content)
|
||||
if !handled {
|
||||
return nil, false
|
||||
}
|
||||
if text == "" {
|
||||
return nil, true
|
||||
}
|
||||
if unapplied {
|
||||
return []HistoryMessage{{
|
||||
Role: "notice",
|
||||
Content: agent.UnappliedSteerNotice(text),
|
||||
Code: event.NoticeCodeUnappliedSteer,
|
||||
Level: "warn",
|
||||
}}, true
|
||||
}
|
||||
return []HistoryMessage{{Role: "notice", Content: "↪ " + text}}, true
|
||||
}
|
||||
+20
-18
@@ -73,15 +73,16 @@ type InboxItemView struct {
|
||||
|
||||
// InboxSnapshotView is the Wails-facing queue snapshot.
|
||||
type InboxSnapshotView struct {
|
||||
Revision int64 `json:"revision"`
|
||||
Paused bool `json:"paused"`
|
||||
Recovered bool `json:"recovered"`
|
||||
RecoveredN int `json:"recoveredCount,omitempty"`
|
||||
Items []InboxItemView `json:"items"`
|
||||
ItemsCount int `json:"itemsCount"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
MaxItems int `json:"maxItems"`
|
||||
MaxBytes int64 `json:"maxBytes"`
|
||||
Revision int64 `json:"revision"`
|
||||
Paused bool `json:"paused"`
|
||||
Recovered bool `json:"recovered"`
|
||||
RecoveredN int `json:"recoveredCount,omitempty"`
|
||||
SessionPath string `json:"sessionPath,omitempty"`
|
||||
Items []InboxItemView `json:"items"`
|
||||
ItemsCount int `json:"itemsCount"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
MaxItems int `json:"maxItems"`
|
||||
MaxBytes int64 `json:"maxBytes"`
|
||||
}
|
||||
|
||||
// InboxReceiptView is returned after durable enqueue/steer.
|
||||
@@ -118,15 +119,16 @@ func inboxSnapshotView(snap sessioninbox.InboxSnapshot) InboxSnapshotView {
|
||||
})
|
||||
}
|
||||
return InboxSnapshotView{
|
||||
Revision: snap.Revision,
|
||||
Paused: snap.Paused,
|
||||
Recovered: snap.Recovered,
|
||||
RecoveredN: snap.RecoveredN,
|
||||
Items: items,
|
||||
ItemsCount: len(items),
|
||||
Bytes: snap.Capacity.Bytes,
|
||||
MaxItems: snap.Capacity.MaxItems,
|
||||
MaxBytes: snap.Capacity.MaxBytes,
|
||||
Revision: snap.Revision,
|
||||
Paused: snap.Paused,
|
||||
Recovered: snap.Recovered,
|
||||
RecoveredN: snap.RecoveredN,
|
||||
SessionPath: snap.SessionPath,
|
||||
Items: items,
|
||||
ItemsCount: len(items),
|
||||
Bytes: snap.Capacity.Bytes,
|
||||
MaxItems: snap.Capacity.MaxItems,
|
||||
MaxBytes: snap.Capacity.MaxBytes,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,25 @@ func TestSteerInboxItemPausedReturnsStableCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnqueueInboxSteerWhenPausedQueuesFollowup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctrl := control.New(control.Options{
|
||||
SessionDir: dir, SessionPath: filepath.Join(dir, "session.jsonl"), Sink: event.Discard,
|
||||
})
|
||||
if err := ctrl.SetInboxPaused(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp()
|
||||
app.setTestCtrl(ctrl, "")
|
||||
receipt, err := app.EnqueueInboxSteer("test", "guide later", "guide later", "paused-enqueue-steer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if receipt.Disposition != string(sessioninbox.DispositionQueuedFollowup) || !receipt.Paused {
|
||||
t.Fatalf("EnqueueInboxSteer receipt = %+v, want queued_followup while paused", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableInvocationFollowupPreservesEmptyExplicitTask(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctrl := control.New(control.Options{
|
||||
|
||||
+66
-89
@@ -2,67 +2,21 @@ package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/sessioncatalog"
|
||||
)
|
||||
|
||||
// recoveryGCInterval bounds how often the background sweep repeats after the
|
||||
// startup run. Covered copies are also reclaimed by the shorter startup grace.
|
||||
const recoveryGCInterval = 6 * time.Hour
|
||||
|
||||
// recoveryGCFollowUpDelay re-runs the short-grace sweep once after startup so
|
||||
// branches that just crossed the 15-minute idle line are not left until the
|
||||
// next 6-hour tick.
|
||||
const recoveryGCFollowUpDelay = 20 * time.Minute
|
||||
|
||||
// startRecoveryGC waits for tab restore, runs a short-grace startup sweep (and
|
||||
// one follow-up), then repeats on the long interval until cancelled. The wait
|
||||
// is load-bearing: a pre-restore sweep sees every saved tab as closed, letting
|
||||
// DeleteSession overwrite desktop-tabs.json with an empty snapshot.
|
||||
func (a *App) startRecoveryGC() {
|
||||
a.goSafe("recoveryGC", func() {
|
||||
select {
|
||||
case <-a.tabsRestoredSignal():
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
}
|
||||
startup := time.NewTimer(agent.RecoveryGCStartupGracePeriod)
|
||||
if !waitRecoveryGCStartup(a.ctx.Done(), startup.C) {
|
||||
if !startup.Stop() {
|
||||
select {
|
||||
case <-startup.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// Protect every upgraded user for a full startup grace before reclaiming.
|
||||
a.sweepReclaimableRecoveryBranchesWithGrace(agent.RecoveryGCStartupGracePeriod)
|
||||
followUp := time.NewTimer(recoveryGCFollowUpDelay)
|
||||
ticker := time.NewTicker(recoveryGCInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-a.ctx.Done():
|
||||
if !followUp.Stop() {
|
||||
select {
|
||||
case <-followUp.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return
|
||||
case <-followUp.C:
|
||||
// Second short-grace pass, then fall through to long-interval only.
|
||||
a.sweepReclaimableRecoveryBranchesWithGrace(agent.RecoveryGCStartupGracePeriod)
|
||||
followUp.C = nil
|
||||
case <-ticker.C:
|
||||
a.sweepReclaimableRecoveryBranches()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// startRecoveryGC is intentionally a no-op for physical moves.
|
||||
//
|
||||
// Catalog v4 folds recovery lineages into one ordinary list row. Automatic
|
||||
// startup/upgrade/timer GC must not move JSONL/meta into trash — only explicit
|
||||
// CleanRecoveryLineage (UI, with preview) and `reasonix sessions cleanup
|
||||
// --apply` may reclaim covered copies. reclaimRecoveryBranchesIn remains for
|
||||
// those explicit entry points and focused tests.
|
||||
func (a *App) startRecoveryGC() {}
|
||||
|
||||
func waitRecoveryGCStartup(done <-chan struct{}, elapsed <-chan time.Time) bool {
|
||||
select {
|
||||
@@ -73,31 +27,35 @@ func waitRecoveryGCStartup(done <-chan struct{}, elapsed <-chan time.Time) bool
|
||||
}
|
||||
}
|
||||
|
||||
// sweepReclaimableRecoveryBranches trashes conflict-recovery branches that
|
||||
// preserve nothing unique (their content is covered by a still-present parent
|
||||
// session), were never continued on, sat idle past the grace period, and are
|
||||
// not held by any runtime. Trashing — never hard deletion — keeps every swept
|
||||
// branch recoverable from the session trash. Returns how many were reclaimed.
|
||||
func (a *App) sweepReclaimableRecoveryBranches() int {
|
||||
return a.sweepReclaimableRecoveryBranchesWithGrace(agent.RecoveryGCGracePeriod)
|
||||
}
|
||||
|
||||
func (a *App) sweepReclaimableRecoveryBranchesWithGrace(grace time.Duration) int {
|
||||
return a.reclaimRecoveryBranchesIn(recoveryGCDirs(), time.Now(), grace)
|
||||
}
|
||||
|
||||
func (a *App) reclaimRecoveryBranchesIn(dirs []string, now time.Time, grace time.Duration) int {
|
||||
if grace <= 0 {
|
||||
grace = agent.RecoveryGCGracePeriod
|
||||
}
|
||||
reclaimed := 0
|
||||
for _, dir := range dirs {
|
||||
removed := map[string]bool{}
|
||||
if catalog := a.sessionCatalog.Load(); catalog != nil {
|
||||
groups, err := catalog.ListRecoveryGroups(a.bootContext(), dir)
|
||||
if err != nil {
|
||||
slog.Warn("desktop: list recovery lineages for GC", "dir", dir, "err", err)
|
||||
} else {
|
||||
for _, group := range groups {
|
||||
for _, path := range a.reclaimAdoptedRecoveryGroup(group, now, grace) {
|
||||
removed[sessionRuntimeKey(path)] = true
|
||||
reclaimed++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
reclaimable, err := agent.ReclaimableRecoveryBranches(dir, now, grace)
|
||||
if err != nil {
|
||||
slog.Warn("desktop: scan reclaimable recovery branches", "dir", dir, "err", err)
|
||||
continue
|
||||
}
|
||||
for _, path := range reclaimable {
|
||||
if removed[sessionRuntimeKey(path)] {
|
||||
continue
|
||||
}
|
||||
// Re-check liveness right before disposal: the scan is a snapshot,
|
||||
// and the user may have opened the branch since.
|
||||
if agent.SessionLeaseHeld(path) || a.sessionOpenInAnyTab(path) {
|
||||
@@ -120,29 +78,48 @@ func (a *App) reclaimRecoveryBranchesIn(dirs []string, now time.Time, grace time
|
||||
return reclaimed
|
||||
}
|
||||
|
||||
// recoveryGCDirs returns every session directory the desktop lists sessions
|
||||
// from: the global desktop and legacy shared dirs plus each saved project's
|
||||
// session dirs, deduplicated.
|
||||
func recoveryGCDirs() []string {
|
||||
seen := map[string]bool{}
|
||||
var dirs []string
|
||||
add := func(dir string) {
|
||||
key := projectRootKey(dir)
|
||||
if dir == "" || seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
dirs = append(dirs, dir)
|
||||
// reclaimAdoptedRecoveryGroup compacts an entire legacy recovery chain once a
|
||||
// canonical leaf has proved it covers every member. Every candidate is still
|
||||
// revalidated under removal guards immediately before it is moved to trash.
|
||||
func (a *App) reclaimAdoptedRecoveryGroup(group sessioncatalog.RecoveryGroup, now time.Time, grace time.Duration) []string {
|
||||
if group.State != "adopted" || group.CanonicalPath == "" || group.ID == "" {
|
||||
return nil
|
||||
}
|
||||
add(desktopSessionDir(globalWorkspaceRoot()))
|
||||
add(config.SessionDir())
|
||||
for _, project := range loadProjectsFile().Projects {
|
||||
if root := normalizeProjectRoot(project.Root); root != "" {
|
||||
add(desktopSessionDir(root))
|
||||
add(config.ProjectSessionDir(root))
|
||||
candidates := []string{}
|
||||
for _, member := range group.Members {
|
||||
if member.Path == group.CanonicalPath || member.RecoveryRole != sessioncatalog.RecoveryRoleCoveredCopy {
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(member.Path)
|
||||
if err != nil || now.Sub(info.ModTime()) < grace {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, member.Path)
|
||||
}
|
||||
return dirs
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
defer a.lockRuntimeMutation("gc-recovery-lineage")()
|
||||
a.sessionRemovalMu.Lock()
|
||||
defer a.sessionRemovalMu.Unlock()
|
||||
if a.sessionOpenInAnyTab(group.CanonicalPath) || agent.SessionLeaseHeld(group.CanonicalPath) {
|
||||
return nil
|
||||
}
|
||||
if err := agent.ReparentRecoveryCanonical(group.CanonicalPath, group.ID, group.Directory); err != nil {
|
||||
return nil
|
||||
}
|
||||
moved := []string{}
|
||||
for _, path := range candidates {
|
||||
if a.sessionOpenInAnyTab(path) || agent.SessionLeaseHeld(path) {
|
||||
continue
|
||||
}
|
||||
if err := agent.TrashRecoveryBranchCoveredBy(path, group.CanonicalPath, group.Directory); err != nil {
|
||||
continue
|
||||
}
|
||||
moved = append(moved, path)
|
||||
a.removeSessionCatalogPath(path, "recovery_lineage_gc")
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
// sessionOpenInAnyTab reports whether any tab's current session is path.
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/sessioncatalog"
|
||||
)
|
||||
|
||||
type RecoveryLineageMember struct {
|
||||
Path string `json:"path"`
|
||||
Role string `json:"role"`
|
||||
Canonical bool `json:"canonical"`
|
||||
Turns int `json:"turns"`
|
||||
Open bool `json:"open"`
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
|
||||
type RecoveryLineageView struct {
|
||||
GroupID string `json:"groupId"`
|
||||
State string `json:"state"`
|
||||
BranchCount int `json:"branchCount"`
|
||||
Unresolved int `json:"unresolved"`
|
||||
CleanupEligible int `json:"cleanupEligible"`
|
||||
Members []RecoveryLineageMember `json:"members"`
|
||||
}
|
||||
|
||||
type RecoveryCleanupRequest struct {
|
||||
Scope string `json:"scope"`
|
||||
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
|
||||
TopicID string `json:"topicId"`
|
||||
Apply bool `json:"apply"`
|
||||
}
|
||||
|
||||
type RecoveryPreferenceRequest struct {
|
||||
Scope string `json:"scope"`
|
||||
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
|
||||
TopicID string `json:"topicId"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type RecoveryCleanupItem struct {
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type RecoveryCleanupResult struct {
|
||||
Eligible int `json:"eligible"`
|
||||
Moved int `json:"moved"`
|
||||
Busy int `json:"busy"`
|
||||
Kept int `json:"kept"`
|
||||
DryRun bool `json:"dryRun"`
|
||||
Items []RecoveryCleanupItem `json:"items"`
|
||||
}
|
||||
|
||||
func (a *App) GetRecoveryLineage(key ProjectTopicKey) RecoveryLineageView {
|
||||
out := RecoveryLineageView{Members: []RecoveryLineageMember{}}
|
||||
catalog := a.sessionCatalog.Load()
|
||||
if catalog == nil {
|
||||
return out
|
||||
}
|
||||
topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{Scope: key.Scope, WorkspaceRoot: key.WorkspaceRoot, TopicID: key.TopicID})
|
||||
if err != nil || !ok {
|
||||
return out
|
||||
}
|
||||
groupID, directory := "", ""
|
||||
for _, record := range topic.Sessions {
|
||||
if record.Recovered && record.RecoveryGroupID != "" {
|
||||
groupID, directory = record.RecoveryGroupID, filepath.Dir(record.Path)
|
||||
break
|
||||
}
|
||||
}
|
||||
if groupID == "" || directory == "" {
|
||||
return out
|
||||
}
|
||||
groups, err := catalog.ListRecoveryGroups(a.bootContext(), directory)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
members := []sessioncatalog.SessionRecord{}
|
||||
for _, group := range groups {
|
||||
if group.ID == groupID {
|
||||
members = group.Members
|
||||
out.State = group.State
|
||||
break
|
||||
}
|
||||
}
|
||||
out.GroupID = groupID
|
||||
_, overlays := a.catalogRuntimeOverlays()
|
||||
for _, record := range members {
|
||||
overlay := overlays[sessionRuntimeKey(record.Path)]
|
||||
out.Members = append(out.Members, RecoveryLineageMember{Path: record.Path, Role: record.RecoveryRole,
|
||||
Canonical: record.RecoveryCanonical, Turns: record.Turns, Open: overlay.open, Running: overlay.running})
|
||||
out.BranchCount++
|
||||
if record.RecoveryRole == sessioncatalog.RecoveryRoleDiverged {
|
||||
out.Unresolved++
|
||||
}
|
||||
if record.RecoveryRole == sessioncatalog.RecoveryRoleCoveredCopy {
|
||||
out.CleanupEligible++
|
||||
}
|
||||
}
|
||||
if out.State == "" {
|
||||
out.State = topic.RecoveryState
|
||||
}
|
||||
if out.State == "preferred" {
|
||||
out.Unresolved = 0
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ChooseRecoveryBranch changes only the default open target. Diverged content
|
||||
// remains on disk and is never made cleanup-eligible by this choice.
|
||||
func (a *App) ChooseRecoveryBranch(req RecoveryPreferenceRequest) error {
|
||||
catalog := a.sessionCatalog.Load()
|
||||
if catalog == nil {
|
||||
return errors.New("session catalog is unavailable")
|
||||
}
|
||||
topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, TopicID: req.TopicID})
|
||||
if err != nil || !ok {
|
||||
return errors.New("recovery lineage is unavailable")
|
||||
}
|
||||
groupID, dir := "", ""
|
||||
for _, record := range topic.Sessions {
|
||||
if record.Recovered && record.RecoveryGroupID != "" {
|
||||
groupID, dir = record.RecoveryGroupID, filepath.Dir(record.Path)
|
||||
break
|
||||
}
|
||||
}
|
||||
groups, err := catalog.ListRecoveryGroups(a.bootContext(), dir)
|
||||
if err != nil {
|
||||
return errors.New("recovery lineage is unavailable")
|
||||
}
|
||||
paths := []string{}
|
||||
chosen := ""
|
||||
for _, group := range groups {
|
||||
if group.ID != groupID {
|
||||
continue
|
||||
}
|
||||
for _, member := range group.Members {
|
||||
paths = append(paths, member.Path)
|
||||
if sessionRuntimeKey(member.Path) == sessionRuntimeKey(req.Path) && member.RecoveryRole != sessioncatalog.RecoveryRoleCoveredCopy {
|
||||
chosen = member.Path
|
||||
}
|
||||
}
|
||||
}
|
||||
if chosen == "" {
|
||||
return errors.New("selected branch is outside the recovery lineage")
|
||||
}
|
||||
defer a.lockRuntimeMutation("choose-recovery-branch")()
|
||||
a.sessionRemovalMu.Lock()
|
||||
defer a.sessionRemovalMu.Unlock()
|
||||
if err := agent.SetRecoveryPreferred(paths, chosen); err != nil {
|
||||
return errors.New("could not save the recovery branch choice")
|
||||
}
|
||||
if err := catalog.ReconcileDirectory(a.bootContext(), sessioncatalog.DirectoryTarget{Path: dir, Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot}); err != nil {
|
||||
return errors.New("the branch choice was saved but the session catalog could not refresh")
|
||||
}
|
||||
a.emitProjectTreeChangedForSessionDirs(dir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanRecoveryLineage performs one backend-owned, revalidated cleanup batch.
|
||||
// It never purges and never moves diverged content.
|
||||
func (a *App) CleanRecoveryLineage(req RecoveryCleanupRequest) RecoveryCleanupResult {
|
||||
result := RecoveryCleanupResult{DryRun: !req.Apply, Items: []RecoveryCleanupItem{}}
|
||||
catalog := a.sessionCatalog.Load()
|
||||
if catalog == nil {
|
||||
return result
|
||||
}
|
||||
topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, TopicID: req.TopicID})
|
||||
if err != nil || !ok {
|
||||
return result
|
||||
}
|
||||
canonical := ""
|
||||
rootID := ""
|
||||
for _, record := range topic.Sessions {
|
||||
if record.RecoveryCanonical && (record.RecoveryRole == sessioncatalog.RecoveryRoleAdopted || record.RecoveryRole == sessioncatalog.RecoveryRolePreferred) {
|
||||
canonical = record.Path
|
||||
rootID = record.RecoveryGroupID
|
||||
break
|
||||
}
|
||||
}
|
||||
if canonical == "" || rootID == "" {
|
||||
return result
|
||||
}
|
||||
dir := filepath.Dir(canonical)
|
||||
groups, err := catalog.ListRecoveryGroups(a.bootContext(), dir)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
members := []sessioncatalog.SessionRecord{}
|
||||
for _, group := range groups {
|
||||
if group.ID == rootID {
|
||||
members = group.Members
|
||||
break
|
||||
}
|
||||
}
|
||||
candidates := []sessioncatalog.SessionRecord{}
|
||||
for _, record := range members {
|
||||
if record.Path == canonical || record.RecoveryRole != sessioncatalog.RecoveryRoleCoveredCopy {
|
||||
continue
|
||||
}
|
||||
result.Eligible++
|
||||
candidates = append(candidates, record)
|
||||
result.Items = append(result.Items, RecoveryCleanupItem{Path: record.Path, Status: "eligible"})
|
||||
}
|
||||
if !req.Apply || len(candidates) == 0 {
|
||||
return result
|
||||
}
|
||||
defer a.lockRuntimeMutation("clean-recovery-lineage")()
|
||||
a.sessionRemovalMu.Lock()
|
||||
defer a.sessionRemovalMu.Unlock()
|
||||
if a.sessionOpenInAnyTab(canonical) || agent.SessionLeaseHeld(canonical) {
|
||||
for index := range result.Items {
|
||||
result.Items[index].Status = "busy"
|
||||
result.Busy++
|
||||
}
|
||||
return result
|
||||
}
|
||||
if err := agent.ReparentRecoveryCanonical(canonical, rootID, dir); err != nil {
|
||||
for index := range result.Items {
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
result.Items[index].Status = "busy"
|
||||
result.Busy++
|
||||
} else {
|
||||
result.Items[index].Status = "kept"
|
||||
result.Items[index].Error = "recovery branch changed and was kept"
|
||||
result.Kept++
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
for index, record := range candidates {
|
||||
item := &result.Items[index]
|
||||
if a.sessionOpenInAnyTab(record.Path) || agent.SessionLeaseHeld(record.Path) {
|
||||
item.Status = "busy"
|
||||
result.Busy++
|
||||
continue
|
||||
}
|
||||
if err := agent.TrashRecoveryBranchCoveredBy(record.Path, canonical, dir); err != nil {
|
||||
item.Status = "kept"
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
item.Status = "busy"
|
||||
result.Busy++
|
||||
} else {
|
||||
item.Error = "recovery branch changed and was kept"
|
||||
result.Kept++
|
||||
}
|
||||
} else {
|
||||
item.Status = "moved"
|
||||
result.Moved++
|
||||
a.removeSessionCatalogPath(record.Path, "recovery_lineage_cleaned")
|
||||
}
|
||||
}
|
||||
if result.Moved > 0 {
|
||||
a.emitProjectTreeChangedForSessionDirs(dir)
|
||||
a.invalidatePromptHistoryCache()
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/sessioncatalog"
|
||||
)
|
||||
|
||||
@@ -26,24 +28,188 @@ func (a *App) resolveCanonicalSessionPath(path string) string {
|
||||
}
|
||||
if rec.TopicID == "" {
|
||||
// Group by recovery group when topic is unset.
|
||||
if rec.RecoveryCanonical && rec.RecoveryRole == sessioncatalog.RecoveryRoleAdopted {
|
||||
if rec.RecoveryCanonical && (rec.RecoveryRole == sessioncatalog.RecoveryRoleAdopted || rec.RecoveryRole == sessioncatalog.RecoveryRolePreferred) {
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
page, err := catalog.ListSessions(ctx, sessioncatalog.SessionPageRequest{
|
||||
Scope: rec.Scope,
|
||||
WorkspaceRoot: rec.WorkspaceRoot,
|
||||
Limit: sessioncatalog.MaxLimit,
|
||||
})
|
||||
if err != nil {
|
||||
topic, ok, err := catalog.GetTopic(ctx, sessioncatalog.TopicKey{Scope: rec.Scope, WorkspaceRoot: rec.WorkspaceRoot, TopicID: rec.TopicID})
|
||||
if err != nil || !ok {
|
||||
return ""
|
||||
}
|
||||
var sameTopic []sessioncatalog.SessionRecord
|
||||
for _, s := range page.Items {
|
||||
if s.TopicID == rec.TopicID && s.Scope == rec.Scope && s.WorkspaceRoot == rec.WorkspaceRoot {
|
||||
sameTopic = append(sameTopic, s)
|
||||
return sessioncatalog.CanonicalSessionPathForTopic(topic.Sessions, path)
|
||||
}
|
||||
|
||||
func (a *App) resolveOpenTopicSessionPath(scope, workspaceRoot, sessionPath string) (string, string) {
|
||||
actualRoot := workspaceRoot
|
||||
if scope == "global" {
|
||||
actualRoot = globalWorkspaceRoot()
|
||||
}
|
||||
// Keep a live controller on this path (including paused). Opening a
|
||||
// different ordinary session of the same topic must still switch.
|
||||
if continued := a.continuePathForOpen(sessionPath); continued != "" {
|
||||
if a.sessionHasLiveController(sessionPath) {
|
||||
return actualRoot, sessionPath
|
||||
}
|
||||
sessionPath = continued
|
||||
}
|
||||
return actualRoot, sessionPath
|
||||
}
|
||||
|
||||
func (a *App) sessionHasLiveController(path string) bool {
|
||||
key := sessionRuntimeKey(path)
|
||||
if a == nil || key == "" {
|
||||
return false
|
||||
}
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
for _, tabs := range []map[string]*WorkspaceTab{a.tabs, a.detachedSessions} {
|
||||
for _, tab := range tabs {
|
||||
if tab != nil && tab.Ctrl != nil && sessionRuntimeKey(tab.currentSessionPath()) == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) skipCoveringLeafRebind(tab *WorkspaceTab, target string) bool {
|
||||
if tab == nil || tab.Ctrl == nil {
|
||||
return false
|
||||
}
|
||||
next := a.continuePathForOpen(tab.currentSessionPath())
|
||||
return next != "" && sessionRuntimeKey(next) == sessionRuntimeKey(target)
|
||||
}
|
||||
|
||||
func (a *App) continuePathForOpen(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
catalog := a.sessionCatalog.Load()
|
||||
if catalog == nil {
|
||||
return ""
|
||||
}
|
||||
ctx := context.Background()
|
||||
rec, ok, err := catalog.GetSession(ctx, path)
|
||||
if err != nil || !ok {
|
||||
return a.continuePathForMissingParent(ctx, catalog, path)
|
||||
}
|
||||
if rec.TopicID == "" {
|
||||
return ""
|
||||
}
|
||||
topic, ok, err := catalog.GetTopic(ctx, sessioncatalog.TopicKey{Scope: rec.Scope, WorkspaceRoot: rec.WorkspaceRoot, TopicID: rec.TopicID})
|
||||
if err != nil || !ok {
|
||||
return ""
|
||||
}
|
||||
return sessioncatalog.OrdinaryContinuePath(topic.Sessions, path)
|
||||
}
|
||||
|
||||
func (a *App) continuePathForMissingParent(ctx context.Context, catalog *sessioncatalog.Catalog, path string) string {
|
||||
parentID := agent.BranchID(path)
|
||||
if parentID == "" {
|
||||
return ""
|
||||
}
|
||||
// desktop-tabs.json may still name a parent that lineage folded off the
|
||||
// ordinary row. Look up the topic by the filename id.
|
||||
for _, target := range a.sessionCatalogTargets() {
|
||||
page, err := catalog.ListTopics(ctx, sessioncatalog.TopicPageRequest{
|
||||
Scope: target.Scope, WorkspaceRoot: target.WorkspaceRoot, Limit: sessioncatalog.MaxLimit,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, topic := range page.Items {
|
||||
for _, session := range topic.Sessions {
|
||||
if session.ParentID == parentID || strings.TrimSpace(session.RecoveryGroupID) == parentID {
|
||||
if next := sessioncatalog.OrdinaryContinuePath(topic.Sessions, path); next != "" {
|
||||
return next
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
if continued := a.continuePathForOpen(path); continued != "" {
|
||||
current := tab.currentSessionPath()
|
||||
if !tab.hasActiveRuntimeWork() || sessionRuntimeKey(current) != sessionRuntimeKey(path) {
|
||||
path = continued
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (a *App) retargetOpenTabsToCoveringLeaves() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
type candidate struct {
|
||||
tab *WorkspaceTab
|
||||
current string
|
||||
}
|
||||
a.mu.RLock()
|
||||
items := make([]candidate, 0, len(a.tabs)+len(a.detachedSessions))
|
||||
collect := func(tab *WorkspaceTab) {
|
||||
if tab == nil || tab.hasActiveRuntimeWork() {
|
||||
return
|
||||
}
|
||||
items = append(items, candidate{tab: tab, current: tab.currentSessionPath()})
|
||||
}
|
||||
for _, tab := range a.tabs {
|
||||
collect(tab)
|
||||
}
|
||||
for _, tab := range a.detachedSessions {
|
||||
collect(tab)
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
type pending struct {
|
||||
tab *WorkspaceTab
|
||||
next string
|
||||
}
|
||||
ready := make([]pending, 0, len(items))
|
||||
for _, item := range items {
|
||||
next := a.continuePathForOpen(item.current)
|
||||
if next == "" || sessionRuntimeKey(next) == sessionRuntimeKey(item.current) {
|
||||
continue
|
||||
}
|
||||
ready = append(ready, pending{tab: item.tab, next: next})
|
||||
}
|
||||
for _, item := range ready {
|
||||
if item.tab.hasActiveRuntimeWork() {
|
||||
continue
|
||||
}
|
||||
if item.tab.Ctrl == nil {
|
||||
a.mu.Lock()
|
||||
if !item.tab.hasActiveRuntimeWork() && (a.tabs[item.tab.ID] == item.tab || a.detachedSessions[sessionRuntimeKey(item.tab.currentSessionPath())] == item.tab) {
|
||||
item.tab.SessionPath = item.next
|
||||
a.saveTabsLocked()
|
||||
}
|
||||
a.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
if err := a.rebindTabToSessionPath(item.tab, item.next); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return sessioncatalog.CanonicalSessionPathForTopic(sameTopic, path)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,6 +17,8 @@ import (
|
||||
"reasonix/internal/taskcatalog"
|
||||
)
|
||||
|
||||
const sessionCatalogMetadataSyncTimeout = 30 * time.Second
|
||||
|
||||
type SessionCatalogStatus struct {
|
||||
State string `json:"state"`
|
||||
Mode string `json:"mode"`
|
||||
@@ -155,7 +158,7 @@ func (a *App) startSessionCatalog(rebuild bool) {
|
||||
return
|
||||
}
|
||||
a.sessionCatalog.Store(catalog)
|
||||
if err := a.syncSessionCatalogMetadata(ctx, catalog); err != nil && !errors.Is(err, context.Canceled) {
|
||||
if err := a.syncSessionCatalogMetadataBounded(ctx, catalog); err != nil && !errors.Is(err, context.Canceled) {
|
||||
slog.Warn("desktop: sync session catalog metadata", "err", err)
|
||||
}
|
||||
select {
|
||||
@@ -171,23 +174,24 @@ func (a *App) startSessionCatalog(rebuild bool) {
|
||||
// Legacy assignment is deliberately background-only. It can scan and
|
||||
// repair old metadata, but no project-tree or controller request waits.
|
||||
if migrated := migrateLegacySessionsIntoGlobalTopics(target.Path); len(migrated) > 0 {
|
||||
_ = a.syncSessionCatalogMetadata(ctx, catalog)
|
||||
_ = a.syncSessionCatalogMetadataBounded(ctx, catalog)
|
||||
}
|
||||
if err := catalog.ReconcileDirectory(ctx, target); err != nil && !errors.Is(err, context.Canceled) {
|
||||
slog.Debug("desktop: reconcile session catalog directory", "dir", target.Path, "err", err)
|
||||
}
|
||||
}
|
||||
a.retargetOpenTabsToCoveringLeaves()
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := a.syncSessionCatalogMetadata(ctx, catalog); err != nil && !errors.Is(err, context.Canceled) {
|
||||
if err := a.syncSessionCatalogMetadataBounded(ctx, catalog); err != nil && !errors.Is(err, context.Canceled) {
|
||||
slog.Debug("desktop: refresh session catalog metadata", "err", err)
|
||||
}
|
||||
for _, target := range a.sessionCatalogTargets() {
|
||||
if migrated := migrateLegacySessionsIntoGlobalTopics(target.Path); len(migrated) > 0 {
|
||||
_ = a.syncSessionCatalogMetadata(ctx, catalog)
|
||||
_ = a.syncSessionCatalogMetadataBounded(ctx, catalog)
|
||||
}
|
||||
catalog.RequestReconcile(target)
|
||||
}
|
||||
@@ -322,6 +326,17 @@ func (a *App) indexRestoredSessionPaths(ctx context.Context, catalog *sessioncat
|
||||
}
|
||||
}
|
||||
|
||||
// syncSessionCatalogMetadataBounded is the only form the long-lived catalog
|
||||
// goroutine may use. SyncMetadata runs under the catalog's single-writer mutex,
|
||||
// so one transaction that never returns silently wedges every later index,
|
||||
// reconcile, and revision bump — and the sidebar then stops updating for the
|
||||
// rest of the process lifetime instead of failing loudly.
|
||||
func (a *App) syncSessionCatalogMetadataBounded(ctx context.Context, catalog *sessioncatalog.Catalog) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, sessionCatalogMetadataSyncTimeout)
|
||||
defer cancel()
|
||||
return a.syncSessionCatalogMetadata(ctx, catalog)
|
||||
}
|
||||
|
||||
func (a *App) syncSessionCatalogMetadata(ctx context.Context, catalog *sessioncatalog.Catalog) error {
|
||||
f := loadProjectsFile()
|
||||
deleted := map[string]bool{}
|
||||
@@ -495,6 +510,33 @@ func (a *App) GetProjectTreeSnapshot() ProjectTreeSnapshot {
|
||||
return ProjectTreeSnapshot{
|
||||
Revision: status.Revision, Projects: projects, Catalog: status,
|
||||
Indexed: status.Indexed, Total: status.Total,
|
||||
IndexingDone: status.State == string(sessioncatalog.StateReady) && status.RepairPending == 0,
|
||||
IndexingDone: a.catalogIndexingDone(status),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) catalogIndexingDone(status SessionCatalogStatus) bool {
|
||||
if status.State != string(sessioncatalog.StateReady) || status.RepairPending > 0 {
|
||||
return false
|
||||
}
|
||||
catalog := a.sessionCatalog.Load()
|
||||
if catalog == nil {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := a.catalogReadContext()
|
||||
defer cancel()
|
||||
targets := a.sessionCatalogTargets()
|
||||
if len(targets) == 0 {
|
||||
return false
|
||||
}
|
||||
sawExisting := false
|
||||
for _, target := range targets {
|
||||
if _, err := os.Stat(target.Path); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
sawExisting = true
|
||||
if !catalog.DirectoryScanReady(ctx, target.Path) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return sawExisting
|
||||
}
|
||||
@@ -4,9 +4,14 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/sessioncatalog"
|
||||
)
|
||||
|
||||
@@ -116,3 +121,493 @@ func TestProjectTreeShellSurvivesCatalogRevisionRace(t *testing.T) {
|
||||
t.Fatalf("snapshot projects = %#v, want Shell Race", snapshot.Projects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeLegacyRecoveryChainNeedsNoUserActionForOneRowSidebar(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := desktopSessionDir(globalWorkspaceRoot())
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
save := func(path, topic string, messages ...provider.Message) {
|
||||
t.Helper()
|
||||
session := agent.NewSession("sys")
|
||||
for _, message := range messages {
|
||||
session.Add(message)
|
||||
}
|
||||
if err := session.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
|
||||
ID: agent.BranchID(path), Scope: "global", TopicID: topic,
|
||||
TopicTitle: "Upgraded conversation",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
q := provider.Message{Role: provider.RoleUser, Content: "question"}
|
||||
a := provider.Message{Role: provider.RoleAssistant, Content: "answer"}
|
||||
next := provider.Message{Role: provider.RoleUser, Content: "continue"}
|
||||
done := provider.Message{Role: provider.RoleAssistant, Content: "done"}
|
||||
root := filepath.Join(dir, "root.jsonl")
|
||||
copy := filepath.Join(dir, "copy.jsonl")
|
||||
leaf := filepath.Join(dir, "leaf.jsonl")
|
||||
save(root, "conversation", q, a)
|
||||
save(copy, "legacy-copy-topic", q, a)
|
||||
save(leaf, "legacy-leaf-topic", q, a, next, done)
|
||||
for path, meta := range map[string]agent.BranchMeta{
|
||||
copy: {ID: "copy", Scope: "global", TopicID: "legacy-copy-topic", Recovered: true, ParentID: "root", RecoveryDepth: 1},
|
||||
leaf: {ID: "leaf", Scope: "global", TopicID: "legacy-leaf-topic", Recovered: true, ParentID: "copy", RecoveryDepth: 2},
|
||||
} {
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(path, meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
app := NewApp()
|
||||
installSessionCatalogForTest(t, app, dir, "global", "")
|
||||
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 1 || page.Items[0].TopicID != "conversation" || len(page.Items[0].Children) != 0 {
|
||||
t.Fatalf("zero-touch upgraded sidebar = %+v, want one ordinary conversation row", page.Items)
|
||||
}
|
||||
if page.Items[0].RecoveryBranchCount != 0 || page.Items[0].RecoveryUnresolvedCount != 0 ||
|
||||
page.Items[0].Status == topicStatusDivergedRecovery {
|
||||
// Recovery counts and conflict status belong only to History advanced
|
||||
// views; the ordinary root row must stay a plain conversation line.
|
||||
t.Fatalf("ordinary row leaked recovery decoration: %+v", page.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProjectTopicsSurfacesLiveTabWhileCatalogLags(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := t.TempDir()
|
||||
if err := addProject(root, "Lagging Catalog"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessionDir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp()
|
||||
installSessionCatalogForTest(t, app, sessionDir, "project", root)
|
||||
app.tabs["tab-1"] = &WorkspaceTab{
|
||||
ID: "tab-1", Scope: "project", WorkspaceRoot: root,
|
||||
TopicID: "topic_20260812-082637_live", TopicTitle: "Ownership Hub",
|
||||
}
|
||||
|
||||
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 1 || page.Items[0].TopicID != "topic_20260812-082637_live" {
|
||||
t.Fatalf("page items = %#v, want the live tab's topic", page.Items)
|
||||
}
|
||||
if page.Items[0].CreatedAt <= 0 {
|
||||
t.Fatalf("createdAt = %d, want the topic's creation time", page.Items[0].CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProjectTopicsDoesNotDuplicateIndexedLiveTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := t.TempDir()
|
||||
if err := addProject(root, "Indexed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessionDir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessionPath := filepath.Join(sessionDir, "live.jsonl")
|
||||
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := agent.UpdateBranchMeta(sessionPath, false, func(meta *agent.BranchMeta) error {
|
||||
meta.TopicID = "topic-indexed"
|
||||
meta.TopicTitle = "Indexed Topic"
|
||||
meta.WorkspaceRoot = root
|
||||
meta.Scope = "project"
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp()
|
||||
installSessionCatalogForTest(t, app, sessionDir, "project", root)
|
||||
app.tabs["tab-1"] = &WorkspaceTab{
|
||||
ID: "tab-1", Scope: "project", WorkspaceRoot: root,
|
||||
TopicID: "topic-indexed", TopicTitle: "Indexed Topic", SessionPath: sessionPath,
|
||||
}
|
||||
|
||||
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 1 {
|
||||
t.Fatalf("page items = %#v, want the catalog row only", page.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProjectTopicsFoldsRestoredLegacyTopicTabIntoOneRow(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := desktopSessionDir(globalWorkspaceRoot())
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
save := func(path, topic string) {
|
||||
t.Helper()
|
||||
session := agent.NewSession("sys")
|
||||
session.Add(provider.Message{Role: provider.RoleUser, Content: "question"})
|
||||
session.Add(provider.Message{Role: provider.RoleAssistant, Content: "answer"})
|
||||
if err := session.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
|
||||
ID: agent.BranchID(path), Scope: "global", TopicID: topic,
|
||||
TopicTitle: "Upgraded conversation",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
root := filepath.Join(dir, "root.jsonl")
|
||||
copyPath := filepath.Join(dir, "copy.jsonl")
|
||||
save(root, "conversation")
|
||||
save(copyPath, "legacy-copy-topic")
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(copyPath, agent.BranchMeta{
|
||||
ID: "copy", Scope: "global", TopicID: "legacy-copy-topic",
|
||||
Recovered: true, ParentID: "root", RecoveryDepth: 1,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp()
|
||||
installSessionCatalogForTest(t, app, dir, "global", "")
|
||||
// A restored pre-upgrade tab still carries the legacy topic ID for the
|
||||
// recovery copy that the catalog re-anchored onto the root logical topic.
|
||||
// withLiveTopics must dedupe by the projected topic, not the tab's one,
|
||||
// so the sidebar keeps exactly one row for the conversation.
|
||||
app.tabs["tab-1"] = &WorkspaceTab{
|
||||
ID: "tab-1", Scope: "global",
|
||||
TopicID: "legacy-copy-topic", TopicTitle: "Upgraded conversation",
|
||||
SessionPath: copyPath,
|
||||
}
|
||||
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 1 || page.Items[0].TopicID != "conversation" {
|
||||
t.Fatalf("page items = %#v, want one folded conversation row", page.Items)
|
||||
}
|
||||
if !page.Items[0].Open {
|
||||
t.Fatal("open recovery tab must aggregate onto the logical row")
|
||||
}
|
||||
}
|
||||
|
||||
// The catalog goroutine outlives every request, so an unbounded metadata sync
|
||||
// there can hold the catalog's single-writer mutex forever and freeze the whole
|
||||
// sidebar. Guard the call shape, not just today's behaviour.
|
||||
func TestSessionCatalogGoroutineOnlySyncsMetadataWithATimeout(t *testing.T) {
|
||||
source, err := os.ReadFile("session_catalog.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(source)
|
||||
start := strings.Index(body, "func (a *App) startSessionCatalog(")
|
||||
if start < 0 {
|
||||
t.Fatal("startSessionCatalog not found")
|
||||
}
|
||||
end := strings.Index(body[start+1:], "\nfunc ")
|
||||
if end < 0 {
|
||||
t.Fatal("end of startSessionCatalog not found")
|
||||
}
|
||||
for line := range strings.SplitSeq(body[start:start+1+end], "\n") {
|
||||
if !strings.Contains(line, "syncSessionCatalogMetadata(") {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(line, "syncSessionCatalogMetadataBounded(") {
|
||||
t.Fatalf("unbounded metadata sync in startSessionCatalog: %s", strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProjectTopicsKeepsMetadataWhileFirstCatalogScanIsPending(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := t.TempDir()
|
||||
if err := addProject(root, "Upgraded App"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
for i := range f.Projects {
|
||||
if sameProjectRoot(f.Projects[i].Root, root) {
|
||||
f.Projects[i].Topics = []string{"topic-keep"}
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := saveTopicTitles(root, map[string]string{"topic-keep": "Previous chat"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp()
|
||||
catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
app.sessionCatalog.CompareAndSwap(catalog, nil)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_ = catalog.Close(ctx)
|
||||
})
|
||||
app.sessionCatalog.Store(catalog)
|
||||
|
||||
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 1 || page.Items[0].TopicID != "topic-keep" || page.Items[0].Label != "Previous chat" {
|
||||
t.Fatalf("topics while v4 catalog is still empty = %#v, want the desktop-projects conversation", page.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectTreeSnapshotIndexingWaitsForFirstDirectoryScan(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
app := NewApp()
|
||||
catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
app.sessionCatalog.CompareAndSwap(catalog, nil)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_ = catalog.Close(ctx)
|
||||
})
|
||||
app.sessionCatalog.Store(catalog)
|
||||
snapshot := app.GetProjectTreeSnapshot()
|
||||
if snapshot.IndexingDone {
|
||||
t.Fatal("indexingDone must stay false until the first directory scan finishes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuePathForOpenFollowsCoveringLeafFromParent(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := desktopSessionDir(globalWorkspaceRoot())
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := provider.Message{Role: provider.RoleUser, Content: "question"}
|
||||
a := provider.Message{Role: provider.RoleAssistant, Content: "answer"}
|
||||
next := provider.Message{Role: provider.RoleUser, Content: "next"}
|
||||
done := provider.Message{Role: provider.RoleAssistant, Content: "done"}
|
||||
save := func(path, topic string, messages ...provider.Message) {
|
||||
t.Helper()
|
||||
session := agent.NewSession("sys")
|
||||
for _, message := range messages {
|
||||
session.Add(message)
|
||||
}
|
||||
if err := session.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
|
||||
ID: agent.BranchID(path), Scope: "global", TopicID: topic, TopicTitle: "Upgraded",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
root := filepath.Join(dir, "root.jsonl")
|
||||
leaf := filepath.Join(dir, "leaf.jsonl")
|
||||
save(root, "conversation", q, a)
|
||||
save(leaf, "legacy-leaf-topic", q, a, next, done)
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(leaf, agent.BranchMeta{
|
||||
ID: "leaf", Scope: "global", TopicID: "legacy-leaf-topic",
|
||||
Recovered: true, ParentID: "root", RecoveryDepth: 1,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp()
|
||||
installSessionCatalogForTest(t, app, dir, "global", "")
|
||||
if got := app.continuePathForOpen(root); got != leaf {
|
||||
t.Fatalf("continue parent = %q, want covering leaf %q", got, leaf)
|
||||
}
|
||||
if got := app.continuePathForOpen(leaf); got != "" {
|
||||
t.Fatalf("continue leaf = %q, want keep", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProjectTopicsWaitsUntilEveryGlobalDirectoryIsScanned(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
legacy := config.SessionDir()
|
||||
global := desktopSessionDir(globalWorkspaceRoot())
|
||||
for _, dir := range []string{legacy, global} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
f.GlobalTopics = []string{"topic-keep"}
|
||||
return true, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := saveTopicTitles("", map[string]string{"topic-keep": "Previous chat"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp()
|
||||
catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
app.sessionCatalog.CompareAndSwap(catalog, nil)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_ = catalog.Close(ctx)
|
||||
})
|
||||
app.sessionCatalog.Store(catalog)
|
||||
if err := catalog.ReconcileDirectory(context.Background(), sessioncatalog.DirectoryTarget{Path: legacy, Scope: "global"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 1 || page.Items[0].TopicID != "topic-keep" || page.Items[0].Label != "Previous chat" {
|
||||
t.Fatalf("topics after only the empty global dir scanned = %#v, want metadata fallback", page.Items)
|
||||
}
|
||||
}
|
||||
|
||||
type retargetRuntimeController struct {
|
||||
control.SessionAPI
|
||||
status control.RuntimeStatus
|
||||
path string
|
||||
}
|
||||
|
||||
func (c *retargetRuntimeController) RuntimeStatus() control.RuntimeStatus { return c.status }
|
||||
func (c *retargetRuntimeController) SessionPath() string { return c.path }
|
||||
func (c *retargetRuntimeController) PlanMode() bool { return false }
|
||||
func (c *retargetRuntimeController) AutoApproveTools() bool { return false }
|
||||
func (c *retargetRuntimeController) Goal() string { return "" }
|
||||
func (c *retargetRuntimeController) GoalStatus() string { return "" }
|
||||
func (c *retargetRuntimeController) ToolApprovalMode() string { return control.ToolApprovalAsk }
|
||||
|
||||
func TestRetargetOpenTabsSkipsRunningSessions(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := desktopSessionDir(globalWorkspaceRoot())
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := provider.Message{Role: provider.RoleUser, Content: "question"}
|
||||
a := provider.Message{Role: provider.RoleAssistant, Content: "answer"}
|
||||
save := func(path, topic string, messages ...provider.Message) {
|
||||
t.Helper()
|
||||
session := agent.NewSession("sys")
|
||||
for _, message := range messages {
|
||||
session.Add(message)
|
||||
}
|
||||
if err := session.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
|
||||
ID: agent.BranchID(path), Scope: "global", TopicID: topic, TopicTitle: "Upgraded",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
root := filepath.Join(dir, "root.jsonl")
|
||||
leaf := filepath.Join(dir, "leaf.jsonl")
|
||||
save(root, "conversation", q, a)
|
||||
save(leaf, "legacy-leaf-topic", q, a,
|
||||
provider.Message{Role: provider.RoleUser, Content: "next"},
|
||||
provider.Message{Role: provider.RoleAssistant, Content: "done"})
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(leaf, agent.BranchMeta{
|
||||
ID: "leaf", Scope: "global", TopicID: "legacy-leaf-topic",
|
||||
Recovered: true, ParentID: "root", RecoveryDepth: 1,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp()
|
||||
installSessionCatalogForTest(t, app, dir, "global", "")
|
||||
idle := &WorkspaceTab{ID: "idle", Scope: "global", SessionPath: root}
|
||||
running := &WorkspaceTab{
|
||||
ID: "running", Scope: "global", SessionPath: root,
|
||||
Ctrl: &retargetRuntimeController{status: control.RuntimeStatus{Running: true}, path: root},
|
||||
}
|
||||
app.tabs = map[string]*WorkspaceTab{"idle": idle, "running": running}
|
||||
|
||||
app.retargetOpenTabsToCoveringLeaves()
|
||||
if idle.SessionPath != leaf {
|
||||
t.Fatalf("idle tab path = %q, want covering leaf %q", idle.SessionPath, leaf)
|
||||
}
|
||||
if running.SessionPath != root {
|
||||
t.Fatalf("running tab path = %q, want original parent %q", running.SessionPath, root)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenTopicTabKeepsRunningParentInsteadOfCoveringLeaf(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := desktopSessionDir(globalWorkspaceRoot())
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := provider.Message{Role: provider.RoleUser, Content: "question"}
|
||||
a := provider.Message{Role: provider.RoleAssistant, Content: "answer"}
|
||||
save := func(path, topic string, messages ...provider.Message) {
|
||||
t.Helper()
|
||||
session := agent.NewSession("sys")
|
||||
for _, message := range messages {
|
||||
session.Add(message)
|
||||
}
|
||||
if err := session.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
|
||||
ID: agent.BranchID(path), Scope: "global", TopicID: topic, TopicTitle: "Upgraded",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
root := filepath.Join(dir, "root.jsonl")
|
||||
leaf := filepath.Join(dir, "leaf.jsonl")
|
||||
save(root, "conversation", q, a)
|
||||
save(leaf, "legacy-leaf-topic", q, a,
|
||||
provider.Message{Role: provider.RoleUser, Content: "next"},
|
||||
provider.Message{Role: provider.RoleAssistant, Content: "done"})
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(leaf, agent.BranchMeta{
|
||||
ID: "leaf", Scope: "global", TopicID: "legacy-leaf-topic",
|
||||
Recovered: true, ParentID: "root", RecoveryDepth: 1,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp()
|
||||
installSessionCatalogForTest(t, app, dir, "global", "")
|
||||
running := &WorkspaceTab{
|
||||
ID: "running", Scope: "global", TopicID: "conversation", SessionPath: root,
|
||||
Ctrl: &retargetRuntimeController{status: control.RuntimeStatus{Running: true}, path: root},
|
||||
}
|
||||
app.tabs = map[string]*WorkspaceTab{"running": running}
|
||||
|
||||
_, resolved := app.resolveOpenTopicSessionPath("global", "", root)
|
||||
if resolved != root {
|
||||
t.Fatalf("resolve running open = %q, want parent %q", resolved, root)
|
||||
}
|
||||
|
||||
meta, err := app.openTopicTab("global", "", "conversation", root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if running.SessionPath != root {
|
||||
t.Fatalf("running tab path = %q, want parent %q", running.SessionPath, root)
|
||||
}
|
||||
if meta.ID != "running" || meta.SessionPath != root {
|
||||
t.Fatalf("open meta = %+v, want focused running parent", meta)
|
||||
}
|
||||
|
||||
idle := &WorkspaceTab{ID: "idle", Scope: "global", TopicID: "conversation", SessionPath: root}
|
||||
app.tabs = map[string]*WorkspaceTab{"idle": idle}
|
||||
_, idleResolved := app.resolveOpenTopicSessionPath("global", "", root)
|
||||
if idleResolved != leaf {
|
||||
t.Fatalf("resolve idle open = %q, want covering leaf %q", idleResolved, leaf)
|
||||
}
|
||||
}
|
||||
@@ -41,18 +41,16 @@ func TestProjectNodeFromCatalogTopicFiltersIdleRecoveryCopies(t *testing.T) {
|
||||
sessionOverlays := map[string]catalogRuntimeOverlay{
|
||||
sessionRuntimeKey("/s/open-copy.jsonl"): {open: true},
|
||||
}
|
||||
node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, sessionOverlays)
|
||||
node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, sessionOverlays, nil)
|
||||
if !ok {
|
||||
t.Fatal("mixed topic should stay visible")
|
||||
}
|
||||
// Parent + open copy remain; idle copy is filtered. Two sessions => children.
|
||||
if len(node.Children) != 2 {
|
||||
t.Fatalf("children = %d, want parent + open recovery copy", len(node.Children))
|
||||
// Ordinary list is always one logical row; open recovery only flips Open.
|
||||
if len(node.Children) != 0 {
|
||||
t.Fatalf("children = %d, want collapsed logical row", len(node.Children))
|
||||
}
|
||||
for _, child := range node.Children {
|
||||
if child.SessionPath == "/s/copy.jsonl" {
|
||||
t.Fatal("idle recovery copy must not appear in project tree children")
|
||||
}
|
||||
if !node.Open {
|
||||
t.Fatal("open recovery member must aggregate onto the logical row")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +62,7 @@ func TestProjectNodeFromCatalogTopicHidesRecoveryOnlyIdleTopic(t *testing.T) {
|
||||
{Path: "/s/only.jsonl", Recovered: true, RecoveryCopy: true, Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK},
|
||||
},
|
||||
}
|
||||
if _, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}); ok {
|
||||
if _, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil); ok {
|
||||
t.Fatal("idle recovery-only topic must be hidden from the ordinary tree")
|
||||
}
|
||||
}
|
||||
@@ -77,7 +75,7 @@ func TestProjectNodeFromCatalogTopicKeepsPinnedRecoveryOnlyTopic(t *testing.T) {
|
||||
{Path: "/s/pinned.jsonl", Recovered: true, RecoveryCopy: true, Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK},
|
||||
},
|
||||
}
|
||||
node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{})
|
||||
node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil)
|
||||
if !ok {
|
||||
t.Fatal("pinned recovery-only topic must remain visible")
|
||||
}
|
||||
@@ -96,7 +94,7 @@ func TestProjectNodeFromCatalogTopicCollapsesWhenOnlyOneVisible(t *testing.T) {
|
||||
{Path: "/s/copy.jsonl", Recovered: true, RecoveryCopy: true, Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK},
|
||||
},
|
||||
}
|
||||
node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{})
|
||||
node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil)
|
||||
if !ok {
|
||||
t.Fatal("topic with parent should stay visible")
|
||||
}
|
||||
@@ -105,7 +103,47 @@ func TestProjectNodeFromCatalogTopicCollapsesWhenOnlyOneVisible(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectNodeFromCatalogTopicCollapsesDivergedForksToOne(t *testing.T) {
|
||||
app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
|
||||
topic := sessioncatalog.TopicRecord{
|
||||
Scope: "global", TopicID: "forks", Title: "WebView", Turns: 5,
|
||||
Sessions: []sessioncatalog.SessionRecord{
|
||||
{Path: "/s/root.jsonl", Turns: 3, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, LastActivityAt: 10},
|
||||
{Path: "/s/fork-a.jsonl", Turns: 5, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
|
||||
Recovered: true, RecoveryRole: sessioncatalog.RecoveryRoleDiverged, ParentID: "root", RecoveryGroupID: "root", LastActivityAt: 30},
|
||||
{Path: "/s/fork-b.jsonl", Turns: 4, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
|
||||
Recovered: true, RecoveryRole: sessioncatalog.RecoveryRoleDiverged, ParentID: "root", RecoveryGroupID: "root", LastActivityAt: 40},
|
||||
},
|
||||
}
|
||||
node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil)
|
||||
if !ok {
|
||||
t.Fatal("topic with parent should stay visible")
|
||||
}
|
||||
// Parent remains; idle diverged forks hide while the normal root exists.
|
||||
if len(node.Children) != 0 {
|
||||
t.Fatalf("children = %d, want collapsed parent-only row, children=%+v", len(node.Children), node.Children)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectNodeFromCatalogTopicHidesOrphanForkTopicsWhenPreferredElsewhere(t *testing.T) {
|
||||
app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
|
||||
// Separate topic that only holds a non-preferred diverged fork.
|
||||
topic := sessioncatalog.TopicRecord{
|
||||
Scope: "global", TopicID: "orphan-fork", Title: "WebView",
|
||||
Sessions: []sessioncatalog.SessionRecord{
|
||||
{Path: "/s/fork-only.jsonl", Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
|
||||
Recovered: true, RecoveryRole: sessioncatalog.RecoveryRoleDiverged, ParentID: "root", RecoveryGroupID: "root"},
|
||||
},
|
||||
}
|
||||
// Workspace preference already chose the parent path for this lineage.
|
||||
preferred := map[string]struct{}{"/s/root.jsonl": {}}
|
||||
if _, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, preferred); ok {
|
||||
t.Fatal("orphan non-preferred recovery topic must stay out of the ordinary tree")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProjectTopicsSkipsRecoveryOnlyPages(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
ctx := context.Background()
|
||||
catalog, err := sessioncatalog.Open(ctx, sessioncatalog.Options{
|
||||
Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -11,6 +13,15 @@ import (
|
||||
"reasonix/internal/sessioncatalog"
|
||||
)
|
||||
|
||||
// Sidebar reads are bound so a starved connection pool or a slow projection
|
||||
// degrades into a stale page the next revision repairs, instead of a Wails call
|
||||
// that never returns and a tree that never moves again.
|
||||
const sessionCatalogReadTimeout = 10 * time.Second
|
||||
|
||||
func (a *App) catalogReadContext() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(a.bootContext(), sessionCatalogReadTimeout)
|
||||
}
|
||||
|
||||
type catalogRuntimeSnapshot struct {
|
||||
scope string
|
||||
workspaceRoot string
|
||||
@@ -34,7 +45,7 @@ func catalogRuntimeStatus(activity string, runtimeStatus control.RuntimeStatus)
|
||||
return topicStatusWaitingConfirmation
|
||||
}
|
||||
if runtimeStatus.Running {
|
||||
if status == "" || status == topicStatusError || status == topicStatusPaused {
|
||||
if status == "" || status == topicStatusError || status == topicStatusPaused || status == topicStatusAwaitingDelivery {
|
||||
return topicStatusThinking
|
||||
}
|
||||
return status
|
||||
@@ -42,7 +53,7 @@ func catalogRuntimeStatus(activity string, runtimeStatus control.RuntimeStatus)
|
||||
if runtimeStatus.BackgroundJobs > 0 {
|
||||
return topicStatusBackgroundJob
|
||||
}
|
||||
if status == topicStatusError || status == topicStatusPaused {
|
||||
if status == topicStatusError || status == topicStatusPaused || status == topicStatusAwaitingDelivery {
|
||||
return status
|
||||
}
|
||||
return status
|
||||
@@ -172,6 +183,15 @@ func (a *App) metadataProjectTopics(scope, workspaceRoot string) []ProjectNode {
|
||||
}
|
||||
|
||||
func (a *App) runtimeOnlyProjectTopics(scope, workspaceRoot string) []ProjectNode {
|
||||
nodes, _ := a.runtimeOnlyProjectTopicsWithSessions(scope, workspaceRoot)
|
||||
return nodes
|
||||
}
|
||||
|
||||
// runtimeOnlyProjectTopicsWithSessions also reports each runtime topic's known
|
||||
// session paths so callers can resolve the topics those sessions project onto
|
||||
// in the catalog (a restored tab may carry a legacy topic ID for a re-anchored
|
||||
// recovery lineage).
|
||||
func (a *App) runtimeOnlyProjectTopicsWithSessions(scope, workspaceRoot string) ([]ProjectNode, map[string][]string) {
|
||||
a.mu.RLock()
|
||||
snapshots := []catalogRuntimeSnapshot{}
|
||||
collect := func(tab *WorkspaceTab, open bool) {
|
||||
@@ -199,11 +219,15 @@ func (a *App) runtimeOnlyProjectTopics(scope, workspaceRoot string) []ProjectNod
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
byTopic := map[string][]catalogRuntimeSnapshot{}
|
||||
sessionsByTopic := map[string][]string{}
|
||||
for _, snapshot := range snapshots {
|
||||
if snapshot.sessionPath == "" && snapshot.ctrl != nil {
|
||||
snapshot.sessionPath = snapshot.ctrl.SessionPath()
|
||||
}
|
||||
byTopic[snapshot.topicID] = append(byTopic[snapshot.topicID], snapshot)
|
||||
if path := strings.TrimSpace(snapshot.sessionPath); path != "" {
|
||||
sessionsByTopic[snapshot.topicID] = append(sessionsByTopic[snapshot.topicID], path)
|
||||
}
|
||||
}
|
||||
topicIDs := make([]string, 0, len(byTopic))
|
||||
for topicID := range byTopic {
|
||||
@@ -256,7 +280,7 @@ func (a *App) runtimeOnlyProjectTopics(scope, workspaceRoot string) []ProjectNod
|
||||
}
|
||||
out = append(out, node)
|
||||
}
|
||||
return out
|
||||
return out, sessionsByTopic
|
||||
}
|
||||
|
||||
func (a *App) metadataTopicPage(req ProjectTopicPageRequest) ProjectTopicPage {
|
||||
@@ -295,7 +319,7 @@ func (a *App) metadataTopicPage(req ProjectTopicPageRequest) ProjectTopicPage {
|
||||
return page
|
||||
}
|
||||
|
||||
func (a *App) projectNodeFromCatalogTopic(topic sessioncatalog.TopicRecord, topicOverlays, sessionOverlays map[string]catalogRuntimeOverlay) (ProjectNode, bool) {
|
||||
func (a *App) projectNodeFromCatalogTopic(topic sessioncatalog.TopicRecord, topicOverlays, sessionOverlays map[string]catalogRuntimeOverlay, preferred map[string]struct{}) (ProjectNode, bool) {
|
||||
kind := "topic"
|
||||
if topic.Scope == "global" {
|
||||
kind = "global_topic"
|
||||
@@ -307,15 +331,35 @@ func (a *App) projectNodeFromCatalogTopic(topic sessioncatalog.TopicRecord, topi
|
||||
TurnsState: string(topic.TurnsState), Health: string(topic.Health),
|
||||
CreatedAt: topic.CreatedAt, LastActivityAt: topic.LastActivityAt,
|
||||
Pinned: topic.Pinned, Open: overlay.open, Running: overlay.running, Status: overlay.status,
|
||||
// Ordinary tree is zero-config: never surface recovery counts, badges,
|
||||
// or forced-handling status. History "other saved versions" owns that.
|
||||
Children: []ProjectNode{},
|
||||
}
|
||||
// Fall back to topic-local preference when the workspace map is unavailable
|
||||
// so multi-fork topics still collapse instead of listing every replica.
|
||||
localPreferred := preferred
|
||||
if localPreferred == nil {
|
||||
localPreferred = sessioncatalog.PreferredOrdinarySessionPaths(topic.Sessions)
|
||||
}
|
||||
visible := make([]sessioncatalog.SessionRecord, 0, len(topic.Sessions))
|
||||
runtimeSessions := make([]runtimeSessionStatus, 0, len(topic.Sessions))
|
||||
for _, session := range topic.Sessions {
|
||||
sessionOverlay := sessionOverlays[sessionRuntimeKey(session.Path)]
|
||||
// Idle covered recovery copies stay out of the ordinary tree. Open or
|
||||
// running copies remain reachable so the user can still inspect them.
|
||||
if session.RecoveryCopy && !sessionOverlay.open && !sessionOverlay.running {
|
||||
// Aggregate open/running state from every physical member onto the
|
||||
// single logical row — never expand recovery runtimes as children.
|
||||
if sessionOverlay.open {
|
||||
node.Open = true
|
||||
}
|
||||
if sessionOverlay.running {
|
||||
node.Running = true
|
||||
if node.Status == "" {
|
||||
node.Status = sessionOverlay.status
|
||||
}
|
||||
}
|
||||
// 1.23 ordinary-list contract: hide idle covered copies and non-
|
||||
// preferred conflict forks. Open/running recovery is still not a
|
||||
// second row — status is already aggregated above.
|
||||
if !sessioncatalog.OrdinaryTreeSession(session, false, false, localPreferred) {
|
||||
continue
|
||||
}
|
||||
visible = append(visible, session)
|
||||
@@ -325,54 +369,51 @@ func (a *App) projectNodeFromCatalogTopic(topic sessioncatalog.TopicRecord, topi
|
||||
}
|
||||
summary := topicSummaryFromCatalogTopic(topic, visible)
|
||||
if topicHiddenAsRecoveryOnly(summary, topic.Pinned, append(runtimeSessions, runtimeSessionStatus{
|
||||
open: overlay.open, running: overlay.running,
|
||||
open: overlay.open || node.Open, running: overlay.running || node.Running,
|
||||
})) {
|
||||
return ProjectNode{Children: []ProjectNode{}}, false
|
||||
}
|
||||
// A single effective session collapses to a normal topic row.
|
||||
if len(visible) <= 1 {
|
||||
return node, true
|
||||
if a.ordinaryTreeHidesUnindexedBlank(topic) {
|
||||
return ProjectNode{Children: []ProjectNode{}}, false
|
||||
}
|
||||
diverged := 0
|
||||
for _, session := range visible {
|
||||
if session.RecoveryRole == sessioncatalog.RecoveryRoleDiverged ||
|
||||
(session.Recovered && !session.RecoveryCopy && session.RecoveryRole != sessioncatalog.RecoveryRoleAdopted) {
|
||||
diverged++
|
||||
// After filtering non-preferred recovery forks, a topic may have nothing
|
||||
// left. Keep pinned/open shells; otherwise drop the empty row.
|
||||
if len(visible) == 0 {
|
||||
if topic.Pinned || overlay.open || overlay.running || node.Open || node.Running {
|
||||
return node, true
|
||||
}
|
||||
return ProjectNode{Children: []ProjectNode{}}, false
|
||||
}
|
||||
if diverged >= 2 {
|
||||
// Non-destructive choice prompt. The frontend renders the label from
|
||||
// this status so it stays translated.
|
||||
node.Status = topicStatusDivergedRecovery
|
||||
}
|
||||
for _, session := range visible {
|
||||
sessionKind := "session"
|
||||
if topic.Scope == "global" {
|
||||
sessionKind = "global_session"
|
||||
}
|
||||
sessionOverlay := sessionOverlays[sessionRuntimeKey(session.Path)]
|
||||
label := strings.TrimSpace(session.CustomTitle)
|
||||
if label == "" {
|
||||
label = strings.TrimSpace(session.Preview)
|
||||
}
|
||||
if label == "" {
|
||||
label = filepath.Base(session.Path)
|
||||
}
|
||||
node.Children = append(node.Children, ProjectNode{
|
||||
Key: projectSessionNodeKey(topic.Scope, session.Path), Kind: sessionKind,
|
||||
Label: label, Root: topic.WorkspaceRoot, TopicID: topic.TopicID,
|
||||
SessionPath: session.Path, Turns: session.Turns,
|
||||
TurnsState: string(session.TurnsState), Health: string(session.Health),
|
||||
CreatedAt: session.CreatedAt, LastActivityAt: session.LastActivityAt,
|
||||
Open: sessionOverlay.open, Running: sessionOverlay.running, Status: sessionOverlay.status,
|
||||
Recovered: session.Recovered, RecoveryReason: session.RecoveryReason,
|
||||
RecoveryDigest: session.RecoveryDigest, RecoveryParentID: session.ParentID,
|
||||
Children: []ProjectNode{},
|
||||
})
|
||||
// Ordinary list is always one logical row. Multiple normal non-recovery
|
||||
// sessions under one topic also collapse: open/running already aggregated.
|
||||
// History "other saved versions" is the only place physical forks appear.
|
||||
if live := a.liveSessionPathForTopic(topic.Scope, topic.WorkspaceRoot, topic.TopicID); live != "" {
|
||||
node.SessionPath = live
|
||||
} else if rep := strings.TrimSpace(topic.RepresentativePath); rep != "" {
|
||||
node.SessionPath = rep
|
||||
} else if path := sessioncatalog.CanonicalSessionPathForTopic(visible, ""); path != "" {
|
||||
node.SessionPath = path
|
||||
} else if len(visible) == 1 {
|
||||
node.SessionPath = visible[0].Path
|
||||
}
|
||||
return node, true
|
||||
}
|
||||
|
||||
func (a *App) ordinaryTreeHidesUnindexedBlank(topic sessioncatalog.TopicRecord) bool {
|
||||
if topic.Pinned || topic.Turns > 0 {
|
||||
return false
|
||||
}
|
||||
if !isDefaultTopicTitle(topic.Title) && strings.TrimSpace(topic.Title) != "" {
|
||||
return false
|
||||
}
|
||||
for _, session := range topic.Sessions {
|
||||
if session.Turns > 0 || strings.TrimSpace(session.Preview) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return !topicIndexedInRegistry(topic.Scope, topic.WorkspaceRoot, topic.TopicID)
|
||||
}
|
||||
|
||||
func topicSummaryFromCatalogTopic(topic sessioncatalog.TopicRecord, visible []sessioncatalog.SessionRecord) topicSummary {
|
||||
summary := topicSummary{turns: topic.Turns, lastActivityAt: topic.LastActivityAt}
|
||||
if len(visible) == 0 {
|
||||
@@ -403,11 +444,139 @@ func topicSummaryFromCatalogTopic(topic sessioncatalog.TopicRecord, visible []se
|
||||
}
|
||||
|
||||
func (a *App) ListProjectTopics(req ProjectTopicPageRequest) (ProjectTopicPage, error) {
|
||||
out := ProjectTopicPage{Items: []ProjectNode{}}
|
||||
catalog := a.sessionCatalog.Load()
|
||||
if catalog == nil {
|
||||
return a.metadataTopicPage(req), nil
|
||||
if catalog == nil || !a.catalogWorkspaceScanReady(catalog, req.Scope, req.WorkspaceRoot) {
|
||||
// A freshly opened v4 cache is live but empty until the first directory
|
||||
// scan. Treat that the same as "catalog unavailable" so upgrade does
|
||||
// not blank the sidebar that desktop-projects.json still knows about.
|
||||
page := a.metadataTopicPage(req)
|
||||
if catalog == nil {
|
||||
return page, nil
|
||||
}
|
||||
return a.withLiveTopics(catalog, req, page), nil
|
||||
}
|
||||
page, err := a.catalogTopicPage(catalog, req)
|
||||
if err != nil {
|
||||
return page, err
|
||||
}
|
||||
return a.withLiveTopics(catalog, req, page), nil
|
||||
}
|
||||
|
||||
func (a *App) catalogWorkspaceScanReady(catalog *sessioncatalog.Catalog, scope, workspaceRoot string) bool {
|
||||
if a == nil || catalog == nil {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := a.catalogReadContext()
|
||||
defer cancel()
|
||||
scope, workspaceRoot = normalizeDesktopTopicScope(scope, workspaceRoot)
|
||||
matchedExisting := 0
|
||||
for _, target := range a.sessionCatalogTargets() {
|
||||
if target.Scope != scope {
|
||||
continue
|
||||
}
|
||||
if scope == "project" && !sameProjectRoot(target.WorkspaceRoot, workspaceRoot) {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(target.Path); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
matchedExisting++
|
||||
if !catalog.DirectoryScanReady(ctx, target.Path) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if matchedExisting > 0 {
|
||||
return true
|
||||
}
|
||||
return catalog.HasWorkspaceRecords(ctx, scope, workspaceRoot)
|
||||
}
|
||||
|
||||
func normalizeDesktopTopicScope(scope, workspaceRoot string) (string, string) {
|
||||
if strings.TrimSpace(scope) != "project" {
|
||||
return "global", ""
|
||||
}
|
||||
return "project", strings.TrimSpace(workspaceRoot)
|
||||
}
|
||||
|
||||
// withLiveTopics restores topics the catalog does not (yet) carry. A tab is
|
||||
// authoritative for its own existence, while the catalog is a projection that
|
||||
// can lag a fresh session, fall behind a stalled writer, or run degraded — and
|
||||
// the sidebar must never hide a conversation this app is running. Only an
|
||||
// uncursored page merges, so keyset pagination past it stays the catalog's.
|
||||
func (a *App) withLiveTopics(catalog *sessioncatalog.Catalog, req ProjectTopicPageRequest, page ProjectTopicPage) ProjectTopicPage {
|
||||
if strings.TrimSpace(req.Cursor) != "" {
|
||||
return page
|
||||
}
|
||||
indexed := make(map[string]bool, len(page.Items))
|
||||
for _, item := range page.Items {
|
||||
indexed[item.TopicID] = true
|
||||
}
|
||||
query := strings.ToLower(strings.TrimSpace(req.Query))
|
||||
live := []ProjectNode{}
|
||||
runtimeNodes, sessionsByTopic := a.runtimeOnlyProjectTopicsWithSessions(req.Scope, req.WorkspaceRoot)
|
||||
ctx, cancel := a.catalogReadContext()
|
||||
defer cancel()
|
||||
for _, node := range runtimeNodes {
|
||||
if indexed[node.TopicID] {
|
||||
continue
|
||||
}
|
||||
// A restored tab may still carry a legacy topic ID for a recovery
|
||||
// session the catalog re-anchored onto the root logical topic. That
|
||||
// logical row already represents the conversation, so a second
|
||||
// runtime-only row would break the one-row ordinary-list contract.
|
||||
if liveTopicProjectedOnPage(ctx, catalog, sessionsByTopic[node.TopicID], indexed) {
|
||||
continue
|
||||
}
|
||||
if query != "" && !strings.Contains(strings.ToLower(node.Label), query) {
|
||||
continue
|
||||
}
|
||||
live = append(live, node)
|
||||
}
|
||||
if len(live) == 0 {
|
||||
return page
|
||||
}
|
||||
f := loadProjectsFile()
|
||||
deleted := map[string]bool{}
|
||||
for _, topicID := range f.DeletedTopics {
|
||||
deleted[topicID] = true
|
||||
}
|
||||
created := loadTopicCreatedAts(topicTitleRoot(req.Scope, req.WorkspaceRoot))
|
||||
kept := page.Items[:0:0]
|
||||
for _, node := range live {
|
||||
if deleted[node.TopicID] {
|
||||
continue
|
||||
}
|
||||
node.CreatedAt = topicCreatedAtForTree(created, node.TopicID)
|
||||
node.LastActivityAt = node.CreatedAt
|
||||
kept = append(kept, node)
|
||||
}
|
||||
page.Items = append(kept, page.Items...)
|
||||
return page
|
||||
}
|
||||
|
||||
// liveTopicProjectedOnPage reports whether every catalog-known session of a
|
||||
// runtime-only topic already projects onto a topic on this page. Any session
|
||||
// the catalog has not indexed yet keeps the live row (that is the lag case
|
||||
// withLiveTopics exists for), and an off-page projection also keeps it so an
|
||||
// open conversation never disappears from the first page.
|
||||
func liveTopicProjectedOnPage(ctx context.Context, catalog *sessioncatalog.Catalog, paths []string, indexed map[string]bool) bool {
|
||||
if catalog == nil || len(paths) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, path := range paths {
|
||||
record, ok, err := catalog.GetSession(ctx, path)
|
||||
if err != nil || !ok {
|
||||
return false
|
||||
}
|
||||
if !indexed[record.TopicID] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) catalogTopicPage(catalog *sessioncatalog.Catalog, req ProjectTopicPageRequest) (ProjectTopicPage, error) {
|
||||
out := ProjectTopicPage{Items: []ProjectNode{}}
|
||||
limit := req.Limit
|
||||
if limit <= 0 {
|
||||
limit = sessioncatalog.DefaultLimit
|
||||
@@ -416,12 +585,20 @@ func (a *App) ListProjectTopics(req ProjectTopicPageRequest) (ProjectTopicPage,
|
||||
limit = sessioncatalog.MaxLimit
|
||||
}
|
||||
topicOverlays, sessionOverlays := a.catalogRuntimeOverlays()
|
||||
ctx, cancel := a.catalogReadContext()
|
||||
defer cancel()
|
||||
// Workspace-wide preference collapses cross-topic recovery replicas that
|
||||
// share a lineage but were indexed as separate topic rows.
|
||||
preferred, prefErr := catalog.PreferredOrdinarySessionPaths(ctx, req.Scope, req.WorkspaceRoot)
|
||||
if prefErr != nil {
|
||||
preferred = nil
|
||||
}
|
||||
cursor := req.Cursor
|
||||
// Keep scanning past pages that are entirely idle recovery copies so the
|
||||
// sidebar never shows an empty "no sessions" state when later pages still
|
||||
// have ordinary topics.
|
||||
for {
|
||||
page, err := catalog.ListTopics(a.bootContext(), sessioncatalog.TopicPageRequest{
|
||||
page, err := catalog.ListTopics(ctx, sessioncatalog.TopicPageRequest{
|
||||
Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, Cursor: cursor,
|
||||
Limit: limit, Query: req.Query, TimeFilter: req.TimeFilter,
|
||||
})
|
||||
@@ -430,7 +607,7 @@ func (a *App) ListProjectTopics(req ProjectTopicPageRequest) (ProjectTopicPage,
|
||||
}
|
||||
out.Revision = page.Revision
|
||||
for i, topic := range page.Items {
|
||||
node, ok := a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays)
|
||||
node, ok := a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays, preferred)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -462,7 +639,9 @@ func encodeProjectTopicCursor(topic sessioncatalog.TopicRecord) string {
|
||||
|
||||
func (a *App) GetTopicSummary(key ProjectTopicKey) (ProjectNode, error) {
|
||||
if catalog := a.sessionCatalog.Load(); catalog != nil {
|
||||
topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{
|
||||
ctx, cancel := a.catalogReadContext()
|
||||
defer cancel()
|
||||
topic, ok, err := catalog.GetTopic(ctx, sessioncatalog.TopicKey{
|
||||
Scope: key.Scope, WorkspaceRoot: key.WorkspaceRoot, TopicID: key.TopicID,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -470,7 +649,8 @@ func (a *App) GetTopicSummary(key ProjectTopicKey) (ProjectNode, error) {
|
||||
}
|
||||
if ok {
|
||||
topicOverlays, sessionOverlays := a.catalogRuntimeOverlays()
|
||||
if node, visible := a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays); visible {
|
||||
preferred, _ := catalog.PreferredOrdinarySessionPaths(ctx, key.Scope, key.WorkspaceRoot)
|
||||
if node, visible := a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays, preferred); visible {
|
||||
return node, nil
|
||||
}
|
||||
return ProjectNode{Children: []ProjectNode{}}, nil
|
||||
@@ -572,8 +752,17 @@ func (a *App) catalogSessionPathForTopic(scope, workspaceRoot, topicID string) s
|
||||
if err != nil || !ok || len(topic.Sessions) == 0 {
|
||||
return ""
|
||||
}
|
||||
if canonical := sessioncatalog.CanonicalSessionPathForTopic(topic.Sessions, ""); canonical != "" {
|
||||
return canonical
|
||||
}
|
||||
preferred := sessioncatalog.PreferredOrdinarySessionPaths(topic.Sessions)
|
||||
sort.SliceStable(topic.Sessions, func(i, j int) bool {
|
||||
// Prefer real conversations over idle covered recovery copies.
|
||||
// Prefer ordinary-tree survivors, then real conversations over copies.
|
||||
iPref := sessioncatalog.OrdinaryTreeSession(topic.Sessions[i], false, false, preferred)
|
||||
jPref := sessioncatalog.OrdinaryTreeSession(topic.Sessions[j], false, false, preferred)
|
||||
if iPref != jPref {
|
||||
return iPref
|
||||
}
|
||||
if topic.Sessions[i].RecoveryCopy != topic.Sessions[j].RecoveryCopy {
|
||||
return !topic.Sessions[i].RecoveryCopy
|
||||
}
|
||||
|
||||
+34
-36
@@ -1789,11 +1789,8 @@ func topicActivityStatusFromEvent(e event.Event) (string, bool) {
|
||||
case event.ApprovalRequest, event.AskRequest:
|
||||
return topicStatusWaitingConfirmation, true
|
||||
case event.TurnDone:
|
||||
if e.Outcome == event.TurnOutcomeFinalReadiness || e.Outcome == event.TurnOutcomeRecoveryPaused {
|
||||
// The transcript presents this turn end as a recoverable delivery
|
||||
// pause with a continue action, so the sidebar must not flag it
|
||||
// as an error.
|
||||
return topicStatusPaused, true
|
||||
if status, ok := topicStatusFromTurnDone(e.Outcome); ok {
|
||||
return status, true
|
||||
}
|
||||
if e.Err != nil {
|
||||
return topicStatusError, true
|
||||
@@ -2328,11 +2325,11 @@ func (a *App) openProjectTab(workspaceRoot, topicID string) (TabMeta, error) {
|
||||
a.registerProjectRoot(workspaceRoot)
|
||||
|
||||
sessionPath, _ := a.findTopicSessionForTarget("project", workspaceRoot, topicID)
|
||||
return a.openTopicTab("project", workspaceRoot, topicID, sessionPath)
|
||||
return a.openTopicTabWithActivation("project", workspaceRoot, topicID, sessionPath, true)
|
||||
}
|
||||
|
||||
func (a *App) openTopicTab(scope, workspaceRoot, topicID, sessionPath string) (TabMeta, error) {
|
||||
return a.openTopicTabWithActivation(scope, workspaceRoot, topicID, sessionPath, true)
|
||||
return a.openTopicTabPreferLiveActivation(scope, workspaceRoot, topicID, sessionPath, true)
|
||||
}
|
||||
|
||||
func (a *App) openProjectTabInactive(workspaceRoot, topicID string) (TabMeta, error) {
|
||||
@@ -2359,10 +2356,7 @@ func (a *App) openGlobalTabInactive(topicID string) (TabMeta, error) {
|
||||
}
|
||||
|
||||
func (a *App) openTopicTabWithActivation(scope, workspaceRoot, topicID, sessionPath string, activate bool) (TabMeta, error) {
|
||||
actualRoot := workspaceRoot
|
||||
if scope == "global" {
|
||||
actualRoot = globalWorkspaceRoot()
|
||||
}
|
||||
actualRoot, sessionPath := a.resolveOpenTopicSessionPath(scope, workspaceRoot, sessionPath)
|
||||
targetKey := sessionRuntimeKey(sessionPath)
|
||||
|
||||
a.mu.Lock()
|
||||
@@ -2392,7 +2386,7 @@ func (a *App) openTopicTabWithActivation(scope, workspaceRoot, topicID, sessionP
|
||||
meta := a.tabMeta(tab, tab.ID == a.activeTabID)
|
||||
a.saveTabsLocked()
|
||||
a.mu.Unlock()
|
||||
if sameSession {
|
||||
if sameSession || a.skipCoveringLeafRebind(tab, sessionPath) {
|
||||
return enrichTabMeta(meta), nil
|
||||
}
|
||||
if err := a.rebindTabToSessionPath(tab, sessionPath); err != nil {
|
||||
@@ -2467,7 +2461,7 @@ func (a *App) openGlobalTab(topicID string) (TabMeta, error) {
|
||||
}
|
||||
|
||||
sessionPath, _ := a.findTopicSessionForTarget("global", "", topicID)
|
||||
return a.openTopicTab("global", "", topicID, sessionPath)
|
||||
return a.openTopicTabWithActivation("global", "", topicID, sessionPath, true)
|
||||
}
|
||||
|
||||
// OpenTopicSession opens a concrete saved session from the sidebar. Unlike
|
||||
@@ -6443,33 +6437,37 @@ func loadTelemetry(path string) tabTelemetrySnapshot {
|
||||
// ProjectNode is one node in the sidebar project tree (a project folder or a
|
||||
// topic leaf).
|
||||
type ProjectNode struct {
|
||||
Key string `json:"key"` // stable key for React
|
||||
Kind string `json:"kind"` // "project" | "topic" | "session" | "global_folder" | "global_topic" | "global_session"
|
||||
Label string `json:"label"`
|
||||
Root string `json:"root,omitempty"` // project workspace root
|
||||
TopicID string `json:"topicId,omitempty"`
|
||||
SessionPath string `json:"sessionPath,omitempty"`
|
||||
ProjectColor string `json:"projectColor,omitempty"`
|
||||
Turns int `json:"turns,omitempty"`
|
||||
TurnsState string `json:"turnsState,omitempty"`
|
||||
Health string `json:"health,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt,omitempty"`
|
||||
LastActivityAt int64 `json:"lastActivityAt,omitempty"`
|
||||
Open bool `json:"open,omitempty"`
|
||||
Running bool `json:"running,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Pinned bool `json:"pinned,omitempty"`
|
||||
Recovered bool `json:"recovered,omitempty"`
|
||||
RecoveryReason string `json:"recoveryReason,omitempty"`
|
||||
RecoveryDigest string `json:"recoveryDigest,omitempty"`
|
||||
RecoveryParentID string `json:"recoveryParentId,omitempty"`
|
||||
IsolatedWorktree bool `json:"isolatedWorktree,omitempty"`
|
||||
Children []ProjectNode `json:"children,omitempty"`
|
||||
Key string `json:"key"` // stable key for React
|
||||
Kind string `json:"kind"` // "project" | "topic" | "session" | "global_folder" | "global_topic" | "global_session"
|
||||
Label string `json:"label"`
|
||||
Root string `json:"root,omitempty"` // project workspace root
|
||||
TopicID string `json:"topicId,omitempty"`
|
||||
SessionPath string `json:"sessionPath,omitempty"`
|
||||
ProjectColor string `json:"projectColor,omitempty"`
|
||||
Turns int `json:"turns,omitempty"`
|
||||
TurnsState string `json:"turnsState,omitempty"`
|
||||
Health string `json:"health,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt,omitempty"`
|
||||
LastActivityAt int64 `json:"lastActivityAt,omitempty"`
|
||||
Open bool `json:"open,omitempty"`
|
||||
Running bool `json:"running,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Pinned bool `json:"pinned,omitempty"`
|
||||
Recovered bool `json:"recovered,omitempty"`
|
||||
RecoveryReason string `json:"recoveryReason,omitempty"`
|
||||
RecoveryDigest string `json:"recoveryDigest,omitempty"`
|
||||
RecoveryParentID string `json:"recoveryParentId,omitempty"`
|
||||
RecoveryState string `json:"recoveryState,omitempty"`
|
||||
RecoveryBranchCount int `json:"recoveryBranchCount,omitempty"`
|
||||
RecoveryUnresolvedCount int `json:"recoveryUnresolvedCount,omitempty"`
|
||||
RecoveryCleanupEligibleCount int `json:"recoveryCleanupEligibleCount,omitempty"`
|
||||
IsolatedWorktree bool `json:"isolatedWorktree,omitempty"`
|
||||
Children []ProjectNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
func normalizeTopicStatus(status string) string {
|
||||
switch status {
|
||||
case topicStatusThinking, topicStatusStreaming, topicStatusWaitingConfirmation, topicStatusBackgroundJob, topicStatusPaused, topicStatusError, topicStatusDivergedRecovery:
|
||||
case topicStatusThinking, topicStatusStreaming, topicStatusWaitingConfirmation, topicStatusBackgroundJob, topicStatusPaused, topicStatusAwaitingDelivery, topicStatusError, topicStatusDivergedRecovery:
|
||||
return status
|
||||
default:
|
||||
return ""
|
||||
|
||||
@@ -255,14 +255,14 @@ func waitNoJobs(t *testing.T, ctrl control.SessionAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopicActivityStatusPresentsReadinessAsPaused(t *testing.T) {
|
||||
func TestTopicActivityStatusPresentsReadinessSeparatelyFromPause(t *testing.T) {
|
||||
readiness := event.Event{
|
||||
Kind: event.TurnDone,
|
||||
Err: &agent.FinalReadinessError{Attempts: 3, Reason: "missing verification"},
|
||||
Outcome: event.TurnOutcomeFinalReadiness,
|
||||
}
|
||||
if status, ok := topicActivityStatusFromEvent(readiness); !ok || status != topicStatusPaused {
|
||||
t.Fatalf("readiness turn end = (%q, %v), want (%q, true)", status, ok, topicStatusPaused)
|
||||
if status, ok := topicActivityStatusFromEvent(readiness); !ok || status != topicStatusAwaitingDelivery {
|
||||
t.Fatalf("readiness turn end = (%q, %v), want (%q, true)", status, ok, topicStatusAwaitingDelivery)
|
||||
}
|
||||
recoveryPause := event.Event{
|
||||
Kind: event.TurnDone,
|
||||
@@ -279,3 +279,18 @@ func TestTopicActivityStatusPresentsReadinessAsPaused(t *testing.T) {
|
||||
t.Fatalf("clean turn end = (%q, %v), want cleared status", status, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogRuntimeStatusPreservesDeliveryCheckWhenIdle(t *testing.T) {
|
||||
got := catalogRuntimeStatus(topicStatusAwaitingDelivery, control.RuntimeStatus{})
|
||||
if got != topicStatusAwaitingDelivery {
|
||||
t.Fatalf("idle delivery check = %q, want %q", got, topicStatusAwaitingDelivery)
|
||||
}
|
||||
got = catalogRuntimeStatus(topicStatusAwaitingDelivery, control.RuntimeStatus{Running: true})
|
||||
if got != topicStatusThinking {
|
||||
t.Fatalf("running delivery check = %q, want %q", got, topicStatusThinking)
|
||||
}
|
||||
got = catalogRuntimeStatus(topicStatusPaused, control.RuntimeStatus{})
|
||||
if got != topicStatusPaused {
|
||||
t.Fatalf("idle recovery pause = %q, want %q", got, topicStatusPaused)
|
||||
}
|
||||
}
|
||||
+23
-12
@@ -763,24 +763,35 @@ func TestAmbiguousLegacyRecoverySessionsMigrateIntoTopics(t *testing.T) {
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
nodes := waitForCatalogTopic(t, app, "global", "", legacySessionTopicID(recovery))
|
||||
// Filename recovery folds into the root ordinary row; History keeps the
|
||||
// physical recovery file reachable as another saved version.
|
||||
app.startSessionCatalog(false)
|
||||
t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
|
||||
nodes := waitForCatalogTreeCondition(t, app, "filename recovery folded into one ordinary row", func(nodes []ProjectNode) bool {
|
||||
for _, folder := range nodes {
|
||||
if folder.Kind != "global_folder" {
|
||||
continue
|
||||
}
|
||||
if len(folder.Children) != 1 {
|
||||
return false
|
||||
}
|
||||
return folder.Children[0].TopicID == legacySessionTopicID(normal)
|
||||
}
|
||||
return false
|
||||
})
|
||||
if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
|
||||
t.Fatalf("project tree = %#v, want global folder", nodes)
|
||||
}
|
||||
if got := len(nodes[0].Children); got != 2 {
|
||||
t.Fatalf("global migrated topics = %d, want both sessions preserved: %#v", got, nodes[0].Children)
|
||||
if got := len(nodes[0].Children); got != 1 {
|
||||
t.Fatalf("global ordinary topics = %d, want 1 folded conversation: %#v", got, nodes[0].Children)
|
||||
}
|
||||
wantTopics := map[string]bool{legacySessionTopicID(normal): true, legacySessionTopicID(recovery): true}
|
||||
for _, node := range nodes[0].Children {
|
||||
delete(wantTopics, node.TopicID)
|
||||
if _, err := os.Stat(recovery); err != nil {
|
||||
t.Fatalf("physical recovery file must remain on disk: %v", err)
|
||||
}
|
||||
if len(wantTopics) != 0 {
|
||||
t.Fatalf("migrated topics missing %v: %#v", wantTopics, nodes[0].Children)
|
||||
}
|
||||
if meta, ok, err := agent.LoadBranchMeta(recovery); err != nil || !ok {
|
||||
if meta, ok, err := agent.LoadBranchMeta(recovery); err != nil {
|
||||
t.Fatalf("load recovery meta: %v", err)
|
||||
} else if strings.TrimSpace(meta.TopicID) == "" {
|
||||
t.Fatal("ambiguous legacy recovery branch was not migrated into a visible topic")
|
||||
} else if ok && strings.TrimSpace(meta.TopicID) == "" && !agent.LooksLikeRecoveryFilename(recovery) {
|
||||
t.Fatal("legacy recovery branch lost both meta topic and filename lineage")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
)
|
||||
|
||||
// DismissTodoBatchForTab records a closed completed todo list on the session
|
||||
// sidecar (and its parent/covering leaf) so the shelf stays hidden after an
|
||||
// upgrade remounts the same conversation on a different path.
|
||||
func (a *App) DismissTodoBatchForTab(tabID, batchKey string) error {
|
||||
tab := a.tabByID(tabID)
|
||||
if tab == nil {
|
||||
return a.workspaceNotReadyErr(nil)
|
||||
}
|
||||
sessionPath := strings.TrimSpace(tab.currentSessionPath())
|
||||
batchKey = strings.TrimSpace(batchKey)
|
||||
if sessionPath == "" || batchKey == "" {
|
||||
return nil
|
||||
}
|
||||
var firstErr error
|
||||
for _, path := range a.todoDismissalPersistPaths(sessionPath) {
|
||||
if err := agent.RecordDismissedTodoBatch(path, batchKey); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (a *App) todoDismissalReadPaths(sessionPath string) []string {
|
||||
sessionPath = strings.TrimSpace(sessionPath)
|
||||
if sessionPath == "" {
|
||||
return nil
|
||||
}
|
||||
paths := []string{sessionPath}
|
||||
if parent := parentSessionPathForTodoDismissal(sessionPath); parent != "" {
|
||||
paths = append(paths, parent)
|
||||
}
|
||||
return uniqueExistingSessionPaths(paths)
|
||||
}
|
||||
|
||||
func (a *App) todoDismissalPersistPaths(sessionPath string) []string {
|
||||
paths := a.todoDismissalReadPaths(sessionPath)
|
||||
if a != nil {
|
||||
if leaf := strings.TrimSpace(a.continuePathForOpen(sessionPath)); leaf != "" {
|
||||
paths = append(paths, leaf)
|
||||
}
|
||||
}
|
||||
return uniqueExistingSessionPaths(paths)
|
||||
}
|
||||
|
||||
func (a *App) dismissedTodoBatchesForSession(sessionPath string) []string {
|
||||
var sets [][]string
|
||||
for _, path := range a.todoDismissalReadPaths(sessionPath) {
|
||||
sets = append(sets, agent.DismissedTodoBatches(path))
|
||||
}
|
||||
out := agent.MergeDismissedTodoBatches(sets...)
|
||||
if out == nil {
|
||||
return []string{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parentSessionPathForTodoDismissal(sessionPath string) string {
|
||||
meta, ok, err := agent.LoadBranchMeta(sessionPath)
|
||||
if err != nil || !ok {
|
||||
return ""
|
||||
}
|
||||
parentID := strings.TrimSpace(meta.ParentID)
|
||||
if !safeTodoDismissalParentID(parentID) || parentID == agent.BranchID(sessionPath) {
|
||||
return ""
|
||||
}
|
||||
dir := filepath.Clean(filepath.Dir(sessionPath))
|
||||
candidate := filepath.Join(dir, parentID+".jsonl")
|
||||
if filepath.Dir(filepath.Clean(candidate)) != dir {
|
||||
return ""
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
func safeTodoDismissalParentID(parentID string) bool {
|
||||
if parentID == "" || parentID == "." || parentID == ".." {
|
||||
return false
|
||||
}
|
||||
return filepath.Base(parentID) == parentID && !strings.ContainsAny(parentID, `/\`)
|
||||
}
|
||||
|
||||
func uniqueExistingSessionPaths(paths []string) []string {
|
||||
seen := make(map[string]struct{}, len(paths))
|
||||
out := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
key := agent.CanonicalSessionPath(path)
|
||||
if key == "" {
|
||||
key = path
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if _, metaErr := os.Stat(agent.BranchMetaPath(path)); metaErr != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
)
|
||||
|
||||
func TestDismissTodoBatchPersistsOnSessionAndParent(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
parent := filepath.Join(dir, "parent.jsonl")
|
||||
leaf := filepath.Join(dir, "leaf.jsonl")
|
||||
if err := os.WriteFile(parent, []byte("{}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(leaf, []byte("{}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := agent.SaveBranchMeta(leaf, agent.BranchMeta{ID: "leaf", ParentID: "parent"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.tabs = map[string]*WorkspaceTab{"tab-1": {ID: "tab-1", SessionPath: leaf}}
|
||||
app.activeTabID = "tab-1"
|
||||
|
||||
if err := app.DismissTodoBatchForTab("tab-1", `[{"content":"Ship"}]`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, path := range []string{leaf, parent} {
|
||||
got, ok, err := agent.LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta(%s) = %+v ok=%v err=%v", path, got, ok, err)
|
||||
}
|
||||
if len(got.DismissedTodoBatches) != 1 || got.DismissedTodoBatches[0] != `[{"content":"Ship"}]` {
|
||||
t.Fatalf("%s dismissed batches = %#v", path, got.DismissedTodoBatches)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaForTabSurfacesParentDismissedTodoBatches(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
parent := filepath.Join(dir, "parent.jsonl")
|
||||
leaf := filepath.Join(dir, "leaf.jsonl")
|
||||
if err := os.WriteFile(parent, []byte("{}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(leaf, []byte("{}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := agent.SaveBranchMeta(parent, agent.BranchMeta{
|
||||
ID: "parent", DismissedTodoBatches: []string{`[{"content":"Ship"}]`},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := agent.SaveBranchMeta(leaf, agent.BranchMeta{ID: "leaf", ParentID: "parent"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.tabs = map[string]*WorkspaceTab{"tab-1": {ID: "tab-1", SessionPath: leaf, Ready: true}}
|
||||
app.activeTabID = "tab-1"
|
||||
|
||||
got := app.dismissedTodoBatchesForSession(leaf)
|
||||
if len(got) != 1 || got[0] != `[{"content":"Ship"}]` {
|
||||
t.Fatalf("lineage batches = %#v", got)
|
||||
}
|
||||
|
||||
meta := app.MetaForTab("tab-1")
|
||||
if len(meta.DismissedTodoBatches) != 1 || meta.DismissedTodoBatches[0] != `[{"content":"Ship"}]` {
|
||||
t.Fatalf("meta dismissed batches = %#v", meta.DismissedTodoBatches)
|
||||
}
|
||||
raw, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"dismissedTodoBatches"`) {
|
||||
t.Fatalf("meta JSON missing dismissedTodoBatches: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissedTodoBatchesEmptySliceNotNull(t *testing.T) {
|
||||
meta := Meta{DismissedTodoBatches: []string{}}
|
||||
raw, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `"dismissedTodoBatches":null`) {
|
||||
t.Fatalf("empty dismissed batches encoded as null: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissTodoBatchSkipsMissingTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
app := NewApp()
|
||||
if err := app.DismissTodoBatchForTab("missing", "batch"); err == nil {
|
||||
t.Fatal("missing tab should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaForTabDoesNotReadSiblingCoveringLeaf(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
parent := filepath.Join(dir, "parent.jsonl")
|
||||
leaf := filepath.Join(dir, "leaf.jsonl")
|
||||
if err := os.WriteFile(parent, []byte("{}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(leaf, []byte("{}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := agent.SaveBranchMeta(parent, agent.BranchMeta{ID: "parent"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := agent.SaveBranchMeta(leaf, agent.BranchMeta{
|
||||
ID: "leaf", ParentID: "parent", DismissedTodoBatches: []string{`[{"content":"Ship"}]`},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.tabs = map[string]*WorkspaceTab{"tab-1": {ID: "tab-1", SessionPath: parent, Ready: true}}
|
||||
app.activeTabID = "tab-1"
|
||||
if got := app.dismissedTodoBatchesForSession(parent); len(got) != 0 {
|
||||
t.Fatalf("read path saw covering-leaf batches = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParentSessionPathRejectsTraversal(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
outside := filepath.Join(filepath.Dir(dir), "escaped.jsonl")
|
||||
leaf := filepath.Join(dir, "leaf.jsonl")
|
||||
if err := os.WriteFile(leaf, []byte("{}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := agent.SaveBranchMeta(leaf, agent.BranchMeta{ID: "leaf", ParentID: filepath.Join("..", "escaped")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := parentSessionPathForTodoDismissal(leaf); got != "" {
|
||||
t.Fatalf("traversal parent = %q", got)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.tabs = map[string]*WorkspaceTab{"tab-1": {ID: "tab-1", SessionPath: leaf}}
|
||||
if err := app.DismissTodoBatchForTab("tab-1", `[{"content":"Ship"}]`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(agent.BranchMetaPath(outside)); !os.IsNotExist(err) {
|
||||
t.Fatalf("traversal wrote outside the session dir: %v", err)
|
||||
}
|
||||
if !safeTodoDismissalParentID("parent") || safeTodoDismissalParentID("../escaped") || safeTodoDismissalParentID(`foo/bar`) {
|
||||
t.Fatal("parent id sanitizer accepted a path-shaped id")
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,11 @@ func (a *App) runTopicActivationCompletion(gen uint64, requestID, tabID string)
|
||||
<-buildDone
|
||||
}
|
||||
|
||||
// A reused/reattached runtime is already usable. Publish ready before
|
||||
// prune: keepOnlyVisibleTab takes runtimeRebuildMu, which an in-flight
|
||||
// MCP rebuild on the previous tab can hold for a long time.
|
||||
emittedReady := a.emitTopicActivationReadyIfCurrent(gen, requestID, tabID)
|
||||
|
||||
// The generation check and the prune serialize against new activations
|
||||
// through singleSurfaceMu: either this completion runs entirely before the
|
||||
// next activation's synchronous phase (its tabs are not there to prune),
|
||||
@@ -249,14 +254,22 @@ func (a *App) runTopicActivationCompletion(gen uint64, requestID, tabID string)
|
||||
|
||||
if _, err := a.keepOnlyVisibleTab(tabID); err != nil {
|
||||
a.finishTopicActivation(gen, requestID)
|
||||
a.emitTopicActivation(TopicActivationEvent{
|
||||
RequestID: requestID,
|
||||
TabID: tabID,
|
||||
Phase: topicActivationPhaseFailed,
|
||||
// keepOnlyVisibleTab errors can wrap snapshot/path details; the
|
||||
// event stays generic, the slog entry keeps the specifics.
|
||||
Error: "failed to switch the visible session",
|
||||
})
|
||||
if !emittedReady {
|
||||
a.emitTopicActivation(TopicActivationEvent{
|
||||
RequestID: requestID,
|
||||
TabID: tabID,
|
||||
Phase: topicActivationPhaseFailed,
|
||||
// keepOnlyVisibleTab errors can wrap snapshot/path details; the
|
||||
// event stays generic, the slog entry keeps the specifics.
|
||||
Error: "failed to switch the visible session",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if emittedReady {
|
||||
a.finishTopicActivation(gen, requestID)
|
||||
a.scheduleTabMetaExtrasRefresh(tabID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -291,6 +304,21 @@ func (a *App) runTopicActivationCompletion(gen uint64, requestID, tabID string)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) emitTopicActivationReadyIfCurrent(gen uint64, requestID, tabID string) bool {
|
||||
a.mu.RLock()
|
||||
tab := a.tabs[tabID]
|
||||
ok := a.activationGen == gen &&
|
||||
a.latestActivationRequestID == requestID &&
|
||||
a.pendingActivationTabID == tabID &&
|
||||
tab != nil && tab.Ready && tab.Ctrl != nil
|
||||
a.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
a.emitTopicActivation(TopicActivationEvent{RequestID: requestID, TabID: tabID, Phase: topicActivationPhaseReady})
|
||||
return true
|
||||
}
|
||||
|
||||
// sanitizedTopicActivationError keeps local paths and lease-holder writer IDs
|
||||
// out of the activation event. The lease-busy message is already sanitized;
|
||||
// everything else degrades to a generic summary — the full detail remains
|
||||
|
||||
@@ -311,9 +311,13 @@ type activationStubController struct {
|
||||
control.SessionAPI
|
||||
sessionPath string
|
||||
closed atomic.Bool
|
||||
status *control.RuntimeStatus
|
||||
}
|
||||
|
||||
func (c *activationStubController) RuntimeStatus() control.RuntimeStatus {
|
||||
if c.status != nil {
|
||||
return *c.status
|
||||
}
|
||||
return control.RuntimeStatus{Running: true}
|
||||
}
|
||||
func (c *activationStubController) SessionPath() string { return c.sessionPath }
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -70,6 +72,19 @@ func (a *App) trashTopic(topicID string) (retErr error) {
|
||||
slog.Warn("desktop: open fallback after topic archive failed")
|
||||
}
|
||||
}
|
||||
keepPath := ""
|
||||
a.mu.RLock()
|
||||
if tab := a.tabs[a.activeTabID]; tab != nil {
|
||||
keepPath = tab.SessionPath
|
||||
}
|
||||
if fallback.workspaceRoot != "" {
|
||||
changedDirs = append(changedDirs, desktopSessionDir(fallback.workspaceRoot))
|
||||
} else if fallback.needs {
|
||||
changedDirs = append(changedDirs, desktopSessionDir(globalWorkspaceRoot()))
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
trace.phase = "discard_unused_blanks"
|
||||
a.discardUnusedTransientBlankSessions(changedDirs, keepPath)
|
||||
trace.phase = "notify"
|
||||
if len(changedDirs) > 0 {
|
||||
a.emitProjectTreeChangedForSessionDirs(changedDirs...)
|
||||
@@ -268,3 +283,95 @@ func (a *App) topicTrashTargets(topicID string) ([]topicTrashTarget, error) {
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func topicIndexedInRegistry(scope, workspaceRoot, topicID string) bool {
|
||||
topicID = strings.TrimSpace(topicID)
|
||||
if topicID == "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(loadTopicTitles(topicTitleRoot(scope, workspaceRoot))[topicID]) != "" {
|
||||
return true
|
||||
}
|
||||
f := loadProjectsFile()
|
||||
if scope != "project" {
|
||||
return containsDesktopString(f.GlobalTopics, topicID)
|
||||
}
|
||||
if i := projectIndexByRoot(f.Projects, workspaceRoot); i >= 0 {
|
||||
return containsDesktopString(f.Projects[i].Topics, topicID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) discardUnusedTransientBlankSessions(dirs []string, keepPath string) {
|
||||
keepPath = canonicalTabSessionPath(strings.TrimSpace(keepPath))
|
||||
kept := map[string]bool{}
|
||||
if keepPath != "" {
|
||||
kept[keepPath] = true
|
||||
}
|
||||
if a != nil {
|
||||
a.mu.RLock()
|
||||
for _, tabs := range []map[string]*WorkspaceTab{a.tabs, a.detachedSessions} {
|
||||
for _, tab := range tabs {
|
||||
if tab == nil {
|
||||
continue
|
||||
}
|
||||
if path := canonicalTabSessionPath(strings.TrimSpace(tab.SessionPath)); path != "" {
|
||||
kept[path] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, dir := range dirs {
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" || seen[dir] {
|
||||
continue
|
||||
}
|
||||
seen[dir] = true
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || !store.IsSessionTranscriptName(name) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
if kept[canonicalTabSessionPath(path)] {
|
||||
continue
|
||||
}
|
||||
if !unusedTransientBlankSession(dir, path) {
|
||||
continue
|
||||
}
|
||||
discardTransientBlankSessionArtifacts(path)
|
||||
if a != nil {
|
||||
a.removeSessionCatalogPath(path, "transient_blank_discarded")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func unusedTransientBlankSession(dir, path string) bool {
|
||||
resolved, ok := pinnedTabSessionPath(dir, path)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(resolved)
|
||||
if err != nil || info.IsDir() || info.Size() != 0 {
|
||||
return false
|
||||
}
|
||||
meta, ok, err := agent.LoadBranchMeta(resolved)
|
||||
if err != nil || !ok {
|
||||
return true
|
||||
}
|
||||
topicID := strings.TrimSpace(meta.TopicID)
|
||||
if topicID == "" {
|
||||
return true
|
||||
}
|
||||
if !isDefaultTopicTitle(meta.TopicTitle) && strings.TrimSpace(meta.TopicTitle) != "" {
|
||||
return false
|
||||
}
|
||||
return !topicIndexedInRegistry(meta.DefaultScope(), meta.WorkspaceRoot, topicID)
|
||||
}
|
||||
@@ -476,3 +476,139 @@ func TestTopicArchiveOwnershipRollbackRestoresLocalLease(t *testing.T) {
|
||||
t.Fatalf("competing lease after rollback err = %v, want ErrSessionLeaseHeld", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicFallbackStaysOffCatalogSidebar(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
if err := addProject(projectRoot, "Archive Sidebar"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
keepID := "topic_keep"
|
||||
archiveID := "topic_archive"
|
||||
if err := setTopicTitle(projectRoot, keepID, "Keep me"); err != nil {
|
||||
t.Fatalf("set keep title: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, archiveID, "Archive me"); err != nil {
|
||||
t.Fatalf("set archive title: %v", err)
|
||||
}
|
||||
dir := desktopSessionDir(projectRoot)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
keepPath := writeTopicSession(t, dir, "keep.jsonl", keepID, "Keep me", projectRoot)
|
||||
archivePath := writeTopicSession(t, dir, "archive.jsonl", archiveID, "Archive me", projectRoot)
|
||||
leftoverA := filepath.Join(dir, "leftover-a.jsonl")
|
||||
leftoverB := filepath.Join(dir, "leftover-b.jsonl")
|
||||
for _, path := range []string{leftoverA, leftoverB} {
|
||||
writeZeroByteSession(t, path)
|
||||
if err := pinNewEmptySessionBranchMeta(path, "project", projectRoot, "", defaultTopicTitle); err != nil {
|
||||
t.Fatalf("pin leftover %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
ghostID := agent.BranchID(filepath.Join(dir, "ghost-blank.jsonl"))
|
||||
ghostPath := writeEmptyNamedSession(t, dir, "ghost-blank.jsonl", ghostID, defaultTopicTitle, projectRoot)
|
||||
nonzeroPath := filepath.Join(dir, "system-only.jsonl")
|
||||
if err := os.WriteFile(nonzeroPath, []byte(`{"role":"system","content":"identity"}`+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write non-empty sibling: %v", err)
|
||||
}
|
||||
if err := pinNewEmptySessionBranchMeta(nonzeroPath, "project", projectRoot, "", defaultTopicTitle); err != nil {
|
||||
t.Fatalf("pin non-empty sibling: %v", err)
|
||||
}
|
||||
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: archivePath, Label: "test", WorkspaceRoot: projectRoot})
|
||||
defer ctrl.Close()
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"archive": {
|
||||
ID: "archive", Scope: "project", WorkspaceRoot: projectRoot,
|
||||
TopicID: archiveID, TopicTitle: "Archive me", Ctrl: ctrl, Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
},
|
||||
},
|
||||
tabOrder: []string{"archive"},
|
||||
activeTabID: "archive",
|
||||
}
|
||||
installSessionCatalogForTest(t, app, dir, "project", projectRoot)
|
||||
|
||||
if err := app.TrashTopic(archiveID); err != nil {
|
||||
t.Fatalf("TrashTopic: %v", err)
|
||||
}
|
||||
reconcileSessionCatalogForTest(t, app, dir, "project", projectRoot)
|
||||
|
||||
if _, err := os.Stat(archivePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("archived session should be gone, stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(keepPath); err != nil {
|
||||
t.Fatalf("kept session missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(nonzeroPath); err != nil {
|
||||
t.Fatalf("non-empty sibling must not be swept, stat err = %v", err)
|
||||
}
|
||||
for _, path := range []string{leftoverA, leftoverB, ghostPath} {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("unused transient blank %s should be discarded, stat err = %v", path, err)
|
||||
}
|
||||
}
|
||||
if len(app.tabs) != 1 {
|
||||
t.Fatalf("fallback should create exactly one visible tab, got %d", len(app.tabs))
|
||||
}
|
||||
var fallbackPath string
|
||||
for id, tab := range app.tabs {
|
||||
if strings.TrimSpace(tab.TopicID) != "" {
|
||||
t.Fatalf("fallback tab %q topic ID = %q, want transient unindexed blank", id, tab.TopicID)
|
||||
}
|
||||
fallbackPath = tab.SessionPath
|
||||
if strings.TrimSpace(fallbackPath) == "" {
|
||||
t.Fatalf("fallback tab %q has no precreated session path", id)
|
||||
}
|
||||
}
|
||||
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: projectRoot, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("ListProjectTopics: %v", err)
|
||||
}
|
||||
if len(page.Items) != 1 || page.Items[0].TopicID != keepID {
|
||||
t.Fatalf("sidebar after archive = %#v, want only %q", page.Items, keepID)
|
||||
}
|
||||
if page.Items[0].Label == defaultTopicTitle || page.Items[0].Label == "New session" {
|
||||
t.Fatalf("sidebar listed a default blank title: %#v", page.Items[0])
|
||||
}
|
||||
if fallbackPath != "" {
|
||||
if _, err := os.Stat(fallbackPath); err != nil {
|
||||
t.Fatalf("current fallback blank should remain writable: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnusedTransientBlankSessionOnlyMatchesZeroByteFiles(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
zero := filepath.Join(dir, "zero.jsonl")
|
||||
writeZeroByteSession(t, zero)
|
||||
if !unusedTransientBlankSession(dir, zero) {
|
||||
t.Fatal("zero-byte unindexed session should be an unused transient blank")
|
||||
}
|
||||
nonzero := filepath.Join(dir, "nonzero.jsonl")
|
||||
if err := os.WriteFile(nonzero, []byte(`{"role":"system","content":"identity"}`+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write non-empty session: %v", err)
|
||||
}
|
||||
if unusedTransientBlankSession(dir, nonzero) {
|
||||
t.Fatal("non-empty session must not be classified as an unused transient blank")
|
||||
}
|
||||
}
|
||||
|
||||
func writeZeroByteSession(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, nil, 0o600); err != nil {
|
||||
t.Fatalf("write zero-byte session %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeEmptyNamedSession(t *testing.T, dir, name, topicID, topicTitle, workspaceRoot string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
writeZeroByteSession(t, path)
|
||||
if err := pinNewEmptySessionBranchMeta(path, "project", workspaceRoot, topicID, topicTitle); err != nil {
|
||||
t.Fatalf("pin empty session: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -365,22 +365,7 @@ func sessionInfoIsUnmodifiedRecoveryCopy(info agent.SessionInfo, parentDir strin
|
||||
}
|
||||
|
||||
func isAutomaticRecoverySessionPath(path string) bool {
|
||||
id := agent.BranchID(path)
|
||||
idx := strings.LastIndex(id, "-recovery-")
|
||||
if idx <= 0 {
|
||||
return false
|
||||
}
|
||||
suffix := id[idx+len("-recovery-"):]
|
||||
if len(suffix) != 16 {
|
||||
return false
|
||||
}
|
||||
for _, r := range suffix {
|
||||
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
return agent.LooksLikeRecoveryFilename(path)
|
||||
}
|
||||
|
||||
func legacyMigrationTargetForDir(dir string) (scope, workspaceRoot, topicTitleRoot string, ok bool) {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/sessioncatalog"
|
||||
)
|
||||
|
||||
// preferLiveSessionPath chooses which session a topic row should open.
|
||||
// A live runtime wins over ordinary catalog rows (RepresentativePath,
|
||||
// canonical leaf, and the covering parent that continue would follow).
|
||||
// An explicit non-ordinary path (History inspect) is left alone.
|
||||
func preferLiveSessionPath(requested, live string, ordinary ...string) string {
|
||||
liveKey := sessionRuntimeKey(live)
|
||||
if liveKey == "" {
|
||||
return requested
|
||||
}
|
||||
reqKey := sessionRuntimeKey(requested)
|
||||
if reqKey == "" || reqKey == liveKey {
|
||||
if reqKey == "" {
|
||||
return live
|
||||
}
|
||||
return requested
|
||||
}
|
||||
if len(ordinary) == 0 {
|
||||
return live
|
||||
}
|
||||
for _, path := range ordinary {
|
||||
if sessionRuntimeKey(path) == reqKey {
|
||||
return live
|
||||
}
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
func (a *App) liveSessionPathForTopic(scope, workspaceRoot, topicID string) string {
|
||||
if a == nil {
|
||||
return ""
|
||||
}
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if tab := a.liveRuntimeForTopicLocked(scope, workspaceRoot, topicID); tab != nil {
|
||||
return tab.currentSessionPath()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *App) liveRuntimeForTopicLocked(scope, workspaceRoot, topicID string) *WorkspaceTab {
|
||||
var best *WorkspaceTab
|
||||
visit := func(tab *WorkspaceTab) {
|
||||
if tab == nil || !tabMatchesTopicTarget(tab, scope, workspaceRoot, topicID) {
|
||||
return
|
||||
}
|
||||
if tab.currentSessionPath() == "" {
|
||||
return
|
||||
}
|
||||
if best == nil {
|
||||
best = tab
|
||||
return
|
||||
}
|
||||
if best.Ctrl == nil && tab.Ctrl != nil {
|
||||
best = tab
|
||||
return
|
||||
}
|
||||
if normalizeTopicStatus(best.ActivityStatus) == "" && normalizeTopicStatus(tab.ActivityStatus) != "" {
|
||||
best = tab
|
||||
}
|
||||
}
|
||||
for _, tab := range a.tabs {
|
||||
visit(tab)
|
||||
}
|
||||
for _, tab := range a.detachedSessions {
|
||||
visit(tab)
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func (a *App) resolveOpenSessionPath(scope, workspaceRoot, topicID, requested string) string {
|
||||
live := a.liveSessionPathForTopic(scope, workspaceRoot, topicID)
|
||||
return preferLiveSessionPath(requested, live, a.ordinaryOpenSessionPaths(scope, workspaceRoot, topicID, requested)...)
|
||||
}
|
||||
|
||||
func (a *App) catalogTopicRecord(scope, workspaceRoot, topicID string) (sessioncatalog.TopicRecord, bool) {
|
||||
if a == nil || strings.TrimSpace(topicID) == "" {
|
||||
return sessioncatalog.TopicRecord{}, false
|
||||
}
|
||||
catalog := a.sessionCatalog.Load()
|
||||
if catalog == nil {
|
||||
return sessioncatalog.TopicRecord{}, false
|
||||
}
|
||||
topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{
|
||||
Scope: scope, WorkspaceRoot: workspaceRoot, TopicID: topicID,
|
||||
})
|
||||
if err != nil || !ok {
|
||||
return sessioncatalog.TopicRecord{}, false
|
||||
}
|
||||
return topic, true
|
||||
}
|
||||
|
||||
// ordinaryOpenSessionPaths are the catalog identities of the sidebar row:
|
||||
// RepresentativePath, the canonical covering leaf, and the parent that
|
||||
// continue would follow. History inspect paths are not in this set.
|
||||
func (a *App) ordinaryOpenSessionPaths(scope, workspaceRoot, topicID, requested string) []string {
|
||||
var ordinary []string
|
||||
if topic, ok := a.catalogTopicRecord(scope, workspaceRoot, topicID); ok {
|
||||
if rep := strings.TrimSpace(topic.RepresentativePath); rep != "" {
|
||||
ordinary = append(ordinary, rep)
|
||||
}
|
||||
if canonical := sessioncatalog.CanonicalSessionPathForTopic(topic.Sessions, ""); canonical != "" {
|
||||
ordinary = append(ordinary, canonical)
|
||||
}
|
||||
} else if path := a.catalogSessionPathForTopic(scope, workspaceRoot, topicID); path != "" {
|
||||
ordinary = append(ordinary, path)
|
||||
}
|
||||
if continued := a.continuePathForOpen(requested); continued != "" {
|
||||
ordinary = append(ordinary, requested, continued)
|
||||
}
|
||||
return ordinary
|
||||
}
|
||||
|
||||
func (a *App) openTopicTabPreferLiveActivation(scope, workspaceRoot, topicID, sessionPath string, activate bool) (TabMeta, error) {
|
||||
sessionPath = a.resolveOpenSessionPath(scope, workspaceRoot, topicID, sessionPath)
|
||||
a.mu.Lock()
|
||||
if promoted := a.promoteDetachedRuntimeLocked(sessionPath, activate); promoted != nil {
|
||||
meta := a.tabMeta(promoted, promoted.ID == a.activeTabID)
|
||||
a.saveTabsLocked()
|
||||
a.mu.Unlock()
|
||||
return enrichTabMeta(meta), nil
|
||||
}
|
||||
a.mu.Unlock()
|
||||
return a.openTopicTabWithActivation(scope, workspaceRoot, topicID, sessionPath, activate)
|
||||
}
|
||||
|
||||
func (a *App) promoteDetachedRuntimeLocked(sessionPath string, activate bool) *WorkspaceTab {
|
||||
key := sessionRuntimeKey(sessionPath)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
tab := a.detachedSessions[key]
|
||||
if tab == nil || tab.Ctrl == nil {
|
||||
return nil
|
||||
}
|
||||
delete(a.detachedSessions, key)
|
||||
if a.tabs[tab.ID] == nil {
|
||||
a.tabs[tab.ID] = tab
|
||||
a.tabOrder = append(a.tabOrder, tab.ID)
|
||||
}
|
||||
if activate {
|
||||
a.activeTabID = tab.ID
|
||||
}
|
||||
return tab
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/sessioncatalog"
|
||||
)
|
||||
|
||||
func TestStartTopicActivationPrefersLiveRuntimeOverRepresentative(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
sessionDir := desktopSessionDir(projectRoot)
|
||||
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
oldPath := writeTopicSessionWithPrompt(t, sessionDir, "old.jsonl", "topic-b", "Topic B", projectRoot, "yesterday unsigned todos", time.Now().Add(-24*time.Hour))
|
||||
livePath := writeTopicSessionWithPrompt(t, sessionDir, "live.jsonl", "topic-b", "Topic B", projectRoot, "today live turn", time.Now())
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
app.readyHook = func() {}
|
||||
events := newActivationEventRecorder(app)
|
||||
t.Cleanup(func() { app.shutdown(context.Background()) })
|
||||
|
||||
stub := &activationStubController{sessionPath: livePath}
|
||||
live := &WorkspaceTab{
|
||||
ID: "tab-live",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicID: "topic-b",
|
||||
TopicTitle: "Topic B",
|
||||
SessionPath: livePath,
|
||||
Ctrl: stub,
|
||||
Label: "stub-model",
|
||||
Ready: true,
|
||||
ActivityStatus: topicStatusPaused,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
live.sink = &tabEventSink{tabID: live.ID, app: app}
|
||||
installNoopRuntimeEvents(app, live.sink)
|
||||
if err := live.ensureSessionLease(livePath); err != nil {
|
||||
t.Fatalf("ensureSessionLease live: %v", err)
|
||||
}
|
||||
app.detachedSessions[sessionRuntimeKey(livePath)] = live
|
||||
|
||||
ticket, err := app.StartTopicActivation(TopicActivationRequest{
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicID: "topic-b",
|
||||
SessionPath: oldPath,
|
||||
RequestID: "req-live",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StartTopicActivation: %v", err)
|
||||
}
|
||||
events.waitFor(t, activationEventFor("req-live", "ready"))
|
||||
|
||||
app.mu.RLock()
|
||||
tab := app.tabs[ticket.TabID]
|
||||
app.mu.RUnlock()
|
||||
if tab == nil {
|
||||
t.Fatal("activated tab missing")
|
||||
}
|
||||
if tab.Ctrl != stub {
|
||||
t.Fatal("clicking the live topic row opened the catalog representative instead of attaching the live controller")
|
||||
}
|
||||
if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(livePath) {
|
||||
t.Fatalf("session path = %q, want live %q", tab.currentSessionPath(), livePath)
|
||||
}
|
||||
if stub.closed.Load() {
|
||||
t.Fatal("live controller was closed while attaching")
|
||||
}
|
||||
if ticket.Meta.SessionPath != "" && sessionRuntimeKey(ticket.Meta.SessionPath) != sessionRuntimeKey(livePath) {
|
||||
t.Fatalf("ticket session path = %q, want live %q", ticket.Meta.SessionPath, livePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartTopicActivationReadyDoesNotWaitForRebuildMutex(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
sessionDir := desktopSessionDir(projectRoot)
|
||||
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
pathA := writeTopicSession(t, sessionDir, "a.jsonl", "topic-a", "Topic A", projectRoot)
|
||||
oldB := writeTopicSessionWithPrompt(t, sessionDir, "b-old.jsonl", "topic-b", "Topic B", projectRoot, "old b", time.Now().Add(-time.Hour))
|
||||
liveB := writeTopicSessionWithPrompt(t, sessionDir, "b-live.jsonl", "topic-b", "Topic B", projectRoot, "live b", time.Now())
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
app.readyHook = func() {}
|
||||
events := newActivationEventRecorder(app)
|
||||
t.Cleanup(func() { app.shutdown(context.Background()) })
|
||||
|
||||
stubA := &activationStubController{sessionPath: pathA}
|
||||
tabA := &WorkspaceTab{
|
||||
ID: "tab-a",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicID: "topic-a",
|
||||
TopicTitle: "Topic A",
|
||||
SessionPath: pathA,
|
||||
Ctrl: stubA,
|
||||
Label: "stub-a",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tabA.sink = &tabEventSink{tabID: tabA.ID, app: app}
|
||||
installNoopRuntimeEvents(app, tabA.sink)
|
||||
if err := tabA.ensureSessionLease(pathA); err != nil {
|
||||
t.Fatalf("ensureSessionLease A: %v", err)
|
||||
}
|
||||
app.tabs[tabA.ID] = tabA
|
||||
app.tabOrder = []string{tabA.ID}
|
||||
app.activeTabID = tabA.ID
|
||||
|
||||
stubB := &activationStubController{sessionPath: liveB}
|
||||
live := &WorkspaceTab{
|
||||
ID: "tab-b",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicID: "topic-b",
|
||||
TopicTitle: "Topic B",
|
||||
SessionPath: liveB,
|
||||
Ctrl: stubB,
|
||||
Label: "stub-b",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
live.sink = &tabEventSink{tabID: live.ID, app: app}
|
||||
installNoopRuntimeEvents(app, live.sink)
|
||||
if err := live.ensureSessionLease(liveB); err != nil {
|
||||
t.Fatalf("ensureSessionLease B: %v", err)
|
||||
}
|
||||
app.detachedSessions[sessionRuntimeKey(liveB)] = live
|
||||
|
||||
app.runtimeRebuildMu.Lock()
|
||||
defer app.runtimeRebuildMu.Unlock()
|
||||
|
||||
started := time.Now()
|
||||
ticket, err := app.StartTopicActivation(TopicActivationRequest{
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicID: "topic-b",
|
||||
SessionPath: oldB,
|
||||
RequestID: "req-rebuild",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StartTopicActivation: %v", err)
|
||||
}
|
||||
if time.Since(started) > 300*time.Millisecond {
|
||||
t.Fatalf("StartTopicActivation blocked %s while MCP rebuild held the mutex", time.Since(started))
|
||||
}
|
||||
deadline := time.After(400 * time.Millisecond)
|
||||
for {
|
||||
select {
|
||||
case ev := <-events.ch:
|
||||
if ev.RequestID == "req-rebuild" && ev.Phase == "ready" {
|
||||
if sessionRuntimeKey(ticket.Meta.SessionPath) != sessionRuntimeKey(liveB) {
|
||||
t.Fatalf("ticket path = %q, want live %q", ticket.Meta.SessionPath, liveB)
|
||||
}
|
||||
return
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("ready waited for keepOnlyVisibleTab to acquire the rebuild mutex")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferLiveSessionPathKeepsExplicitNonRepresentativeInspect(t *testing.T) {
|
||||
live := "/sessions/live.jsonl"
|
||||
rep := "/sessions/rep.jsonl"
|
||||
inspect := "/sessions/inspect.jsonl"
|
||||
if got := preferLiveSessionPath(inspect, live, rep); sessionRuntimeKey(got) != sessionRuntimeKey(inspect) {
|
||||
t.Fatalf("inspect path = %q, want %q", got, inspect)
|
||||
}
|
||||
if got := preferLiveSessionPath(rep, live, rep); sessionRuntimeKey(got) != sessionRuntimeKey(live) {
|
||||
t.Fatalf("representative path = %q, want live %q", got, live)
|
||||
}
|
||||
if got := preferLiveSessionPath("", live, rep); sessionRuntimeKey(got) != sessionRuntimeKey(live) {
|
||||
t.Fatalf("empty path = %q, want live %q", got, live)
|
||||
}
|
||||
if got := preferLiveSessionPath(rep, "", rep); sessionRuntimeKey(got) != sessionRuntimeKey(rep) {
|
||||
t.Fatalf("no live runtime = %q, want representative", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferLiveSessionPathTreatsRepresentativeAndCanonicalAsOrdinary(t *testing.T) {
|
||||
live := "/sessions/live.jsonl"
|
||||
parent := "/sessions/parent.jsonl"
|
||||
leaf := "/sessions/leaf.jsonl"
|
||||
inspect := "/sessions/inspect.jsonl"
|
||||
if got := preferLiveSessionPath(parent, live, parent, leaf); sessionRuntimeKey(got) != sessionRuntimeKey(live) {
|
||||
t.Fatalf("covering parent = %q, want live %q", got, live)
|
||||
}
|
||||
if got := preferLiveSessionPath(leaf, live, parent, leaf); sessionRuntimeKey(got) != sessionRuntimeKey(live) {
|
||||
t.Fatalf("canonical leaf = %q, want live %q", got, live)
|
||||
}
|
||||
if got := preferLiveSessionPath(inspect, live, parent, leaf); sessionRuntimeKey(got) != sessionRuntimeKey(inspect) {
|
||||
t.Fatalf("history inspect = %q, want %q", got, inspect)
|
||||
}
|
||||
}
|
||||
|
||||
func installCoveringLeafTopicCatalog(t *testing.T, app *App) (parent, leaf, live string) {
|
||||
t.Helper()
|
||||
dir := desktopSessionDir(globalWorkspaceRoot())
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := provider.Message{Role: provider.RoleUser, Content: "question"}
|
||||
a := provider.Message{Role: provider.RoleAssistant, Content: "answer"}
|
||||
save := func(path, topic string, messages ...provider.Message) {
|
||||
t.Helper()
|
||||
session := agent.NewSession("sys")
|
||||
for _, message := range messages {
|
||||
session.Add(message)
|
||||
}
|
||||
if err := session.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
|
||||
ID: agent.BranchID(path), Scope: "global", TopicID: topic, TopicTitle: "Upgraded",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
parent = filepath.Join(dir, "root.jsonl")
|
||||
leaf = filepath.Join(dir, "leaf.jsonl")
|
||||
live = filepath.Join(dir, "live.jsonl")
|
||||
save(parent, "conversation", q, a)
|
||||
save(leaf, "legacy-leaf-topic", q, a,
|
||||
provider.Message{Role: provider.RoleUser, Content: "next"},
|
||||
provider.Message{Role: provider.RoleAssistant, Content: "done"})
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(leaf, agent.BranchMeta{
|
||||
ID: "leaf", Scope: "global", TopicID: "legacy-leaf-topic",
|
||||
Recovered: true, ParentID: "root", RecoveryDepth: 1,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
save(live, "conversation",
|
||||
provider.Message{Role: provider.RoleUser, Content: "today live turn"},
|
||||
provider.Message{Role: provider.RoleAssistant, Content: "paused here"})
|
||||
installSessionCatalogForTest(t, app, dir, "global", "")
|
||||
return parent, leaf, live
|
||||
}
|
||||
|
||||
func TestResolveOpenTopicSessionPathKeepsPausedLiveOnCoveringParent(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
app := NewApp()
|
||||
parent, leaf, _ := installCoveringLeafTopicCatalog(t, app)
|
||||
paused := &WorkspaceTab{
|
||||
ID: "paused",
|
||||
Scope: "global",
|
||||
TopicID: "conversation",
|
||||
SessionPath: parent,
|
||||
Ctrl: &retargetRuntimeController{status: control.RuntimeStatus{}, path: parent},
|
||||
ActivityStatus: topicStatusPaused,
|
||||
}
|
||||
app.detachedSessions[sessionRuntimeKey(parent)] = paused
|
||||
|
||||
_, resolved := app.resolveOpenTopicSessionPath("global", "", parent)
|
||||
if resolved != parent {
|
||||
t.Fatalf("resolve paused parent = %q, want keep live parent %q (not covering leaf %q)", resolved, parent, leaf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartTopicActivationAttachesPausedLiveWhenOpeningCoveringParent(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
app.readyHook = func() {}
|
||||
events := newActivationEventRecorder(app)
|
||||
t.Cleanup(func() { app.shutdown(context.Background()) })
|
||||
|
||||
parent, leaf, livePath := installCoveringLeafTopicCatalog(t, app)
|
||||
topic, ok := app.catalogTopicRecord("global", "", "conversation")
|
||||
if !ok {
|
||||
t.Fatal("catalog topic missing")
|
||||
}
|
||||
canonical := sessioncatalog.CanonicalSessionPathForTopic(topic.Sessions, "")
|
||||
if sessionRuntimeKey(canonical) != sessionRuntimeKey(leaf) {
|
||||
t.Fatalf("canonical = %q, want covering leaf %q", canonical, leaf)
|
||||
}
|
||||
if topic.RepresentativePath != "" && sessionRuntimeKey(topic.RepresentativePath) != sessionRuntimeKey(parent) && sessionRuntimeKey(topic.RepresentativePath) != sessionRuntimeKey(leaf) {
|
||||
t.Fatalf("representative = %q, want parent %q or covering leaf %q", topic.RepresentativePath, parent, leaf)
|
||||
}
|
||||
|
||||
stub := &activationStubController{sessionPath: livePath, status: &control.RuntimeStatus{}}
|
||||
live := &WorkspaceTab{
|
||||
ID: "tab-live",
|
||||
Scope: "global",
|
||||
TopicID: "conversation",
|
||||
TopicTitle: "Upgraded",
|
||||
SessionPath: livePath,
|
||||
Ctrl: stub,
|
||||
Label: "stub-model",
|
||||
Ready: true,
|
||||
ActivityStatus: topicStatusPaused,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
live.sink = &tabEventSink{tabID: live.ID, app: app}
|
||||
installNoopRuntimeEvents(app, live.sink)
|
||||
if err := live.ensureSessionLease(livePath); err != nil {
|
||||
t.Fatalf("ensureSessionLease live: %v", err)
|
||||
}
|
||||
app.detachedSessions[sessionRuntimeKey(livePath)] = live
|
||||
|
||||
ticket, err := app.StartTopicActivation(TopicActivationRequest{
|
||||
Scope: "global",
|
||||
TopicID: "conversation",
|
||||
SessionPath: parent,
|
||||
RequestID: "req-covering-parent",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StartTopicActivation: %v", err)
|
||||
}
|
||||
events.waitFor(t, activationEventFor("req-covering-parent", "ready"))
|
||||
|
||||
app.mu.RLock()
|
||||
tab := app.tabs[ticket.TabID]
|
||||
app.mu.RUnlock()
|
||||
if tab == nil {
|
||||
t.Fatal("activated tab missing")
|
||||
}
|
||||
if tab.Ctrl != stub {
|
||||
t.Fatal("clicking the covering parent opened the catalog leaf instead of attaching the paused live controller")
|
||||
}
|
||||
if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(livePath) {
|
||||
t.Fatalf("session path = %q, want live %q (parent %q leaf %q)", tab.currentSessionPath(), livePath, parent, leaf)
|
||||
}
|
||||
if ticket.Meta.SessionPath != "" && sessionRuntimeKey(ticket.Meta.SessionPath) != sessionRuntimeKey(livePath) {
|
||||
t.Fatalf("ticket session path = %q, want live %q", ticket.Meta.SessionPath, livePath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import "reasonix/internal/event"
|
||||
|
||||
// topicStatusAwaitingDelivery is a delivery-check pause, not a recovery pause.
|
||||
const topicStatusAwaitingDelivery = "awaiting_delivery"
|
||||
|
||||
func topicStatusFromTurnDone(outcome string) (string, bool) {
|
||||
switch outcome {
|
||||
case event.TurnOutcomeFinalReadiness:
|
||||
return topicStatusAwaitingDelivery, true
|
||||
case event.TurnOutcomeRecoveryPaused:
|
||||
return topicStatusPaused, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ Reasonix 的 Goal 模式(`/goal`)将目标推进(Goal)、验收(Delive
|
||||
| 功能 | 触发方式 | 效果 |
|
||||
|------|----------|------|
|
||||
| 结构化完成协议 | `update_goal` 工具 | 每轮目标 turn 结束时模型通过工具报告 continue/complete/blocked(含 reason 与 next_action),取代旧的 `[goal:*]` footer 文本标记 |
|
||||
| 完成校验 | 默认 | `complete` 声明必须通过 Delivery readiness(todos、验证、review、签收、能力门禁)才会真正完成;不满足时用缺失项开启下一轮 |
|
||||
| 完成校验 | 默认 | `complete` 必须通过 todos 与必做工作(mutation、capability);Light/Balanced 下已声明 `unverified` 的检查缺口不阻塞;同一检查缺口被连续 `complete` 两次后直接完成,未完成的 todos 仍继续推进 |
|
||||
| 完成自述与对账 | `update_goal` 的 `completion` | `complete` 可附带自述:`verified` 命令逐条与本会话真实 receipt 对账,没跑过 / 跑失败 / 早于最后一次改动都记为 unbacked claim;`unverified` 与 `risks` 是宿主推断不出的声明,只增不减,永远不阻塞完成 |
|
||||
| 独立评审 | 无报告时 | 模型未调用 `update_goal` 时,宿主调用一次独立 bounded evaluator 判定;评审不可用/出错/不确定时安全暂停,绝不默认继续 |
|
||||
| 连续执行 | 默认 | 不设默认 model rounds、Goal turns、墙钟时长或数字式卡死上限;相同宿主失败、零新增证据和 Todo 停滞只触发重新规划,不暂停 Goal |
|
||||
@@ -40,7 +40,7 @@ Reasonix 的 Goal 模式(`/goal`)将目标推进(Goal)、验收(Delive
|
||||
}}
|
||||
```
|
||||
|
||||
宿主对这份自述只做一件事——**对账**。`verified` 里的每条命令都会去本会话的真实 receipt 里找:没跑过、跑失败、或者最后一次运行早于最后一次改动,都会被记成一条 unbacked claim。`unverified` 与 `risks` 宿主无从推断,因此原样保留;它们**只增不减**,一份自述永远不能抹掉宿主自己发现的缺口,也永远不会因为诚实申报而阻塞完成。
|
||||
宿主对这份自述做两件事。`verified` 里的每条命令都会去本会话的真实 receipt 里找:没跑过、跑失败、或者最后一次运行早于最后一次改动,都会被记成一条 unbacked claim。`unverified` 与 `risks` 宿主无从推断,因此原样保留,也**只增不减**——一份自述永远不能抹掉宿主自己发现的缺口。在 Light/Balanced(以及用户禁止测试)时,诚实申报的检查缺口不再阻塞 `complete`;同一检查缺口连续两次 `complete` 后也会结束 Goal,而不是把模型打回验证循环。未完成的 todos 仍继续推进。
|
||||
|
||||
`update_goal` 只在活动 Goal turn 中可用;普通聊天调用会收到结构化错误且不改变任何状态。同值重复调用幂等,`continue` 可升级为 `complete`/`blocked`,终态后冲突调用被拒绝;目标被替换或清除后,迟到的报告/用量一律按 scope+epoch 拒绝。
|
||||
|
||||
@@ -142,7 +142,7 @@ Prometheus 会逐个问澄清问题:
|
||||
5. 应用 readiness 与 evaluator fail-closed 结果;
|
||||
6. 由 Goal FSM 独占决定 complete、continue、blocked 或 pause。
|
||||
|
||||
`complete` 只有在 readiness 通过时才被接受;`blocked` 立即停止;evaluator 超时、报错、JSON 非法或返回 `uncertain` 一律安全暂停。
|
||||
`complete` 在 readiness 通过、或仅剩检查缺口且模型已声明 `unverified`(Light/Balanced)或连续两次同一检查缺口时被接受;`blocked` 立即停止;evaluator 超时、报错、JSON 非法或返回 `uncertain` 一律安全暂停。未完成的 todos 仍继续推进。
|
||||
|
||||
### 证据审计门控(Delivery)
|
||||
|
||||
@@ -165,8 +165,8 @@ Delivery 不再自行注入隐藏模型消息做 3/6 次 readiness 重试:普
|
||||
todo_write → agent 创建任务列表
|
||||
complete_step → agent 标记某一步完成
|
||||
advanceGoalAfterTurn → 读取 update_goal 报告 + readiness + evaluator
|
||||
├─ complete + readiness 通过 → 完成
|
||||
├─ complete + readiness 缺失 → 拦截并列出缺失项,继续循环
|
||||
├─ complete + readiness 通过,或仅剩检查缺口的第二次 complete → 完成
|
||||
├─ complete + readiness 缺失(首次,或仍有未完成 todos) → 拦截并列出缺失项,继续循环
|
||||
├─ blocked → 立即阻塞
|
||||
├─ 无报告 → evaluator 判定一次(失败则安全暂停)
|
||||
└─ 数字式无进展/Todo 阈值 → 重新规划并继续(不改变 Goal 状态)
|
||||
|
||||
+8
-5
@@ -70,10 +70,10 @@ reasoning_language = "auto" # visible reasoning text: auto|zh|en
|
||||
# max_subagent_concurrency = 6 # session-wide sub-agent concurrency (task/fleet/skills)
|
||||
# max_parallel_writers = 3 # concurrent writers with non-overlapping write_paths
|
||||
# compact_ratio = 0.85 # sole auto trigger; presets 0.70 / 0.80 / 0.85
|
||||
# max_output_tokens = 0 # recommended: automatic (DeepSeek default high → ~64K; not unlimited)
|
||||
# max_output_tokens = 32768 # ordinary coding / cost control
|
||||
# max_output_tokens = 65536 # heavy reasoning / long tool loops
|
||||
# max_output_tokens = 131072 # only after repeated finish_reason=length
|
||||
# max_output_tokens = 0 # recommended: official DeepSeek omits the field (server 384K)
|
||||
# max_output_tokens = 32768 # optional cost cap
|
||||
# max_output_tokens = 65536 # optional cost cap
|
||||
# max_output_tokens = 131072 # optional cost cap
|
||||
# max_output_tokens never changes compact_ratio; only the final send-time clip does
|
||||
|
||||
[[providers]]
|
||||
@@ -1182,7 +1182,10 @@ compatibility, but they no longer select an execution quota. There is no
|
||||
separate research runtime to configure. Goal state stays in the normal session sidecar, progress
|
||||
comes only from novel host receipts, canonical todos, `complete_step`, review
|
||||
and the Delivery checkpoint, and completion is decided by Delivery readiness
|
||||
plus the bounded Goal evaluator. Legacy `.reasonix/autoresearch/<task-id>/` archives are
|
||||
plus the bounded Goal evaluator. Light/Balanced honor an `update_goal`
|
||||
`completion.unverified` account for checks the model could not run; a second
|
||||
identical complete on the same leftover checks finishes the Goal instead of
|
||||
looping. Legacy `.reasonix/autoresearch/<task-id>/` archives are
|
||||
read-only: an explicit old path can be recovered as an ordinary Goal, but new
|
||||
runs never create or update those directories. Deprecated budget flags are
|
||||
accepted for compatibility but are hidden from help and completion.
|
||||
|
||||
+4
-4
@@ -64,9 +64,9 @@ reasoning_language = "auto" # 可见思考过程语言:auto|zh|en
|
||||
# max_subagent_concurrency = 6 # 会话级子代理总并发(task/fleet/skills)
|
||||
# max_parallel_writers = 3 # 互不重叠 write_paths 时的并行写入上限
|
||||
# compact_ratio 是唯一自动维护阈值(默认 0.85;预设 0.70/0.80/0.85)
|
||||
# max_output_tokens = 0 # 推荐:自动(DeepSeek 默认 high → 约 64K;不是无限)
|
||||
# max_output_tokens = 32768 # 普通编码 / 控制费用
|
||||
# max_output_tokens = 65536 # 重推理、长工具链
|
||||
# max_output_tokens = 0 # 推荐:官方 DeepSeek 省略字段(服务端 384K)
|
||||
# max_output_tokens = 32768 # 可选控费上限
|
||||
# max_output_tokens = 65536 # 可选控费上限
|
||||
# max_output_tokens = 131072 # 仅在反复 finish_reason=length 时再考虑
|
||||
# max_output_tokens 不参与 compact_ratio;只在发送阶段裁剪本轮最长输出
|
||||
|
||||
@@ -922,7 +922,7 @@ Output format、Constraints 和 Pause policy。Goal 模式会把这些部分当
|
||||
|
||||
旧的简单/写入/研究参数只作为兼容元数据解析,不再改变执行额度。Goal 状态只保存在普通会话 sidecar;进展只来自宿主工具 receipt、canonical todo、
|
||||
`complete_step`、review 与 Delivery checkpoint 中的新证据,最终由 Delivery readiness 和有界 Goal
|
||||
evaluator 判定。旧 `.reasonix/autoresearch/<task-id>/` 目录保持只读:显式引用旧路径时可恢复为
|
||||
evaluator 判定。Light/Balanced 会接受 `update_goal` 里诚实申报的 `unverified` 检查缺口;同一检查缺口连续两次 `complete` 会结束 Goal,而不是继续验证循环。旧 `.reasonix/autoresearch/<task-id>/` 目录保持只读:显式引用旧路径时可恢复为
|
||||
普通 Goal,但新版本不会创建或改写这些目录。旧预算 flags 仅为兼容继续接受,不再出现在帮助和补全中。
|
||||
|
||||
## @ 引用
|
||||
|
||||
+10
-9
@@ -274,11 +274,12 @@ when the sole automatic threshold is crossed.
|
||||
user-global value used by desktop and new CLI sessions. UI always shows the
|
||||
**effective** ratio.
|
||||
- `max_output_tokens` is an independent **per-turn** completion ceiling.
|
||||
Recommended: `0` (**automatic**, not unlimited; DeepSeek default high → ~64K).
|
||||
User presets: `32768` ordinary coding / cost control, `65536` heavy reasoning /
|
||||
long tool loops, `131072` only after repeated `finish_reason=length`.
|
||||
Negative omits optional wire limits when the protocol allows. Clipped only at
|
||||
send time against remaining window and **never** changes `triggerTokens` or
|
||||
Recommended: `0` — official DeepSeek omits the field so the server uses the
|
||||
documented **384K** output cap; thinking depth is `effort` only (default high).
|
||||
A positive value is an explicit cost cap. Negative omits optional wire limits
|
||||
when the protocol allows; official DeepSeek Anthropic still sends 384K because
|
||||
`max_tokens` is required (`budget_tokens` is ignored). Clipped only at send
|
||||
time against remaining window and **never** changes `triggerTokens` or
|
||||
maintenance timing. Billing follows actual completion tokens, not the ceiling.
|
||||
- Giant tool results are bounded **once**, on first entry to the model:
|
||||
`Content` is the stable ≤32KB visible form; `RawContent` holds the full original
|
||||
@@ -1035,10 +1036,10 @@ default = "deepseek-v4-flash" # optional; defaults to models[0]
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
web_search = true
|
||||
context_window = 1000000 # tokens; harness compacts older history near this limit (0 disables)
|
||||
# max_output_tokens = 0 # recommended: automatic (DeepSeek default high → ~64K)
|
||||
# max_output_tokens = 32768 # ordinary coding / cost control
|
||||
# max_output_tokens = 65536 # heavy reasoning / long tool loops
|
||||
# max_output_tokens = 131072 # only after repeated finish_reason=length
|
||||
# max_output_tokens = 0 # recommended: official DeepSeek omits the field (server 384K)
|
||||
# max_output_tokens = 32768 # optional cost cap
|
||||
# max_output_tokens = 65536 # optional cost cap
|
||||
# max_output_tokens = 131072 # optional cost cap
|
||||
# max_output_tokens never changes compact_ratio
|
||||
# model_overrides = { "deepseek-v4-flash" = { context_window = 1000000, max_output_tokens = 32768 } }
|
||||
|
||||
|
||||
+8
-9
@@ -73,7 +73,7 @@ func New(kind string, cfg Config) (Provider, error)
|
||||
- OpenAI-compatible vendor 只是 `kind = "openai"` 的不同配置实例,通过 `base_url`、`model`、`api_key_env` 区分;新增兼容模型通常只需改配置。
|
||||
- 一个 provider 表示一个 vendor endpoint,可通过 `models` 暴露多个模型,并以 `default` 指定默认项。设置 `request_url` 时,OpenAI-compatible、Anthropic-compatible 和 Responses provider 都会原样使用该完整请求地址;旧 `chat_url` 只保留 OpenAI 历史兼容语义。`default_model`、`--model` 和桌面端模型选择器都经 `Config.ResolveModel` 解析,可接受 provider 名、裸模型名或 `provider/model`。
|
||||
- `context_window` 是 provider 级默认值;`model_overrides.<model>.context_window` 可覆盖单个模型。
|
||||
- `max_output_tokens` 是独立的本轮输出上限,不由客户端 reasoning 字节上限换算,也不参与 `compact_ratio`。推荐 `0`(自动:DeepSeek 默认 high 约 64K);显式 `32768` 控费/普通编码,`65536` 重推理/长工具链,`131072` 仅在反复 `finish_reason=length` 时再考虑。正数为显式上限,负数为在协议允许时省略;混合网关可用 `model_overrides.<model>.max_output_tokens` 覆盖单个模型。Anthropic 因协议要求仍会提供 `max_tokens` 默认值。
|
||||
- `max_output_tokens` 是独立的本轮输出上限,不由客户端 reasoning 字节上限换算,也不参与 `compact_ratio`。推荐 `0`:官方 DeepSeek 省略该字段,由服务端使用定价页上的 **384K** 输出上限;思考深度只走 `effort`(默认 high)。正数为用户显式控费上限。负数为在协议允许时省略;官方 DeepSeek 的 Anthropic 兼容层因 `max_tokens` 必填,仍发送 384K。`budget_tokens` 在该兼容层会被忽略。混合网关可用 `model_overrides.<model>.max_output_tokens` 覆盖单个模型。
|
||||
- streaming tool-call delta 在 provider 内按 index 聚合,只向上层发出完整 `ToolCall`。
|
||||
|
||||
### 3.2 Tool 与 registry(`internal/tool`)
|
||||
@@ -173,10 +173,9 @@ transcript,仅在唯一自动阈值被跨越时安装 provider 可见的短 **
|
||||
- 用户可用 `reasonix config compact-ratio [--local] [VALUE]` 查看或修改阈值。
|
||||
项目配置优先于桌面与新 CLI 会话共用的用户全局配置。UI 始终展示**实际生效**值。
|
||||
- `max_output_tokens` 是独立的**本轮**输出上限。
|
||||
推荐 `0`(**自动**,不是无限;DeepSeek 默认 high → 约 64K)。
|
||||
用户侧常用值:`32768` 普通编码/控费,`65536` 重推理/长工具链,`131072` 仅在反复
|
||||
`finish_reason=length` 时再考虑。负数为在协议允许时省略。仅在发送阶段按剩余
|
||||
窗口裁剪,**绝不**改变 `triggerTokens` 或维护时机。计费按实际 completion,不按配置上限。
|
||||
推荐 `0`:官方 DeepSeek 省略该字段,由服务端使用定价页 **384K** 上限;思考深度只走 `effort`。
|
||||
正数为用户显式控费上限。负数为在协议允许时省略(官方 Anthropic 兼容层仍发送 384K)。
|
||||
仅在发送阶段按剩余窗口裁剪,**绝不**改变 `triggerTokens` 或维护时机。计费按实际 completion,不按配置上限。
|
||||
- 巨型工具结果只在**第一次**进入模型前限长:`Content` 为稳定 ≤32KB 可见版;
|
||||
超限时 `RawContent` 保存完整原文。后续维护不得回头改写。`ModelMessages` 会去掉
|
||||
`RawContent`,provider 序列化与缓存 hash 永不包含它。
|
||||
@@ -410,10 +409,10 @@ default = "deepseek-v4-flash"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
web_search = true
|
||||
context_window = 1000000
|
||||
# max_output_tokens = 0 # 推荐:自动(DeepSeek 默认 high → 约 64K)
|
||||
# max_output_tokens = 32768 # 普通编码 / 控制费用
|
||||
# max_output_tokens = 65536 # 重推理、长工具链
|
||||
# max_output_tokens = 131072 # 仅在反复 finish_reason=length 时再考虑
|
||||
# max_output_tokens = 0 # 推荐:官方 DeepSeek 省略字段(服务端 384K)
|
||||
# max_output_tokens = 32768 # 可选控费上限
|
||||
# max_output_tokens = 65536 # 可选控费上限
|
||||
# max_output_tokens = 131072 # 可选控费上限
|
||||
|
||||
[tools]
|
||||
enabled = []
|
||||
|
||||
@@ -23,7 +23,7 @@ This document records the provider-visible contract for Reasonix compile-time bu
|
||||
| `notebook_edit` | false | Edit one cell of a Jupyter notebook (.ipynb). Target a cell by 0-based cell_number (or cell_id). edit_mode: "replace" (default) swaps the cell's source; "insert" adds a new cell after cell_number (use -1 to prepend at the top), taking cell_type and new_source; "delete" removes the cell. cell_type is "code" or "markdown" (required for insert). Editing a code cell clears its outputs. Prefer this over edit_file for notebooks - it keeps the JSON valid. |
|
||||
| `read_file` | true | Read a text file with optional line offset/limit. Output prefixes each line with its 1-based number so subsequent edit_file calls can target exact lines. Use `offset` and `limit` to page through large files; the tool reports total length and pagination hints in a trailer. |
|
||||
| `todo_write` | true | Record and update a structured task list for the current work. Send the COMPLETE list every call - it replaces the previous one. Use it to plan multi-step work and show progress: keep exactly one item in_progress at a time, and flip an item to completed the moment it's done (don't batch completions). Skip it for trivial single-step tasks. |
|
||||
| `update_goal` | true | Report this turn's disposition for the active goal: `continue` (work is ongoing - give a concrete next_action), `complete` (fully done and verified), or `blocked` (only the user can unblock). An optional `completion` account may accompany `complete`: `verified` commands are reconciled against the session's real receipts, while `unverified` and `risks` are declarations the host cannot infer. The host validates the claim against Delivery acceptance criteria and budget and decides whether to continue automatically. Outside an active goal turn the call fails closed without changing any state. |
|
||||
| `update_goal` | true | Report this turn's disposition for the active goal: `continue` (work is ongoing - give a concrete next_action), `complete` (the request is done and verification was attempted or reported unavailable), or `blocked` (only the user can unblock). An optional `completion` account may accompany `complete`: `verified` commands are reconciled against the session's real receipts, while `unverified` and `risks` are declarations the host cannot infer and do not block Light/Balanced completion. The host validates the claim against Delivery acceptance criteria and budget and decides whether to continue automatically. Outside an active goal turn the call fails closed without changing any state. |
|
||||
| `wait` | true | Block until background jobs finish, then return each job's status and final output/answer. Use to collect the result of a task(run_in_background) or bash(run_in_background) before continuing. Omit job_ids to wait for every running job. |
|
||||
| `web_fetch` | true | Fetch a URL over HTTPS/HTTP and return its text content. HTML pages are reduced to readable text; JSON / plain text / markdown bodies come back verbatim. Use to read documentation pages, API responses, or source files hosted somewhere the local filesystem can't reach. |
|
||||
| `write_file` | false | Write content to a file at the given path (overwriting existing content). Creates parent directories as needed. |
|
||||
|
||||
@@ -88,7 +88,7 @@ stable system / early prefix
|
||||
## 六、Provider 与输出预算
|
||||
|
||||
- 应用层 summary 是默认路径;Responses 等 native compaction 标记 unsupported 时回退 summary
|
||||
- `max_output_tokens=0` 表示 auto ladder(普通 16K / reasoning 32K / high·max 64K);128K 仅显式配置
|
||||
- `max_output_tokens=0` 在官方 DeepSeek 上省略该字段(服务端 384K 上限);MiMo 等仍用 16K/32K 梯子。思考深度只走 effort。
|
||||
- auto ladder 与 `compact_ratio` 解耦
|
||||
|
||||
## 七、缓存影响
|
||||
|
||||
+19
-14
@@ -50,7 +50,9 @@ const maxStreamRecoveries = 5
|
||||
const maxSamplingAttempts = maxStreamRecoveries + 1
|
||||
const maxExecutorHandoffNudges = 1
|
||||
|
||||
const defaultReasoningByteLimit = 128 * 1024
|
||||
// defaultReasoningByteLimit caps stored hidden reasoning for one stream.
|
||||
// It does not cancel generation; official DeepSeek may emit up to 384K tokens.
|
||||
const defaultReasoningByteLimit = 8 << 20
|
||||
|
||||
const finishReasonClientReasoningLimit = "client_reasoning_limit"
|
||||
|
||||
@@ -308,12 +310,12 @@ type Agent struct {
|
||||
// for the agent's lifetime and validates proxy calls after resolution.
|
||||
readOnlyExecution bool
|
||||
|
||||
// mutationDependencyBarrier is set for the remainder of a provider tool
|
||||
// batch after any mutating call fails or is blocked. executeOne re-checks
|
||||
// it after proxy resolution so use_capability cannot bypass the barrier by
|
||||
// advertising schema-level ReadOnly()==true. Parallel read-only segments
|
||||
// never set it. Cleared at the start of each executeBatch.
|
||||
mutationDependencyBarrier atomic.Bool
|
||||
// mutationDependencyBarrier records the first durable-state write that
|
||||
// failed or was blocked in the current provider tool batch. executeOne
|
||||
// re-checks it after proxy resolution so use_capability cannot bypass the
|
||||
// barrier by advertising schema-level ReadOnly()==true. The pointed-to
|
||||
// cause is immutable and contains no arguments, paths, or remote addresses.
|
||||
mutationDependencyBarrier atomic.Pointer[mutationBarrierCause]
|
||||
|
||||
// plannerMCPExecution relaxes the strict read-only MCP boundary for the
|
||||
// two-model Planner only: authorized, non-destructive MCP targets may run
|
||||
@@ -1791,6 +1793,7 @@ func (a *Agent) streamWithFrozen(ctx context.Context, turn int, sink event.Sink,
|
||||
var reasoningID, reasoningStatus string // Responses reasoning item id/status (meta chunk)
|
||||
var calls []provider.ToolCall
|
||||
var responsesItems []json.RawMessage
|
||||
search := newSearchTurn()
|
||||
var partialCalls []provider.ToolCall
|
||||
var usage *provider.Usage
|
||||
var partialToolStarted bool
|
||||
@@ -1802,7 +1805,7 @@ func (a *Agent) streamWithFrozen(ctx context.Context, turn int, sink event.Sink,
|
||||
return streamedTurn{
|
||||
text: text.String(), reasoning: stored, signature: signature,
|
||||
reasoningID: reasoningID, reasoningStatus: reasoningStatus,
|
||||
calls: calls, responsesItems: responsesItems, usage: usage,
|
||||
calls: calls, responsesItems: responsesItems, serverSearch: search.calls, usage: usage,
|
||||
partialToolStarted: partialToolStarted, partialCalls: partialCalls,
|
||||
maxArgChars: maxArgChars, err: err,
|
||||
}
|
||||
@@ -1874,7 +1877,7 @@ func (a *Agent) streamWithFrozen(ctx context.Context, turn int, sink event.Sink,
|
||||
return streamedTurn{
|
||||
text: finalText, reasoning: finalReasoning, signature: signature,
|
||||
reasoningID: reasoningID, reasoningStatus: reasoningStatus,
|
||||
calls: calls, responsesItems: responsesItems, usage: usage,
|
||||
calls: calls, responsesItems: responsesItems, serverSearch: search.calls, usage: usage,
|
||||
partialCalls: partialCalls, maxArgChars: maxArgChars,
|
||||
}
|
||||
}
|
||||
@@ -1897,12 +1900,12 @@ func (a *Agent) streamWithFrozen(ctx context.Context, turn int, sink event.Sink,
|
||||
if chunk.Text != "" && !transformReasoning {
|
||||
sink.Emit(event.Event{Kind: event.Reasoning, Text: chunk.Text})
|
||||
}
|
||||
// Bound stored hidden reasoning only. Do not cancel the provider
|
||||
// stream: official DeepSeek bills this output and still needs to
|
||||
// emit the visible answer or tool calls.
|
||||
if a.reasoningByteLimit > 0 && reasoning.Len() > a.reasoningByteLimit {
|
||||
stored, _ := finishReasoning()
|
||||
usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), finishReasonClientReasoningLimit)
|
||||
usage = provider.UsageWithRequestAttemptCount(ctx, usage)
|
||||
a.storeLatestRequestUsage(usage)
|
||||
return collect(stored, errReasoningByteLimitExceeded)
|
||||
reasoning.Reset()
|
||||
reasoning.WriteString(snapToRuneBoundary(chunk.Text, 0, min(len(chunk.Text), a.reasoningByteLimit)))
|
||||
}
|
||||
case provider.ChunkText:
|
||||
text.WriteString(chunk.Text)
|
||||
@@ -1948,6 +1951,8 @@ func (a *Agent) streamWithFrozen(ctx context.Context, turn int, sink event.Sink,
|
||||
if len(chunk.ResponsesItem) > 0 {
|
||||
responsesItems = append(responsesItems, append(json.RawMessage(nil), chunk.ResponsesItem...))
|
||||
}
|
||||
case provider.ChunkServerSearch:
|
||||
search.onChunk(sink, chunk, attemptID)
|
||||
case provider.ChunkUsage:
|
||||
usage, a.turn.lastReasoning = chunk.Usage, chunk.Usage.ReasoningTokens
|
||||
a.storeLatestRequestUsage(chunk.Usage)
|
||||
|
||||
@@ -48,10 +48,15 @@ type BranchMeta struct {
|
||||
// refuses to fork past SessionRecoveryMaxDepth so a conflict loop cannot
|
||||
// spawn unbounded nested recovery chains (#5993 reached 8 levels). Legacy
|
||||
// recovery metas without the field are treated as depth 1.
|
||||
RecoveryDepth int `json:"recovery_depth,omitempty"`
|
||||
Revision int64 `json:"revision,omitempty"`
|
||||
ContentDigest string `json:"content_digest,omitempty"`
|
||||
WriterID string `json:"writer_id,omitempty"`
|
||||
RecoveryDepth int `json:"recovery_depth,omitempty"`
|
||||
// RecoveryPreferred is a user's explicit choice among genuinely diverged
|
||||
// recovery leaves. It changes the default open target, but never authorizes
|
||||
// deletion and is cleared automatically if that leaf is no longer valid.
|
||||
RecoveryPreferred bool `json:"recovery_preferred,omitempty"`
|
||||
RecoveryPreferredDigest string `json:"recovery_preferred_digest,omitempty"`
|
||||
Revision int64 `json:"revision,omitempty"`
|
||||
ContentDigest string `json:"content_digest,omitempty"`
|
||||
WriterID string `json:"writer_id,omitempty"`
|
||||
// SchemaVersion records the BranchMeta version that last wrote the listing
|
||||
// fields (Turns/Preview) FROM the session's content. It is stamped only by the
|
||||
// writers that actually derive those counts — Controller.snapshot's
|
||||
@@ -69,6 +74,8 @@ type BranchMeta struct {
|
||||
Turns int `json:"turns,omitempty"`
|
||||
Preview string `json:"preview,omitempty"`
|
||||
InFlightTurn *InFlightTurnMeta `json:"in_flight_turn,omitempty"`
|
||||
// Closed completed todo shelves; desktop remounts hide the same fingerprint.
|
||||
DismissedTodoBatches []string `json:"dismissed_todo_batches,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -248,8 +255,14 @@ func saveBranchMeta(sessionPath string, m BranchMeta, touchUpdated bool) error {
|
||||
if m.CreatedAt.IsZero() {
|
||||
m.CreatedAt = now
|
||||
}
|
||||
if touchUpdated || m.UpdatedAt.IsZero() {
|
||||
if touchUpdated {
|
||||
m.UpdatedAt = now
|
||||
} else if m.UpdatedAt.IsZero() {
|
||||
if info, err := os.Stat(sessionPath); err == nil {
|
||||
m.UpdatedAt = info.ModTime().UTC()
|
||||
} else {
|
||||
m.UpdatedAt = now
|
||||
}
|
||||
}
|
||||
if existing, ok, err := LoadBranchMeta(sessionPath); err == nil && ok {
|
||||
preserveBranchMetaPersistence(&m, existing)
|
||||
@@ -287,6 +300,7 @@ func preserveBranchMetaPersistence(next *BranchMeta, existing BranchMeta) {
|
||||
if next == nil {
|
||||
return
|
||||
}
|
||||
next.DismissedTodoBatches = MergeDismissedTodoBatches(existing.DismissedTodoBatches, next.DismissedTodoBatches)
|
||||
if existing.Revision > next.Revision {
|
||||
next.Revision = existing.Revision
|
||||
next.ContentDigest = existing.ContentDigest
|
||||
|
||||
@@ -290,6 +290,37 @@ func TestSessionInFlightTurnMetaRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBranchMetaWithoutTouchUsesFileMtimeForZeroUpdatedAt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
when := time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC)
|
||||
if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chtimes(path, when, when); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(BranchMetaPath(path), []byte(`{"id":"session","created_at":"2026-02-03T04:05:06Z","updated_at":"0001-01-01T00:00:00Z"}`+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := UpdateBranchMeta(path, false, func(meta *BranchMeta) error {
|
||||
meta.TopicID = "topic"
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
|
||||
}
|
||||
if !got.UpdatedAt.Equal(when.UTC()) {
|
||||
t.Fatalf("UpdatedAt = %v, want file mtime %v (listing writes must not mint wall-clock activity)", got.UpdatedAt, when.UTC())
|
||||
}
|
||||
if got.TopicID != "topic" {
|
||||
t.Fatalf("TopicID = %q, want topic", got.TopicID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionModelRoundTripPreservesActivity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
|
||||
@@ -186,62 +186,85 @@ func (p activeReasoningUntilCancelProvider) Stream(ctx context.Context, _ provid
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
type reasoningGuardCancelProvider struct {
|
||||
canceled chan struct{}
|
||||
type finiteReasoningThenTextProvider struct {
|
||||
canceled chan struct{}
|
||||
reasoning, text string
|
||||
finished bool
|
||||
}
|
||||
|
||||
func (reasoningGuardCancelProvider) Name() string { return "reasoning-guard-cancel" }
|
||||
func (finiteReasoningThenTextProvider) Name() string { return "finite-reasoning" }
|
||||
|
||||
func (p reasoningGuardCancelProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
||||
func (p *finiteReasoningThenTextProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
||||
ch := make(chan provider.Chunk)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
defer close(p.canceled)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ch <- provider.Chunk{Type: provider.ChunkReasoning, Text: "0123456789abcdef"}:
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ch <- provider.Chunk{Type: provider.ChunkReasoning, Text: p.reasoning}:
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ch <- provider.Chunk{Type: provider.ChunkText, Text: p.text}:
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ch <- provider.Chunk{Type: provider.ChunkDone}:
|
||||
p.finished = true
|
||||
}
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestRunawayReasoningStopsAtAgentSideByteGuard(t *testing.T) {
|
||||
func TestReasoningByteGuardDoesNotAbortTurn(t *testing.T) {
|
||||
sink := &recordSink{}
|
||||
a := New(activeReasoningUntilCancelProvider{}, tool.NewRegistry(), NewSession(""), Options{ReasoningByteLimit: 64}, sink)
|
||||
reasoning := strings.Repeat("abcd", 64)
|
||||
prov := testutil.NewMock("m", testutil.Turn{Reasoning: reasoning, Text: "svg done"})
|
||||
a := New(prov, tool.NewRegistry(), NewSession(""), Options{ReasoningByteLimit: 32}, sink)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err := a.Run(ctx, "parse this binary by offset")
|
||||
if !errors.Is(err, errReasoningByteLimitExceeded) {
|
||||
t.Fatalf("Run error = %v, want reasoning limit guard", err)
|
||||
if err := a.Run(context.Background(), "draw the compound bow"); err != nil {
|
||||
t.Fatalf("Run error = %v, byte guard must not fail the turn", err)
|
||||
}
|
||||
if got := len(sink.kinds(event.Reasoning)); got == 0 {
|
||||
t.Fatal("no reasoning chunks emitted; repro did not exercise the active-output path")
|
||||
if got := sink.kinds(event.Text); len(got) == 0 || !strings.Contains(got[0].Text, "svg done") {
|
||||
t.Fatal("visible answer was dropped after the reasoning buffer cap")
|
||||
}
|
||||
usages := sink.kinds(event.Usage)
|
||||
if len(usages) != 1 {
|
||||
t.Fatalf("usage events = %d, want one best-effort usage event", len(usages))
|
||||
}
|
||||
if u := usages[0].Usage; u == nil || u.FinishReason != "client_reasoning_limit" || !u.Estimated || u.TotalTokens <= 0 || u.ReasoningTokens <= 0 {
|
||||
t.Fatalf("usage = %+v, want client reasoning limit with estimated reasoning tokens", u)
|
||||
for _, notice := range sink.kinds(event.Notice) {
|
||||
if strings.Contains(notice.Text, "client reasoning safety limit") {
|
||||
t.Fatalf("unexpected abort notice %q", notice.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningByteGuardCancelsProviderStream(t *testing.T) {
|
||||
canceled := make(chan struct{})
|
||||
a := New(reasoningGuardCancelProvider{canceled: canceled}, tool.NewRegistry(), NewSession(""), Options{ReasoningByteLimit: 32}, event.Discard)
|
||||
func TestDefaultReasoningGuardAllowsFormer128KiBStream(t *testing.T) {
|
||||
// 128KiB is ~32K estimated tokens — a legitimate DeepSeek V4 Pro think.
|
||||
reasoning := strings.Repeat("abcd", 128*1024/4+1)
|
||||
prov := testutil.NewMock("m", testutil.Turn{Reasoning: reasoning, Text: "svg done"})
|
||||
a := New(prov, tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "draw the compound bow"); errors.Is(err, errReasoningByteLimitExceeded) {
|
||||
t.Fatalf("default guard aborted a %d-byte reasoning stream", len(reasoning))
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.Run(context.Background(), "trigger the reasoning guard"); !errors.Is(err, errReasoningByteLimitExceeded) {
|
||||
t.Fatalf("Run error = %v, want reasoning limit guard", err)
|
||||
func TestReasoningByteGuardDoesNotCancelProviderStream(t *testing.T) {
|
||||
canceled := make(chan struct{})
|
||||
prov := &finiteReasoningThenTextProvider{canceled: canceled, reasoning: strings.Repeat("x", 64), text: "done"}
|
||||
a := New(prov, tool.NewRegistry(), NewSession(""), Options{ReasoningByteLimit: 16}, event.Discard)
|
||||
|
||||
if err := a.Run(context.Background(), "keep generating"); err != nil {
|
||||
t.Fatalf("Run error = %v, byte guard must not cancel the provider", err)
|
||||
}
|
||||
select {
|
||||
case <-canceled:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("provider context remained live after the reasoning guard returned")
|
||||
t.Fatal("provider stream did not finish after the answer")
|
||||
}
|
||||
if !prov.finished {
|
||||
t.Fatal("provider stream was cut off before the final text")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,11 @@ func estimateMessagesTokens(msgs []provider.Message) int {
|
||||
for _, item := range m.ResponsesItems {
|
||||
total += estimateTextTokens(string(item))
|
||||
}
|
||||
for _, search := range m.ServerSearch {
|
||||
provider.WalkServerSearchEstimate(search, func(s string) {
|
||||
total += estimateTextTokens(s)
|
||||
})
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
@@ -125,6 +125,7 @@ func splitLegacyCoalescedSummary(msg provider.Message) (provider.Message, provid
|
||||
summary.Images = nil
|
||||
summary.ToolCalls = nil
|
||||
summary.ResponsesItems = nil
|
||||
summary.ServerSearch = nil
|
||||
summary.CreatedAt = 0
|
||||
user := msg
|
||||
user.Content = msg.Content[i+len(separator):]
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
func TestEstimateMessagesTokensOmitsEncryptedSearchRaw(t *testing.T) {
|
||||
visible := provider.ServerSearchCall{
|
||||
ID: "s1", Query: "q",
|
||||
Results: []provider.ServerSearchHit{{Title: "T", URL: "https://a.example"}},
|
||||
}
|
||||
withRaw := []provider.Message{{
|
||||
Role: provider.RoleAssistant, Content: "answer",
|
||||
ServerSearch: []provider.ServerSearchCall{{
|
||||
ID: visible.ID, Query: visible.Query, Results: visible.Results,
|
||||
Raw: json.RawMessage(strings.Repeat("E", 50_000)),
|
||||
}},
|
||||
}}
|
||||
withoutRaw := []provider.Message{{
|
||||
Role: provider.RoleAssistant,
|
||||
Content: "answer",
|
||||
ServerSearch: []provider.ServerSearchCall{visible},
|
||||
}}
|
||||
if got, want := estimateMessagesTokens(withRaw), estimateMessagesTokens(withoutRaw); got != want {
|
||||
t.Fatalf("estimateMessagesTokens with raw = %d, without = %d", got, want)
|
||||
}
|
||||
if estimateSamplingRequestInputTokens(provider.Request{Messages: withRaw}) != estimateSamplingRequestInputTokens(provider.Request{Messages: withoutRaw}) {
|
||||
t.Fatal("interrupted-usage estimate counted encrypted search raw")
|
||||
}
|
||||
}
|
||||
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user