Files
Netcatty/components/ai/aiChatsListing.test.ts
T
8b5b0c4416 fix(ai): keep dropped AI chats recoverable with dangling marking + /rebind; reuse workspace id on merge (#4019)
* 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>
2026-10-27 20:26:21 -07:00

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');
});