* fix(ai): keep dropped AI chats recoverable with dangling marking and /rebind Closing a member terminal or dissolving a workspace used to clear the chat's active pointer, making it invisible (scope rank 0) and unrecoverable from the UI even though the record survived in storage. - orphan cleanup now marks dropped chats `scopeDangling: true` and keeps record + active pointer intact; the flag clears when the target returns or the chat is rebound - `/chats` lists dangling chats with a numbered inline affordance and hint line - new `/rebind <n>` panel action rewrites `scope` to the panel's current scope, clears the marker and restores the active pointer - i18n keys added for en / zh-CN / zh-TW / es / ru Stacked on #4018. Refs #4015 (R2). * fix(workspace): reuse existing workspace id when merging tabs with a workspace createWorkspaceFromSessions silently no-opped when either merged tab already belonged to a workspace (e.g. dragging a workspace pane onto a standalone terminal), and the older dissolve->recreate behavior regenerated the id that workspace-scoped AI chats bind to. When one of the merged tabs has a workspace, the orphan tab now joins that existing workspace (pane inserted next to the anchor session, tab slot collapsed), so workspace-scoped chat bindings survive. Merging two standalone tabs still creates a fresh workspace. Stacked on #4018. Refs #4015 (R3). * fix(ai): persist cleaned active map on dangling chat rebind rebindSessionScope was updating React state to drop stale old-scope pointers, then rebuilding localStorage from the pre-rebind snapshot and resurrecting them. Persist the same cleaned map via rebindActiveSessionMap. * fix(ai): persist rebind map inside setState; wire backup actions on terminal panel - Write cleaned active-session map from inside the rebind updater so a deferred React setState cannot skip snapshot/localStorage persistence. - Pass exportSessionsBackup/importSessionsBackup through TerminalLayerSupport and include them in AI_CHAT_SIDE_PANEL_AI_STATE_KEYS so history-drawer backup buttons work on the normal terminal path. * fix(ai): guard backup restore races and use app-wide live targets for /chats - Skip restore when in-memory sessions already exist; apply snapshot, storage write, and AI state emit atomically inside the empty-state path so peer useAIState instances see the restore. - Thread validAIScopeTargetIds into AIChatSidePanel as liveScopeTargetIds so /chats does not mark chats bound to other open terminals as dangling. * fix(ai): open rebound chat in panel view and refresh /chats listing - rebindSessionScope also sets the panel view to the rebound session so an existing draft/other chat does not keep hiding the recovery. - After /rebind, rewrite the stored listing instead of rebuilding from the stale render so the chat is no longer offered as dangling. * fix(workspace): invert join split when dragged pane owns workspace; refresh /chats titles - When merging a workspace-owned dragged pane onto a standalone tab, the orphan is inserted — invert the drop position so the previewed side matches. - Compare workspaces in AIChatSidePanel memo so /chats picks up renames. * fix(ai): validate live sessions array on boot; mark imported orphans dangling - Reject parseable non-array store values (e.g. {}) via readLiveSessionsArray so boot restore is not blocked by a corrupt-but-truthy value. - After manual import, re-run orphan cleanup with the last known live target set so chats bound to closed scopes become dangling and /rebind-able. * fix(ai): merge imported sessions before orphan cleanup Apply import merge to the snapshot/ref synchronously, then run dangling marking, so a deferred setState cannot let cleanup rewrite over the import. * fix(ai): mark restored/imported orphans dangling even with no live targets Restore and import do not change live terminals/workspaces, so the orphan cleanup effect will not re-run. Always call cleanup against the last known live target set after those paths — an empty set is a valid observed state (nothing mounted), not an unavailable snapshot — so chats bound to missing scopes become dangling and /chats can offer /rebind. * fix(ai): apply backup restore synchronously before dangling cleanup Do not gate restore + orphan marking on a setState updater having run. A deferred updater left didRestore false, skipped cleanup, and could leave restored chats with missing scopes unmarked and unrebindable from /chats. * fix(ai): complete /rebind from a partial slash picker selection Selecting Rebind after typing `/re` left the composer unchanged, so a following number became `/re 1` and could be sent to the agent. Replace the slash query with `/rebind ` the same way Rename does. * fix(ai): replace hostIds on rebind; skip non-array backup generations - Rebind builds a fresh destination scope (including hostIds from the panel) instead of spreading the old scope, so history matching follows the new terminal/workspace host. - readLatestGeneration treats valid JSON that is not a session array as unusable and continues to older generations, matching corrupt skips. * fix(ai): revalidate dangling chat before /rebind from stale /chats listing Skip rebind when the cached entry is deleted or already bound to a live scope; drop stale listing entries so a second panel cannot steal the chat. * fix(ai): reject malformed /rebind numbers Require the entire trimmed argument to be digits so /rebind 1foo and /rebind 1.5 show usage instead of silently targeting index 1. --------- Co-authored-by: binaricat <binaricat@users.noreply.github.com> Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
187 lines
7.6 KiB
TypeScript
187 lines
7.6 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import type { AISession, ExternalAgentConfig } from '../../infrastructure/ai/types.ts';
|
|
import {
|
|
buildAiChatsListingEntries,
|
|
canRebindChat,
|
|
formatAiChatsListingNote,
|
|
resolveChatAgentLabel,
|
|
resolveRebindTargetFromArg,
|
|
truncateChatTitle,
|
|
} from './aiChatsListing.ts';
|
|
|
|
test('rebind revalidates cached entries against current sessions and live targets', () => {
|
|
const liveTargets = new Set(['ws-live', 'term-live']);
|
|
assert.equal(canRebindChat(undefined, liveTargets), false);
|
|
assert.equal(canRebindChat(createSession('restored', { scope: { type: 'workspace', targetId: 'ws-live' } }), liveTargets), false);
|
|
assert.equal(canRebindChat(createSession('restored-terminal', { scope: { type: 'terminal', targetId: 'term-live' } }), liveTargets), false);
|
|
assert.equal(canRebindChat(createSession('dangling', { scopeDangling: true, scope: { type: 'workspace', targetId: 'ws-old' } }), liveTargets), true);
|
|
assert.equal(canRebindChat(createSession('unbound', { scope: { type: 'terminal', targetId: 'term-old' } }), liveTargets), true);
|
|
});
|
|
|
|
function createSession(
|
|
id: string,
|
|
overrides: Partial<AISession> = {},
|
|
): AISession {
|
|
return {
|
|
id,
|
|
title: id,
|
|
agentId: 'catty',
|
|
scope: { type: 'workspace', targetId: 'ws-1' },
|
|
messages: [],
|
|
createdAt: 1_000,
|
|
updatedAt: 1_000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const t = (key: string) => key;
|
|
const externalAgents: ExternalAgentConfig[] = [
|
|
{ id: 'claude', name: 'Claude Code' } as ExternalAgentConfig,
|
|
];
|
|
|
|
function buildEntries(sessions: AISession[], activeSessionId: string | null = 's-live') {
|
|
return buildAiChatsListingEntries({
|
|
sessions,
|
|
activeSessionId,
|
|
externalAgents,
|
|
workspaces: [{ id: 'ws-1', title: 'Web farm' }],
|
|
liveTerminalIds: new Set(['term-1']),
|
|
scopeTargetId: 'ws-1',
|
|
t,
|
|
});
|
|
}
|
|
|
|
test('/chats listing marks the live workspace chat, terminal chat and dangling chat', () => {
|
|
const now = Date.now();
|
|
const sessions = [
|
|
createSession('s-live', { scope: { type: 'workspace', targetId: 'ws-1' }, updatedAt: now }),
|
|
createSession('s-term', { scope: { type: 'terminal', targetId: 'term-1' }, agentId: 'claude', updatedAt: now }),
|
|
createSession('s-dangling', { scope: { type: 'workspace', targetId: 'ws-gone' }, updatedAt: now }),
|
|
];
|
|
|
|
const entries = buildEntries(sessions);
|
|
assert.equal(entries.length, 3);
|
|
|
|
assert.equal(entries[0].isCurrent, true);
|
|
assert.equal(entries[0].agentLabel, 'Catty Agent');
|
|
assert.equal(entries[0].scopeLabel, 'ai.chat.chatsScopeWorkspace: Web farm');
|
|
assert.equal(entries[0].isDangling, false);
|
|
|
|
assert.equal(entries[1].isCurrent, false);
|
|
assert.equal(entries[1].agentLabel, 'Claude Code');
|
|
assert.equal(entries[1].scopeLabel, 'ai.chat.chatsScopeTerminal');
|
|
assert.equal(entries[1].isDangling, false);
|
|
|
|
assert.equal(entries[2].isDangling, true);
|
|
assert.equal(entries[2].scopeLabel, 'ai.chat.chatsScopeDangling');
|
|
});
|
|
|
|
test('/chats listing numbers dangling entries for /rebind and leaves live ones unnumbered', () => {
|
|
const sessions = [
|
|
createSession('s-dangling-a', { scope: { type: 'workspace', targetId: 'ws-gone-a' } }),
|
|
createSession('s-live', { scope: { type: 'workspace', targetId: 'ws-1' } }),
|
|
createSession('s-dangling-b', { scope: { type: 'terminal', targetId: 'term-gone' } }),
|
|
createSession('s-dangling-c', { scope: { type: 'workspace', targetId: 'ws-gone-b' } }),
|
|
];
|
|
|
|
const entries = buildEntries(sessions, null);
|
|
assert.deepEqual(
|
|
entries.map((entry) => entry.rebindNumber ?? 0),
|
|
[1, 0, 2, 3],
|
|
);
|
|
});
|
|
|
|
test('/chats listing falls back to the panel scope label for the current workspace', () => {
|
|
const sessions = [
|
|
createSession('s-live', { scope: { type: 'workspace', targetId: 'ws-1' } }),
|
|
];
|
|
const entries = buildAiChatsListingEntries({
|
|
sessions,
|
|
activeSessionId: 's-live',
|
|
externalAgents,
|
|
workspaces: [],
|
|
liveTerminalIds: new Set<string>(),
|
|
currentScopeLabel: 'Web farm',
|
|
scopeTargetId: 'ws-1',
|
|
t,
|
|
});
|
|
assert.equal(entries[0].isDangling, false);
|
|
assert.equal(entries[0].scopeLabel, 'ai.chat.chatsScopeWorkspace: Web farm');
|
|
});
|
|
|
|
test('/chats note renders header, current marker and one line per chat', () => {
|
|
const sessions = [
|
|
createSession('s-live', { title: 'Fix deploy' }),
|
|
createSession('s-term', { title: 'Logs', scope: { type: 'terminal', targetId: 'term-1' }, agentId: 'claude' }),
|
|
createSession('s-dangling', { title: 'Old', scope: { type: 'workspace', targetId: 'ws-gone' } }),
|
|
];
|
|
const entries = buildEntries(sessions);
|
|
const note = formatAiChatsListingNote(entries, t);
|
|
const lines = note.split('\n');
|
|
assert.equal(lines[0], 'ai.chat.chatsHeader');
|
|
// Three chat lines plus the dangling rebind hint.
|
|
assert.equal(lines.length, 5);
|
|
assert.match(lines[1], /^• Fix deploy — Catty Agent · /);
|
|
assert.match(lines[2], /^ {2}Logs — Claude Code · /);
|
|
assert.match(lines[3], /ai\.chat\.chatsScopeDangling/);
|
|
assert.ok(!lines[3].startsWith('•'));
|
|
// The dangling line carries an inline rebind affordance, and the note
|
|
// explains it.
|
|
assert.match(lines[3], /\/rebind 1/);
|
|
assert.equal(lines[4], 'ai.chat.chatsRebindHint');
|
|
});
|
|
|
|
test('/chats note omits the rebind hint when nothing is dangling', () => {
|
|
const sessions = [
|
|
createSession('s-live', { title: 'Fix deploy' }),
|
|
createSession('s-term', { title: 'Logs', scope: { type: 'terminal', targetId: 'term-1' }, agentId: 'claude' }),
|
|
];
|
|
const entries = buildEntries(sessions);
|
|
const note = formatAiChatsListingNote(entries, t);
|
|
assert.ok(!note.includes('rebind'));
|
|
});
|
|
|
|
test('resolveRebindTargetFromArg picks only dangling entries by their number', () => {
|
|
const sessions = [
|
|
createSession('s-live', { title: 'Fix deploy' }),
|
|
createSession('s-dangling', { title: 'Old', scope: { type: 'workspace', targetId: 'ws-gone' } }),
|
|
createSession('s-dangling-2', { title: 'Older', scope: { type: 'terminal', targetId: 'term-gone' } }),
|
|
];
|
|
const entries = buildEntries(sessions);
|
|
const dangling = entries.find((entry) => entry.isDangling);
|
|
assert.ok(dangling);
|
|
|
|
assert.equal(resolveRebindTargetFromArg('1', entries)?.sessionId, 's-dangling');
|
|
assert.equal(resolveRebindTargetFromArg(' 2 ', entries)?.sessionId, entries.filter((entry) => entry.isDangling)[1].sessionId);
|
|
assert.equal(resolveRebindTargetFromArg('3', entries), null);
|
|
assert.equal(resolveRebindTargetFromArg('1foo', entries), null);
|
|
assert.equal(resolveRebindTargetFromArg('1.5', entries), null);
|
|
assert.equal(resolveRebindTargetFromArg('0', entries), null);
|
|
assert.equal(resolveRebindTargetFromArg('-1', entries), null);
|
|
assert.equal(resolveRebindTargetFromArg('', entries), null);
|
|
assert.equal(resolveRebindTargetFromArg('abc', entries), null);
|
|
// A live entry is not rebindable even if its position would match.
|
|
assert.equal(resolveRebindTargetFromArg('1', [entries[0]]), null);
|
|
});
|
|
|
|
test('/chats note reports an empty panel', () => {
|
|
assert.equal(formatAiChatsListingNote([], t), 'ai.chat.chatsEmpty');
|
|
});
|
|
|
|
test('truncateChatTitle clamps long titles and keeps short ones intact', () => {
|
|
assert.equal(truncateChatTitle('short'), 'short');
|
|
assert.equal(truncateChatTitle(' '), '');
|
|
const long = 'x'.repeat(80);
|
|
const truncated = truncateChatTitle(long);
|
|
assert.equal(truncated.length, 42);
|
|
assert.ok(truncated.endsWith('…'));
|
|
});
|
|
|
|
test('resolveChatAgentLabel resolves external agents and falls back to the raw id', () => {
|
|
assert.equal(resolveChatAgentLabel('catty', externalAgents), 'Catty Agent');
|
|
assert.equal(resolveChatAgentLabel('claude', externalAgents), 'Claude Code');
|
|
assert.equal(resolveChatAgentLabel('mystery', externalAgents), 'mystery');
|
|
});
|