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>
This commit is contained in:
authored and GitHub committed 2026-10-27 20:26:21 -07:00
1 parent afb0585016
commit 8b5b0c4416
27 files changed
+819 -169

No files matched your search

+5
View File
@@ -606,6 +606,11 @@ export const enAiMessages: Messages = {
'ai.chat.chatsScopeTerminal': 'Terminal',
'ai.chat.chatsScopeGlobal': 'Global',
'ai.chat.chatsScopeDangling': 'Dissociated',
'ai.chat.slashRebindDesc': 'Rebind a disconnected chat to this panel (/rebind <n>)',
'ai.chat.chatsRebindHint': 'Disconnected chats can be restored with /rebind <n>.',
'ai.chat.rebindUsage': 'Usage: /rebind <number from /chats>',
'ai.chat.rebindDone': 'Chat "{title}" rebound to this panel.',
'ai.chat.rebindUnavailable': 'No rebind target in this panel scope.',
'ai.chat.justNow': 'Just now',
'ai.chat.minutesAgo': '{n}m ago',
'ai.chat.hoursAgo': '{n}h ago',
+5
View File
@@ -606,6 +606,11 @@ export const esAiMessages: Messages = {
'ai.chat.chatsScopeTerminal': 'Terminal',
'ai.chat.chatsScopeGlobal': 'Global',
'ai.chat.chatsScopeDangling': 'Desasociado',
'ai.chat.slashRebindDesc': 'Reasociar un chat desconectado a este panel (/rebind <n>)',
'ai.chat.chatsRebindHint': 'Los chats desconectados se pueden restaurar con /rebind <n>.',
'ai.chat.rebindUsage': 'Uso: /rebind <número de /chats>',
'ai.chat.rebindDone': 'Chat «{title}» reasociado a este panel.',
'ai.chat.rebindUnavailable': 'No hay destino de reasociación en el ámbito de este panel.',
'ai.chat.justNow': 'Ahora mismo',
'ai.chat.minutesAgo': 'hace {n} min',
'ai.chat.hoursAgo': 'hace {n} h',
+5
View File
@@ -545,6 +545,11 @@ export const ruAiMessages: Messages = {
'ai.chat.chatsScopeTerminal': 'Терминал',
'ai.chat.chatsScopeGlobal': 'Глобально',
'ai.chat.chatsScopeDangling': 'Связь потеряна',
'ai.chat.slashRebindDesc': 'Привязать потерянный чат к этой панели (/rebind <n>)',
'ai.chat.chatsRebindHint': 'Потерянные чаты можно восстановить командой /rebind <n>.',
'ai.chat.rebindUsage': 'Использование: /rebind <номер из /chats>',
'ai.chat.rebindDone': 'Чат «{title}» привязан к этой панели.',
'ai.chat.rebindUnavailable': 'В области этой панели нет цели для привязки.',
'ai.chat.justNow': 'Только что',
'ai.chat.minutesAgo': '{n}м назад',
'ai.chat.hoursAgo': '{n}ч назад',
+5
View File
@@ -606,6 +606,11 @@ export const zhCNAiMessages: Messages = {
'ai.chat.chatsScopeTerminal': '终端',
'ai.chat.chatsScopeGlobal': '全局',
'ai.chat.chatsScopeDangling': '已断开关联',
'ai.chat.slashRebindDesc': '将断联的聊天重绑到此面板(/rebind <序号>)',
'ai.chat.chatsRebindHint': '断联的聊天可用 /rebind <序号> 恢复。',
'ai.chat.rebindUsage': '用法:/rebind <序号>',
'ai.chat.rebindDone': '聊天“{title}”已重绑到此面板。',
'ai.chat.rebindUnavailable': '当前面板范围内没有可用的重绑目标。',
'ai.chat.justNow': '刚刚',
'ai.chat.minutesAgo': '{n}分钟前',
'ai.chat.hoursAgo': '{n}小时前',
+5
View File
@@ -596,6 +596,11 @@ export const zhTWAiMessages: Messages = {
'ai.chat.chatsScopeTerminal': '終端',
'ai.chat.chatsScopeGlobal': '全域',
'ai.chat.chatsScopeDangling': '已斷開關聯',
'ai.chat.slashRebindDesc': '將斷開關聯的聊天重新綁定到此面板(/rebind <序號>)',
'ai.chat.chatsRebindHint': '斷開關聯的聊天可用 /rebind <序號> 恢復。',
'ai.chat.rebindUsage': '用法:/rebind <序號>',
'ai.chat.rebindDone': '聊天「{title}」已重新綁定到此面板。',
'ai.chat.rebindUnavailable': '目前面板範圍內沒有可用的重新綁定目標。',
'ai.chat.justNow': '剛剛',
'ai.chat.minutesAgo': '{n}分鐘前',
'ai.chat.hoursAgo': '{n}小時前',
+1 -47
View File
@@ -2,14 +2,9 @@ import test from "node:test";
import assert from "node:assert/strict";
import type {
AIPanelView,
AISession,
} from "../../infrastructure/ai/types.ts";
import { createEmptyDraft } from "./aiDraftState.ts";
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
} from "./aiScopeCleanup.ts";
import { pruneInactiveScopedSessions } from "./aiScopeCleanup.ts";
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
return {
@@ -24,47 +19,6 @@ function createSession(id: string, scope: AISession["scope"], externalSessionId?
};
}
test("pruneInactiveScopedTransientState removes closed workspace and terminal scope state", () => {
const activeSessionIdMap = {
"terminal:open-terminal": "session-open",
"terminal:closed-terminal": "session-closed-terminal",
"workspace:open-workspace": "session-open-workspace",
"workspace:closed-workspace": "session-closed-workspace",
};
const draftsByScope = {
"terminal:open-terminal": createEmptyDraft("catty"),
"terminal:closed-terminal": createEmptyDraft("catty"),
"workspace:open-workspace": createEmptyDraft("catty"),
"workspace:closed-workspace": createEmptyDraft("catty"),
};
const panelViewByScope = {
"terminal:open-terminal": { mode: "draft" },
"terminal:closed-terminal": { mode: "session", sessionId: "session-closed-terminal" },
"workspace:open-workspace": { mode: "draft" },
"workspace:closed-workspace": { mode: "session", sessionId: "session-closed-workspace" },
} satisfies Record<string, AIPanelView>;
const next = pruneInactiveScopedTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open-terminal", "open-workspace"]),
);
assert.deepEqual(next.activeSessionIdMap, {
"terminal:open-terminal": "session-open",
"workspace:open-workspace": "session-open-workspace",
});
assert.deepEqual(next.draftsByScope, {
"terminal:open-terminal": draftsByScope["terminal:open-terminal"],
"workspace:open-workspace": draftsByScope["workspace:open-workspace"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open-terminal": panelViewByScope["terminal:open-terminal"],
"workspace:open-workspace": panelViewByScope["workspace:open-workspace"],
});
});
test("pruneInactiveScopedSessions reports inactive targets without deleting persisted history", () => {
const sessions = [
createSession("terminal-restorable", {
-36
View File
@@ -6,7 +6,6 @@ import type {
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
type ActiveSessionIdMap = Record<string, string | null>;
function isInactiveScopedTarget(
scopeKey: string,
@@ -55,41 +54,6 @@ export function pruneInactiveScopedState(
};
}
export function pruneInactiveScopedTransientState(
activeSessionIdMap: ActiveSessionIdMap,
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTargetIds: Set<string>,
): {
activeSessionIdMap: ActiveSessionIdMap;
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
let activeSessionMapChanged = false;
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (isInactiveScopedTarget(scopeKey, activeTargetIds)) {
activeSessionMapChanged = true;
continue;
}
nextActiveSessionIdMap[scopeKey] = sessionId;
}
const nextScopedState = pruneInactiveScopedState(
draftsByScope,
panelViewByScope,
activeTargetIds,
);
return {
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
draftsByScope: nextScopedState.draftsByScope,
panelViewByScope: nextScopedState.panelViewByScope,
};
}
export function pruneInactiveScopedSessions(
sessions: AISession[],
activeTargetIds: Set<string>,
@@ -135,3 +135,13 @@ test('DebouncedBackupMirror flushNow writes the pending payload immediately', ()
mirror.flushNow();
assert.deepEqual(writes, ['{"gen":1}']);
});
test('readLiveSessionsArray treats non-array JSON as empty (corrupt)', () => {
assert.deepEqual(readLiveSessionsArray(null), []);
assert.deepEqual(readLiveSessionsArray(''), []);
assert.deepEqual(readLiveSessionsArray('{}'), []);
assert.deepEqual(readLiveSessionsArray('{"sessions":[]}'), []);
assert.deepEqual(readLiveSessionsArray('not-json'), []);
assert.deepEqual(readLiveSessionsArray('[]'), []);
assert.equal(readLiveSessionsArray('[{"id":"a"}]').length, 1);
});
+1
View File
@@ -48,6 +48,7 @@ export function readLiveSessionsArray(raw: string | null | undefined): AISession
* usable required fields. Backup generations are written by the same serializer as the
* live store, so entries are expected to be full AISession objects.
*/
export function parseBackupSessions(json: string | null | undefined): AISession[] | null {
if (typeof json !== 'string' || json === '') return null;
let parsed: unknown;
+130 -1
View File
@@ -16,6 +16,7 @@ import {
latestAISessionsSnapshot,
setLatestAIActiveSessionMapSnapshot,
setLatestAISessionsSnapshot,
rebindActiveSessionMap,
} from './aiStateSnapshots';
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
@@ -90,7 +91,9 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
const previousWindow = globalThis.window;
const previousSessions = latestAISessionsSnapshot;
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
t.mock.method(localStorageAdapter, 'read', () => null);
t.mock.method(localStorageAdapter, 'write', () => true);
t.mock.method(localStorageAdapter, 'writeString', () => true);
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
@@ -122,8 +125,24 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
try {
cleanupOrphanedAISessions(new Set(['open', 'resumed-into']));
assert.equal(latestAISessionsSnapshot, sessions);
// Issue #4015 R2: the dropped chat is marked dangling but its record and
// its active pointer stay intact so /chats can offer a rebind.
const persistedSessions = latestAISessionsSnapshot;
assert.ok(persistedSessions);
assert.equal(
persistedSessions.find((session) => session.id === 'privacy-orphan')?.scopeDangling,
true,
);
assert.equal(
persistedSessions.find((session) => session.id === 'privacy-live')?.scopeDangling,
undefined,
);
assert.equal(
persistedSessions.find((session) => session.id === 'privacy-resumed')?.scopeDangling,
undefined,
);
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
'terminal:closed': 'privacy-orphan',
'terminal:open': 'privacy-live',
'terminal:resumed-into': 'privacy-resumed',
});
@@ -158,6 +177,95 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
}
});
test('orphan cleanup marks dangling chats instead of stripping them, and clears the flag when the target returns', (t) => {
const previousSessions = latestAISessionsSnapshot;
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
const read = t.mock.method(localStorageAdapter, 'read', () => null);
t.mock.method(localStorageAdapter, 'write', () => true);
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
const previousWindow = globalThis.window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { dispatchEvent: () => true, netcatty: {} },
});
const dangling = {
...makeSession('dangling-chat', 1, []),
scope: { type: 'terminal' as const, targetId: 'closed-ws' },
};
const live = {
...makeSession('live-chat', 2, []),
scope: { type: 'terminal' as const, targetId: 'open' },
};
setLatestAISessionsSnapshot([dangling, live]);
setLatestAIActiveSessionMapSnapshot({
'terminal:closed-ws': 'dangling-chat',
'terminal:open': 'live-chat',
});
try {
cleanupOrphanedAISessions(new Set(['open']));
let persisted = latestAISessionsSnapshot;
assert.ok(persisted);
assert.equal(persisted.find((s) => s.id === 'dangling-chat')?.scopeDangling, true);
assert.equal(persisted.find((s) => s.id === 'live-chat')?.scopeDangling, undefined);
// The active pointer survives the dangling marking.
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
'terminal:closed-ws': 'dangling-chat',
'terminal:open': 'live-chat',
});
assert.ok(writeString.mock.callCount() > 0);
// The scope target becomes live again → the dangling flag clears.
read.mock.mockImplementation(() => null);
cleanupOrphanedAISessions(new Set(['open', 'closed-ws']));
persisted = latestAISessionsSnapshot;
assert.ok(persisted);
assert.equal(persisted.find((s) => s.id === 'dangling-chat')?.scopeDangling, undefined);
} finally {
setLatestAISessionsSnapshot(previousSessions ?? []);
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: previousWindow,
});
}
});
test('orphan cleanup with an empty live-target set marks all scoped chats dangling', (t) => {
const previousSessions = latestAISessionsSnapshot;
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
t.mock.method(localStorageAdapter, 'read', () => null);
t.mock.method(localStorageAdapter, 'write', () => true);
t.mock.method(localStorageAdapter, 'writeString', () => true);
const previousWindow = globalThis.window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { dispatchEvent: () => true, netcatty: {} },
});
const orphan = {
...makeSession('orphan-chat', 1, []),
scope: { type: 'terminal' as const, targetId: 'gone' },
};
setLatestAISessionsSnapshot([orphan]);
setLatestAIActiveSessionMapSnapshot({ 'terminal:gone': 'orphan-chat' });
try {
// Empty is a valid observed live set (nothing mounted), not "unknown".
cleanupOrphanedAISessions(new Set());
const persisted = latestAISessionsSnapshot;
assert.ok(persisted);
assert.equal(persisted.find((s) => s.id === 'orphan-chat')?.scopeDangling, true);
} finally {
setLatestAISessionsSnapshot(previousSessions ?? []);
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: previousWindow,
});
}
});
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
@@ -858,3 +966,24 @@ test('successful store writes schedule a throttled disk backup mirror', async (t
});
}
});
test('rebindActiveSessionMap drops stale pointers and sets the new scope', () => {
const cleaned = rebindActiveSessionMap(
{
'terminal:old-ws': 'chat-1',
'terminal:other': 'chat-2',
'workspace:keep': 'chat-1',
},
'chat-1',
'terminal:new-ws',
);
assert.deepEqual(cleaned, {
'terminal:other': 'chat-2',
'terminal:new-ws': 'chat-1',
});
// Idempotent when already bound to the target scope with no other pointers.
assert.deepEqual(
rebindActiveSessionMap({ 'terminal:new-ws': 'chat-1' }, 'chat-1', 'terminal:new-ws'),
{ 'terminal:new-ws': 'chat-1' },
);
});
+65 -43
View File
@@ -20,11 +20,11 @@ import {
} from './aiDraftState';
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
pruneInactiveScopedState,
} from './aiScopeCleanup';
import { emitAIStateChanged } from './aiStateEvents';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
import { DebouncedBackupMirror } from './aiSessionsBackup';
import { DebouncedBackupMirror, readLiveSessionsArray } from './aiSessionsBackup';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
export interface AIBridge {
@@ -88,10 +88,13 @@ function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
return activeTargetIds.has(targetId);
}
/** Last live terminal/workspace target ids observed by orphan cleanup. */
export let latestAIActiveTargetIdsSnapshot: Set<string> = new Set();
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
latestAIActiveTargetIdsSnapshot = activeTargetIds;
const currentSessions = latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
// Sessions shown by a still-live scope must be protected from cleanup
// even when their own `scope.targetId` points at a closed terminal —
@@ -117,65 +120,62 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds);
}
if (nextSessionCleanup.sessions !== currentSessions) {
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
writeSessionsForStorage(nextSessionCleanup.sessions);
// Issue #4015 R2: a dropped chat is recoverable now, so mark it dangling
// instead of stripping its record or pointer. `/chats` lists dangling chats
// with a rebind affordance; the flag clears when the chat is rebound or its
// scope target becomes live again.
const orphanedIds = new Set(nextSessionCleanup.orphanedSessionIds);
let sessionsMarked = false;
const markedSessions = currentSessions.map((session) => {
if (orphanedIds.has(session.id)) {
if (session.scopeDangling) return session;
sessionsMarked = true;
return { ...session, scopeDangling: true };
}
if (
session.scopeDangling
&& (!session.scope.targetId || activeTargetIds.has(session.scope.targetId))
) {
sessionsMarked = true;
const { scopeDangling: _cleared, ...rest } = session;
return rest as AISession;
}
return session;
});
if (sessionsMarked) {
setLatestAISessionsSnapshot(markedSessions);
writeSessionsForStorage(markedSessions);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
const activeSessionIdMap = preCleanupActiveSessionMap;
let activeSessionMapChanged = false;
const nextActiveSessionIdMap = { ...activeSessionIdMap };
for (const scopeKey of Object.keys(activeSessionIdMap)) {
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
delete nextActiveSessionIdMap[scopeKey];
activeSessionMapChanged = true;
}
if (activeSessionMapChanged) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
const currentActiveSessionIdMap = activeSessionMapChanged
? nextActiveSessionIdMap
: activeSessionIdMap;
// Issue #4015 R2: keep active-pointer entries for scopes whose target
// closed. Clearing them used to make dangling chats invisible (rank 0) and
// unrecoverable from the UI; the pointer is rewritten on rebind instead.
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const prunedScopedTransientState = pruneInactiveScopedTransientState(
currentActiveSessionIdMap,
const prunedScopedState = pruneInactiveScopedState(
currentDraftsByScope,
currentPanelViewByScope,
activeTargetIds,
);
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
localStorageAdapter.write(
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
prunedScopedTransientState.activeSessionIdMap,
);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
if (prunedScopedState.draftsByScope !== currentDraftsByScope) {
for (const scopeKey of Object.keys(currentDraftsByScope)) {
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
if (scopeKey in prunedScopedState.draftsByScope) continue;
bumpDraftMutationVersion(scopeKey);
bumpDraftUploadGeneration(scopeKey);
}
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
setLatestAIDraftsByScopeSnapshot(prunedScopedState.draftsByScope);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
}
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
if (prunedScopedState.panelViewByScope !== currentPanelViewByScope) {
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
if (scopeKey in prunedScopedState.panelViewByScope) continue;
bumpDraftMutationVersion(scopeKey);
}
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
setLatestAIPanelViewByScopeSnapshot(prunedScopedState.panelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
}
}
@@ -581,6 +581,28 @@ export let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
/**
* Issue #4015 R2: rewrite the active-session map when rebinding a dangling
* chat. Drops every pointer that still names `sessionId` under a different
* scope key, then sets the new scope as active. Callers must persist and
* publish the returned map (do not rebuild from a stale snapshot).
*/
export function rebindActiveSessionMap(
activeSessionIdMap: Record<string, string | null>,
sessionId: string,
scopeKey: string,
): Record<string, string | null> {
let next = activeSessionIdMap;
for (const [key, id] of Object.entries(activeSessionIdMap)) {
if (id !== sessionId || key === scopeKey) continue;
if (next === activeSessionIdMap) next = { ...activeSessionIdMap };
delete next[key];
}
if (next[scopeKey] === sessionId) return next;
return { ...next, [scopeKey]: sessionId };
}
export function setLatestAISessionsSnapshot(sessions: AISession[]) {
latestAISessionsSnapshot = sessions;
}
+92 -1
View File
@@ -64,6 +64,7 @@ import {
bumpDraftUploadGeneration,
cleanupDeletedAIChatSessions,
cleanupOrphanedAISessions,
latestAIActiveTargetIdsSnapshot,
getAIBridge,
flushBackupMirror,
getDraftUploadGeneration,
@@ -73,6 +74,7 @@ import {
latestAISessionsSnapshot,
prepareRestoredSessions,
scheduleBackupMirror,
rebindActiveSessionMap,
writeSessionsForStorage,
setLatestAIActiveSessionMapSnapshot,
setLatestAIDraftsByScopeSnapshot,
@@ -204,16 +206,39 @@ export function useAIState() {
if (cancelled || !backup?.ok) return;
// Re-check after the async gap: a session may have been created
// while the backup was being read; never restore over live state.
// Cover both persisted storage and in-memory snapshot/React state —
// createSession updates the snapshot before the debounced disk write.
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
return;
}
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
const restored = prepareRestoredSessions(parseBackupSessions(backup.json) ?? [], syncEnabled);
if (restored.length === 0) return;
// Apply restore synchronously (not inside a setState updater). A
// deferred updater would leave didRestore false and skip dangling
// marking even when the records later land in React state.
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
if (sessionsRef.current.length > 0) return;
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
return;
}
setLatestAISessionsSnapshot(restored);
setSessionsRaw(prev => (prev.length > 0 ? prev : restored));
writeSessionsForStorage(restored);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
// Restore does not change live terminals/workspaces, so the orphan-
// cleanup effect will not run. Reuse the last known live target set
// (empty is valid: nothing live) so chats bound to missing scopes
// become dangling and /chats can offer /rebind.
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
const nextSessions = latestAISessionsSnapshot ?? restored;
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
setActiveSessionIdMapRaw(
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
console.info(
`[AIState] Restored ${restored.length} AI session(s) from the disk backup.`,
);
@@ -920,6 +945,53 @@ export function useAIState() {
});
}, [persistSessions]);
// Issue #4015 R2: rebind a dangling chat to the panel's current scope.
// Rewrites `scope`, clears the dangling marker and makes the chat the
// active session of its new scope so it shows in the scoped history again.
const rebindSessionScope = useCallback((
sessionId: string,
scope: { type: 'terminal' | 'workspace'; targetId: string; hostIds?: string[] },
) => {
const scopeKey = `${scope.type}:${scope.targetId}`;
setSessionsRaw(prev => {
const next = prev.map(s => {
if (s.id !== sessionId) return s;
const { scopeDangling: _cleared, ...rest } = s;
// Replace scope entirely (including hostIds) so history matching follows
// the destination terminal/workspace rather than the chat's old host.
const nextScope: AISession['scope'] = {
type: scope.type,
targetId: scope.targetId,
};
if (scope.hostIds && scope.hostIds.length > 0) {
nextScope.hostIds = [...scope.hostIds];
}
return {
...rest,
scope: nextScope,
updatedAt: Date.now(),
} as AISession;
});
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
// Drop stale pointers that still reference this chat under its previous
// scope key, then restore the active pointer for the new scope.
// Compute + persist inside the updater so a deferred React setState still
// writes the cleaned map (outer capture would stay null and skip persist).
setActiveSessionIdMapRaw(prev => {
const next = rebindActiveSessionMap(prev, sessionId, scopeKey);
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
});
// Open the rebound chat in this panel; active-map alone loses to an
// explicit draft/session panelView that still points elsewhere.
setPanelViewByScope((prev) => setSessionView(prev, scopeKey, sessionId));
}, [persistSessions, setPanelViewByScope]);
// Manual durability escape hatch (issue #4015 follow-up): export the
// current store to a user-chosen JSON file, or import one back in.
// Import is an explicit action, so it may overwrite the live store.
@@ -939,6 +1011,8 @@ export function useAIState() {
if (!result?.success || !result.json) return 0;
const imported = parseBackupSessions(result.json);
if (!imported || imported.length === 0) return 0;
// Merge synchronously against the live ref so orphan cleanup sees the
// imported chats even if React would defer a setState updater.
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
const next = prepareRestoredSessions(mergeImportedSessions(sessionsRef.current, imported), syncEnabled);
if (!writeSessionsForStorage(next)) return 0;
@@ -948,6 +1022,21 @@ export function useAIState() {
setLatestAISessionsSnapshot(persisted);
setSessionsRaw(persisted);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
// Import does not change live terminals/workspaces, so the orphan-cleanup
// effect will not run. Reuse the last known live target set (empty is
// valid: nothing live) to mark chats bound to missing scopes as dangling
// so /chats can offer /rebind.
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
const nextSessions =
latestAISessionsSnapshot
?? persisted;
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
setActiveSessionIdMapRaw(
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
return persisted.filter(session => importedIds.has(session.id)).length;
}, []);
@@ -1455,6 +1544,7 @@ export function useAIState() {
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
rebindSessionScope,
exportSessionsBackup,
importSessionsBackup,
updateSessionExternalSessionId,
@@ -1519,6 +1609,7 @@ export function useAIState() {
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
rebindSessionScope,
exportSessionsBackup,
importSessionsBackup,
updateSessionExternalSessionId,
+58 -24
View File
@@ -24,6 +24,7 @@ reorderWorkspaceFocusSessionOrder,
SplitDirection,
SplitHint,
updateWorkspaceSplitSizes,
invertSplitPosition,
} from '../../domain/workspace';
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
import {
@@ -795,12 +796,68 @@ export const useSessionState = ({
return workspace.id;
}, [setActiveTabId]);
const addSessionToWorkspace = useCallback((
workspaceId: string,
sessionId: string,
hint: SplitHint
) => {
if (!hint) return;
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session || session.workspaceId) return prevSessions;
setWorkspaces(prevWorkspaces => {
const targetWorkspace = prevWorkspaces.find(w => w.id === workspaceId);
if (!targetWorkspace) return prevWorkspaces;
return insertWorkspacePaneIfMissing(prevWorkspaces, workspaceId, sessionId, hint);
});
setActiveTabId(workspaceId);
return prevSessions.map(s => s.id === sessionId ? { ...s, workspaceId } : s);
});
}, [setActiveTabId]);
// Merge two tabs into one workspace. When one of them already belongs to a
// workspace, that workspace (and its id) is reused instead of dissolving
// and recreating: the id is what workspace-scoped AI chats bind to, so
// regenerating it used to strand every conversation anchored to it
// (issue #4015 R3). Only two genuinely standalone tabs create a new id.
const createWorkspaceFromSessions = useCallback((
baseSessionId: string,
joiningSessionId: string,
hint: SplitHint
) => {
if (!hint || baseSessionId === joiningSessionId) return;
const baseSession = sessionsRef.current.find((s) => s.id === baseSessionId);
const joiningSession = sessionsRef.current.find((s) => s.id === joiningSessionId);
const anchorWorkspaceId = baseSession?.workspaceId ?? joiningSession?.workspaceId;
if (anchorWorkspaceId) {
const anchorSessionId = baseSession?.workspaceId ? baseSessionId : joiningSessionId;
const orphanSessionId = anchorSessionId === baseSessionId ? joiningSessionId : baseSessionId;
const orphanSession = sessionsRef.current.find((s) => s.id === orphanSessionId);
if (!orphanSession || orphanSession.workspaceId) return;
// Split next to the anchor's existing pane: the raw drop hint targets
// the orphan's pane, which is not part of this workspace tree yet.
// When the dragged (joining) pane already owns the workspace, the orphan
// is what gets inserted — invert the drop position so the previewed
// side still matches (hint describes where the dragged pane should sit).
const joinOwnsWorkspace = Boolean(joiningSession?.workspaceId) && !baseSession?.workspaceId;
const insertHint = {
...hint,
targetSessionId: anchorSessionId,
position: joinOwnsWorkspace ? invertSplitPosition(hint.position) : hint.position,
};
addSessionToWorkspace(
anchorWorkspaceId,
orphanSessionId,
insertHint,
);
// The orphan's standalone tab slot is replaced by the workspace tab.
setTabOrder((prevTabOrder) => prevTabOrder.filter((id) => id !== orphanSessionId));
return;
}
// Name the merged workspace after both tabs' connection labels (e.g.
// "01/02") so the window title and the rename dialog show the merged
// identity instead of the generic "Workspace". Falls back to the default
@@ -855,30 +912,7 @@ export const useSessionState = ({
return s;
});
});
}, [setActiveTabId]);
const addSessionToWorkspace = useCallback((
workspaceId: string,
sessionId: string,
hint: SplitHint
) => {
if (!hint) return;
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session || session.workspaceId) return prevSessions;
setWorkspaces(prevWorkspaces => {
const targetWorkspace = prevWorkspaces.find(w => w.id === workspaceId);
if (!targetWorkspace) return prevWorkspaces;
return insertWorkspacePaneIfMissing(prevWorkspaces, workspaceId, sessionId, hint);
});
setActiveTabId(workspaceId);
return prevSessions.map(s => s.id === sessionId ? { ...s, workspaceId } : s);
});
}, [setActiveTabId]);
}, [addSessionToWorkspace, setActiveTabId]);
// Add a host into an existing workspace by creating a new session for
// that host and appending it as the last pane at the workspace root.
@@ -0,0 +1,147 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { collectSessionIds } from "../../domain/workspace.ts";
import { useSessionState } from "./useSessionState.ts";
type SessionState = ReturnType<typeof useSessionState>;
test("merging a workspace tab with a standalone tab reuses the workspace id (issue #4015 R3)", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
Object.assign(eventTarget, {
setTimeout,
clearTimeout,
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: eventTarget,
});
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
t.after(() => {
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
});
let state: SessionState | null = null;
let renderer: ReactTestRenderer | null = null;
const current = (): SessionState => {
assert.ok(state);
return state;
};
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
let baseId = "";
let memberId = "";
let standaloneId = "";
await act(async () => {
baseId = current().createLocalTerminal({ shellName: "01" });
memberId = current().createLocalTerminal({ shellName: "02" });
standaloneId = current().createLocalTerminal({ shellName: "03" });
});
await act(async () => {
current().createWorkspaceFromSessions(baseId, memberId, {
direction: "vertical",
position: "right",
targetSessionId: baseId,
});
});
const workspaceId = current().workspaces[0]?.id;
assert.ok(workspaceId);
assert.equal(current().sessions.find((s) => s.id === standaloneId)?.workspaceId ?? "", "");
// Dropping the standalone tab onto a member of the workspace must join the
// existing workspace — regenerating the id would strand AI chats bound to it.
await act(async () => {
current().createWorkspaceFromSessions(standaloneId, memberId, {
direction: "vertical",
position: "right",
targetSessionId: standaloneId,
});
});
assert.equal(current().workspaces.length, 1);
assert.equal(current().workspaces[0]?.id, workspaceId);
assert.equal(
current().sessions.find((s) => s.id === standaloneId)?.workspaceId,
workspaceId,
);
const memberIds = collectSessionIds(current().workspaces[0]!.root);
assert.ok(memberIds.includes(standaloneId));
assert.ok(memberIds.includes(memberId));
// The standalone tab slot is replaced by the workspace tab.
assert.ok(!current().orderedTabs.includes(standaloneId));
// Dropping a workspace member onto a standalone tab must insert the
// standalone on the opposite side of the drop hint (hint describes where
// the dragged member should appear relative to the standalone).
let orientationStandaloneId = "";
let orientationMemberId = "";
await act(async () => {
orientationStandaloneId = current().createLocalTerminal({ shellName: "orient-standalone" });
});
// memberId is already in workspaceId; drop member onto the new standalone with position "right"
// (preview: member to the right of standalone ⇒ standalone should end up LEFT of member).
await act(async () => {
current().createWorkspaceFromSessions(orientationStandaloneId, memberId, {
direction: "vertical",
position: "right",
targetSessionId: orientationStandaloneId,
});
});
{
const root = current().workspaces.find((ws) => ws.id === workspaceId)?.root;
assert.ok(root);
const ordered = collectSessionIds(root!);
const standaloneIdx = ordered.indexOf(orientationStandaloneId);
const memberIdx = ordered.indexOf(memberId);
assert.ok(standaloneIdx >= 0 && memberIdx >= 0);
assert.ok(
standaloneIdx < memberIdx,
`expected standalone left of member after inverted right-drop, got order ${ordered.join(",")}`,
);
}
// Two genuinely standalone tabs still create a fresh workspace.
let firstStandaloneId = "";
let secondStandaloneId = "";
await act(async () => {
firstStandaloneId = current().createLocalTerminal({ shellName: "04" });
secondStandaloneId = current().createLocalTerminal({ shellName: "05" });
});
await act(async () => {
current().createWorkspaceFromSessions(firstStandaloneId, secondStandaloneId, {
direction: "vertical",
position: "right",
targetSessionId: firstStandaloneId,
});
});
assert.equal(current().workspaces.length, 2);
const freshWorkspace = current().workspaces.find((candidate) => candidate.id !== workspaceId);
assert.ok(freshWorkspace);
assert.notEqual(freshWorkspace.id, workspaceId);
await act(async () => {
renderer?.unmount();
});
});
+4
View File
@@ -63,6 +63,8 @@ interface AIChatPanelContentProps {
onRenameActiveChat?: (title: string) => void;
/** `/chats`: list the chats visible to this panel. Panel action, never agent content. */
onListChats?: () => void;
/** `/rebind <n>`: rebind a dangling chat to this panel's scope. Panel action, never agent content. */
onRebindChat?: (arg: string) => void;
/** `/chats` listing output, rendered as a system-style note above the composer. */
panelCommandNote?: string | null;
onDismissPanelCommandNote?: () => void;
@@ -157,6 +159,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
onRenameSession,
onRenameActiveChat,
onListChats,
onRebindChat,
panelCommandNote,
onDismissPanelCommandNote,
onExportSessions,
@@ -435,6 +438,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
onCompact={handleCompact}
onRenameSession={onRenameActiveChat}
onListChats={onListChats}
onRebindChat={onRebindChat}
canCompact={canCompact}
onSteer={handleSteer}
onStop={handleStop}
+66 -6
View File
@@ -64,7 +64,7 @@ import {
type DefaultTargetSessionHint,
} from '../application/state/useAIChatStreaming';
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
import { buildAiChatsListingEntries, formatAiChatsListingNote } from './ai/aiChatsListing';
import { buildAiChatsListingEntries, canRebindChat, formatAiChatsListingNote, resolveRebindTargetFromArg, type AiChatsListingEntry } from './ai/aiChatsListing';
import { resolveInheritedAIActiveSessionId } from '../domain/aiWorkspaceScopeInherit';
import { resolveUndoLastTurnBoundary } from '../domain/aiUndoLastTurn';
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
@@ -280,6 +280,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
undoLastTurnInSession,
deleteSession,
updateSessionTitle,
rebindSessionScope,
exportSessionsBackup,
importSessionsBackup,
updateSessionExternalSessionId,
@@ -317,6 +318,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
focusedSessionId,
workspaces = [],
terminalSessions = [],
liveScopeTargetIds,
resolveExecutorContext,
isVisible = true,
notes = [],
@@ -334,6 +336,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
// Inline system-style note above the composer (e.g. `/chats` listing).
// Panel-command output, never agent content.
const [panelCommandNote, setPanelCommandNote] = useState<string | null>(null);
// Entries of the last `/chats` listing; `/rebind <n>` resolves against them.
const lastChatsListingRef = useRef<AiChatsListingEntry[]>([]);
const [isSending, setIsSending] = useState(false);
// Undo-last-turn is still
// running. Unlike the send flow this is not reflected in `isSending`, so it
@@ -511,11 +515,19 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}, [handleRenameSession]);
const handleListChats = useCallback(() => {
const liveTerminalIds = scopeType === 'workspace' && workspaceMemberTerminalIds
? workspaceMemberTerminalIds
: new Set(scopeTargetId ? [scopeTargetId] : []);
// Prefer the app-wide live target set so chats bound to other still-open
// terminals are not mislabelled dangling / offered for /rebind.
const liveTerminalIds = liveScopeTargetIds
?? (scopeType === 'workspace' && workspaceMemberTerminalIds
? workspaceMemberTerminalIds
: new Set(scopeTargetId ? [scopeTargetId] : []));
// Dangling chats no longer match any live scope (rank 0), so they must be
// appended explicitly for the listing — that is where they become visible
// and rebindable again (issue #4015 R2).
const listedSessionIds = new Set(historySessions.map((session) => session.id));
const danglingSessions = sessions.filter((session) => session.scopeDangling && !listedSessionIds.has(session.id));
const entries = buildAiChatsListingEntries({
sessions: historySessions,
sessions: [...historySessions, ...danglingSessions],
activeSessionId,
externalAgents,
workspaces,
@@ -524,8 +536,50 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
scopeTargetId,
t,
});
lastChatsListingRef.current = entries;
setPanelCommandNote(formatAiChatsListingNote(entries, t));
}, [activeSessionId, externalAgents, historySessions, scopeLabel, scopeTargetId, scopeType, t, workspaces, workspaceMemberTerminalIds]);
}, [activeSessionId, externalAgents, historySessions, liveScopeTargetIds, scopeLabel, scopeTargetId, scopeType, sessions, t, workspaces, workspaceMemberTerminalIds]);
// `/rebind <n>`: rebind the nth dangling chat from the last /chats listing
// to this panel's current scope. Panel action — no agent round-trip.
const handleRebindChat = useCallback((arg: string) => {
if (!scopeTargetId) {
toast.warning(t('ai.chat.rebindUnavailable'));
return;
}
const target = resolveRebindTargetFromArg(arg, lastChatsListingRef.current);
if (!target) {
toast.warning(t('ai.chat.rebindUsage'));
return;
}
const session = sessions.find((item) => item.id === target.sessionId);
const liveTargets = new Set(liveScopeTargetIds ?? workspaceMemberTerminalIds ?? []);
for (const workspace of workspaces) liveTargets.add(workspace.id);
liveTargets.add(scopeTargetId);
if (canRebindChat(session, liveTargets)) {
rebindSessionScope?.(target.sessionId, { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds });
toast.success(t('ai.chat.rebindDone').replace('{title}', target.title));
} else {
toast.warning(t('ai.chat.rebindUsage'));
}
// Do not rebuild /chats from this render's stale sessions (still dangling).
// Drop the resolved entry from the stored listing so a repeat /rebind <n>
// cannot target it; the next explicit /chats refreshes from live state.
const remaining = lastChatsListingRef.current.filter(
(entry) => entry.sessionId !== target.sessionId,
);
let danglingCounter = 0;
for (const entry of remaining) {
if (!entry.isDangling) {
entry.rebindNumber = undefined;
continue;
}
danglingCounter += 1;
entry.rebindNumber = danglingCounter;
}
lastChatsListingRef.current = remaining;
setPanelCommandNote(formatAiChatsListingNote(remaining, t));
}, [liveScopeTargetIds, rebindSessionScope, scopeHostIds, scopeTargetId, scopeType, sessions, t, workspaces, workspaceMemberTerminalIds]);
// Manual durability actions (history drawer header). Export writes the
// whole store to a user-chosen JSON file; import merges by id.
@@ -2138,6 +2192,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
onRenameSession={handleRenameSession}
onRenameActiveChat={handleRenameSlashCommand}
onListChats={handleListChats}
onRebindChat={handleRebindChat}
panelCommandNote={panelCommandNote}
onDismissPanelCommandNote={() => setPanelCommandNote(null)}
onExportSessions={handleExportSessions}
@@ -2220,6 +2275,9 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
'createSession',
'deleteSession',
'updateSessionTitle',
'rebindSessionScope',
'exportSessionsBackup',
'importSessionsBackup',
'updateSessionExternalSessionId',
'addMessageToSession',
'updateLastMessage',
@@ -2270,6 +2328,8 @@ export function aiChatSidePanelPropsAreEqual(
if ((prev.isVisible ?? true) !== (next.isVisible ?? true)) return false;
if (prev.scopeHostIds !== next.scopeHostIds) return false;
if (prev.terminalSessions !== next.terminalSessions) return false;
if (prev.liveScopeTargetIds !== next.liveScopeTargetIds) return false;
if (prev.workspaces !== next.workspaces) return false;
if (prev.resolveExecutorContext !== next.resolveExecutorContext) return false;
if (prev.notes !== next.notes) return false;
if (prev.hosts !== next.hosts) return false;
+4
View File
@@ -44,6 +44,8 @@ export interface AIChatSidePanelProps {
) => Promise<{ sessionId: string; restored: { text: string; attachments: import('../infrastructure/ai/types').UploadedFile[]; selectedUserSkillSlugs: string[] } } | null>;
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
/** Issue #4015 R2: rebind a dangling chat to this panel's scope (panel action, never agent content). */
rebindSessionScope?: (sessionId: string, scope: { type: 'terminal' | 'workspace'; targetId: string; hostIds?: string[] }) => void;
/** Manual durability escape hatch: export the store to a JSON file (returns the path, '' when fixed-path). */
exportSessionsBackup?: () => Promise<string | null>;
/** Import sessions from a JSON export; merges by id. Returns the imported count. */
@@ -109,6 +111,8 @@ export interface AIChatSidePanelProps {
focusedSessionId?: string;
/** Live workspaces, used to resolve workspace titles for `/chats`. */
workspaces?: Array<{ id: string; title: string }>;
/** All currently live terminal (and workspace) scope target ids. */
liveScopeTargetIds?: Set<string>;
// Terminal session context (from parent)
terminalSessions?: Array<{
+23 -3
View File
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import ChatInput, { replaceSlashQueryWithRename } from './ChatInput';
import ChatInput, { replaceSlashQueryWithCommand, replaceSlashQueryWithRename } from './ChatInput';
import {
CHAT_INPUT_MAX_HEIGHT,
CHAT_INPUT_MIN_HEIGHT,
@@ -230,13 +230,33 @@ test('selecting rename completes a partial slash query without sending', () => {
assert.doesNotMatch(branch, /onSend/);
});
test('selecting rebind completes a partial slash query without sending', () => {
assert.equal(replaceSlashQueryWithCommand('/re', { start: 0, end: 3 }, 'rebind'), '/rebind ');
assert.equal(replaceSlashQueryWithCommand('/', { start: 0, end: 1 }, 'rebind'), '/rebind ');
assert.equal(replaceSlashQueryWithCommand('before /re after', { start: 7, end: 10 }, 'rebind'), 'before /rebind after');
assert.equal(replaceSlashQueryWithCommand('old text', null, 'rebind'), '/rebind ');
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
const branch = source.slice(source.indexOf("if (command === 'rebind')"), source.indexOf("if (command === 'rename')"));
assert.match(branch, /commitComposerText\(replaceSlashQueryWithCommand\(readComposerText\(\), slashRange, 'rebind'\)\)/);
assert.match(branch, /closeAllMenus\(\);[\s\S]*?return;/);
assert.doesNotMatch(branch, /onSend/);
});
test('slash picker system commands clear the local composer', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
// /stop falls through to the shared clear; /rename and /chats have their own
// branches above it (rename keeps the composer so the user can type a title).
// /stop falls through to the shared clear; /rename, /chats and /rebind have
// their own branches above it (rename/rebind keep the composer so the user
// can type an argument).
assert.match(source, /if \(command === 'stop'\) onStop\?\.\(\);[\s\S]*?commitComposerText\(''\);/);
assert.match(source, /if \(command === 'chats'\) onListChats\?\.\(\);/);
assert.match(source, /if \(command === 'rename'\) \{[\s\S]*?closeAllMenus\(\);[\s\S]*?return;/);
assert.match(source, /if \(command === 'rebind'\) \{[\s\S]*?closeAllMenus\(\);[\s\S]*?return;/);
});
test('/rebind routes its argument to onRebindChat as a panel action', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /if \(systemCommand === 'rebind'\) \{[\s\S]*?onRebindChat\?\.\(getRebindSlashCommandArg\(submittedText\)\);/);
});
test('keeps thinking on a separate chip instead of mixing it into the model list', () => {
+28 -2
View File
@@ -33,6 +33,7 @@ import {
filterQuickMessages,
filterSystemSlashCommands,
filterUserSkillsForSlash,
getRebindSlashCommandArg,
getRenameSlashCommandTitle,
getSlashCommandItemKey,
getSystemSlashCommand,
@@ -81,8 +82,17 @@ const MENU_VIEWPORT_GUTTER = 8;
const CONTEXT_USAGE_RING_RADIUS = 10;
const CONTEXT_USAGE_RING_CIRCUMFERENCE = 2 * Math.PI * CONTEXT_USAGE_RING_RADIUS;
export function replaceSlashQueryWithCommand(
text: string,
range: { start: number; end: number } | null,
command: string,
): string {
const replacement = `/${command} `;
return range ? `${text.slice(0, range.start)}${replacement}${text.slice(range.end)}` : replacement;
}
export function replaceSlashQueryWithRename(text: string, range: { start: number; end: number } | null): string {
return range ? `${text.slice(0, range.start)}/rename ${text.slice(range.end)}` : '/rename ';
return replaceSlashQueryWithCommand(text, range, 'rename');
}
function formatContextTokens(tokens: number): string {
@@ -275,6 +285,8 @@ interface ChatInputProps {
onRenameSession?: (title: string) => void;
/** `/chats`: list the chats visible to this panel (panel action, never sent to the agent). */
onListChats?: () => void;
/** `/rebind <n>`: rebind a dangling chat to this panel's scope (panel action, never sent to the agent). */
onRebindChat?: (arg: string) => void;
}
const ChatInput: React.FC<ChatInputProps> = ({
@@ -324,6 +336,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
parked = false,
onRenameSession,
onListChats,
onRebindChat,
}) => {
const { t } = useI18n();
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection || file.vaultNoteId);
@@ -762,6 +775,14 @@ const ChatInput: React.FC<ChatInputProps> = ({
}
if (command === 'stop') onStop?.();
if (command === 'chats') onListChats?.();
if (command === 'rebind') {
// Replace a partial slash query (e.g. `/re`) with `/rebind ` so the
// user can finish the dangling-chat number; never leave `/re 1` to
// be sent to the agent.
commitComposerText(replaceSlashQueryWithCommand(readComposerText(), slashRange, 'rebind'));
closeAllMenus();
return;
}
if (command === 'rename') {
commitComposerText(replaceSlashQueryWithRename(readComposerText(), slashRange));
closeAllMenus();
@@ -989,6 +1010,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
commitComposerText('');
return;
}
if (systemCommand === 'rebind') {
onRebindChat?.(getRebindSlashCommandArg(submittedText));
commitComposerText('');
return;
}
return;
}
if (isStreaming && canSteer) {
@@ -999,7 +1025,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
// abort; the parent clears value only after the user message is accepted.
onSend();
},
[canCompact, canSteer, commitComposerText, isStreaming, onCompact, onListChats, onRenameSession, onSend, onSteer, onStop, readComposerText],
[canCompact, canSteer, commitComposerText, isStreaming, onCompact, onListChats, onRebindChat, onRenameSession, onSend, onSteer, onStop, readComposerText],
);
const status: PromptInputStatus = isStreaming ? 'streaming' : 'idle';
+65 -1
View File
@@ -4,11 +4,22 @@ 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> = {},
@@ -67,6 +78,21 @@ test('/chats listing marks the live workspace chat, terminal chat and dangling c
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' } }),
@@ -95,11 +121,49 @@ test('/chats note renders header, current marker and one line per chat', () => {
const note = formatAiChatsListingNote(entries, t);
const lines = note.split('\n');
assert.equal(lines[0], 'ai.chat.chatsHeader');
assert.equal(lines.length, 4);
// 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', () => {
+44 -2
View File
@@ -29,6 +29,8 @@ export interface AiChatsListingEntry {
scopeLabel: string;
/** Scope target no longer resolves to a live workspace/terminal. */
isDangling: boolean;
/** 1-based number among dangling entries, used by `/rebind <n>`. */
rebindNumber?: number;
isCurrent: boolean;
relativeTime: string;
}
@@ -62,7 +64,7 @@ export function buildAiChatsListingEntries(input: AiChatsListingInput): AiChatsL
const workspaceTitles = new Map(workspaces.map((workspace) => [workspace.id, workspace.title]));
const liveTerminals = new Set(liveTerminalIds);
return sessions.map((session) => {
const entries: AiChatsListingEntry[] = sessions.map((session) => {
const isCurrent = session.id === activeSessionId;
let scopeLabel = '';
let isDangling = false;
@@ -102,6 +104,38 @@ export function buildAiChatsListingEntries(input: AiChatsListingInput): AiChatsL
relativeTime: formatRelativeTime(new Date(session.updatedAt), t),
};
});
// Number the dangling entries so the note can advertise `/rebind <n>`.
let danglingCounter = 0;
for (const entry of entries) {
if (!entry.isDangling) continue;
danglingCounter += 1;
entry.rebindNumber = danglingCounter;
}
return entries;
}
/**
* Resolve a `/rebind <n>` argument against a `/chats` listing. Only dangling
* entries are rebindable; a non-dangling target (or a bad index) yields null.
*/
export function resolveRebindTargetFromArg(
arg: string,
entries: AiChatsListingEntry[],
): AiChatsListingEntry | null {
const trimmed = arg.trim();
if (!/^\d+$/.test(trimmed)) return null;
const index = Number.parseInt(trimmed, 10);
if (!Number.isInteger(index) || index < 1) return null;
const dangling = entries.filter((entry) => entry.isDangling);
return dangling[index - 1] ?? null;
}
/** Revalidate a cached listing entry against the current session and live scopes. */
export function canRebindChat(session: AISession | undefined, liveScopeTargetIds: ReadonlySet<string>): boolean {
if (!session) return false;
return !!session.scopeDangling || !session.scope.targetId || !liveScopeTargetIds.has(session.scope.targetId);
}
export function formatAiChatsListingNote(
@@ -111,9 +145,17 @@ export function formatAiChatsListingNote(
if (entries.length === 0) {
return t('ai.chat.chatsEmpty');
}
let hasDangling = false;
const lines = entries.map((entry) => {
const marker = entry.isCurrent ? '•' : ' ';
return `${marker} ${entry.title} — ${entry.agentLabel} · ${entry.scopeLabel} · ${entry.relativeTime}`;
const rebindHint = entry.isDangling && entry.rebindNumber != null
? ` · /rebind ${entry.rebindNumber}`
: '';
if (entry.isDangling) hasDangling = true;
return `${marker} ${entry.title} — ${entry.agentLabel} · ${entry.scopeLabel} · ${entry.relativeTime}${rebindHint}`;
});
if (hasDangling) {
lines.push(t('ai.chat.chatsRebindHint'));
}
return [t('ai.chat.chatsHeader'), ...lines].join('\n');
}
@@ -283,6 +283,8 @@ interface AIChatPanelsHostProps {
mountedTabIds: string[];
activeTabId: string | null;
activeSidePanelTab: SidePanelTab | null;
/** All live terminal+workspace target ids (for /chats dangling detection). */
liveScopeTargetIds: Set<string>;
contextsByTabId: Map<string, AIPanelContext>;
resolveExecutorContext: (scope: {
type: 'terminal' | 'workspace';
@@ -452,6 +454,7 @@ function aiChatPanelsHostAreEqual(
next: AIChatPanelsHostProps,
): boolean {
if (prev.mountedTabIds !== next.mountedTabIds) return false;
if (prev.liveScopeTargetIds !== next.liveScopeTargetIds) return false;
if (prev.contextsByTabId !== next.contextsByTabId) return false;
if (prev.activeSidePanelTab !== next.activeSidePanelTab) return false;
if (prev.pendingTerminalSelection !== next.pendingTerminalSelection) return false;
@@ -492,6 +495,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
mountedTabIds,
activeTabId,
activeSidePanelTab,
liveScopeTargetIds,
contextsByTabId,
resolveExecutorContext,
pendingTerminalSelection,
@@ -596,6 +600,9 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
undoLastTurnInSession={aiConfig.undoLastTurnInSession}
deleteSession={aiConfig.deleteSession}
updateSessionTitle={aiConfig.updateSessionTitle}
rebindSessionScope={aiConfig.rebindSessionScope}
exportSessionsBackup={aiConfig.exportSessionsBackup}
importSessionsBackup={aiConfig.importSessionsBackup}
updateSessionExternalSessionId={aiConfig.updateSessionExternalSessionId}
addMessageToSession={aiConfig.addMessageToSession}
updateLastMessage={aiConfig.updateLastMessage}
@@ -631,6 +638,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
focusedSessionId={context.focusedSessionId}
workspaces={aiPanelWorkspaces}
terminalSessions={context.terminalSessions}
liveScopeTargetIds={liveScopeTargetIds}
resolveExecutorContext={resolveExecutorContext}
isVisible={isVisible}
notes={notes}
@@ -654,6 +654,7 @@ function SidePanelAiSlotInner({
mountedTabIds={mountedAiTabIds}
activeTabId={activeTabId}
activeSidePanelTab={isVisible ? 'ai' : null}
liveScopeTargetIds={validAIScopeTargetIds}
contextsByTabId={aiContextsByTabId}
resolveExecutorContext={resolveAIExecutorContext}
pendingTerminalSelection={pendingTerminalSelectionForAI}
+10
View File
@@ -10,6 +10,16 @@ export type SplitHint = {
targetSessionId?: string;
};
export const invertSplitPosition = (position: SplitPosition): SplitPosition => {
switch (position) {
case 'left': return 'right';
case 'right': return 'left';
case 'top': return 'bottom';
case 'bottom': return 'top';
}
};
export const pruneWorkspaceNode = (node: WorkspaceNode, targetSessionId: string): WorkspaceNode | null => {
if (node.type === 'pane') {
return node.sessionId === targetSessionId ? null : node;
+18 -2
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
buildSlashCommandItems,
filterQuickMessages,
getRebindSlashCommandArg,
getRenameSlashCommandTitle,
getSystemSlashCommand,
isValidQuickMessageSlug,
@@ -102,11 +103,26 @@ test('getRenameSlashCommandTitle extracts the argument and trims it', () => {
assert.equal(getRenameSlashCommandTitle('/rename '), '');
});
test('getSystemSlashCommand recognizes /rebind with an argument', () => {
assert.equal(getSystemSlashCommand('/rebind'), 'rebind');
assert.equal(getSystemSlashCommand('/rebind 2'), 'rebind');
assert.equal(getSystemSlashCommand(' /Rebind 1 '), 'rebind');
assert.equal(getSystemSlashCommand('/rebindx'), null);
assert.equal(getSystemSlashCommand('/re'), null);
});
test('getRebindSlashCommandArg extracts the dangling entry number', () => {
assert.equal(getRebindSlashCommandArg('/rebind 2'), '2');
assert.equal(getRebindSlashCommandArg(' /REBIND 1 '), '1');
assert.equal(getRebindSlashCommandArg('/rebind'), '');
assert.equal(getRebindSlashCommandArg('/rebind '), '');
});
test('buildSlashCommandItems reserves system slugs over quick messages and skills', () => {
const emptyQuery = buildSlashCommandItems([], [], '', true);
assert.deepEqual(
emptyQuery.filter((item) => item.kind === 'system').map((item) => item.kind === 'system' ? item.command.slug : ''),
['compact', 'stop', 'rename', 'chats'],
['compact', 'stop', 'rename', 'chats', 'rebind'],
);
const withCollision = buildSlashCommandItems(
@@ -121,7 +137,7 @@ test('buildSlashCommandItems reserves system slugs over quick messages and skill
: item.kind === 'quickMessage' ? `qm:${item.message.slug}`
: `sk:${item.skill.slug}`
)),
['sys:compact', 'sys:stop', 'sys:rename', 'sys:chats'],
['sys:compact', 'sys:stop', 'sys:rename', 'sys:chats', 'sys:rebind'],
);
});
+12 -1
View File
@@ -17,7 +17,7 @@ export interface UserSkillSlashOption {
description: string;
}
export type SystemSlashCommandSlug = 'stop' | 'compact' | 'rename' | 'chats';
export type SystemSlashCommandSlug = 'stop' | 'compact' | 'rename' | 'chats' | 'rebind';
export interface SystemSlashCommand {
slug: SystemSlashCommandSlug;
@@ -126,6 +126,7 @@ export const SYSTEM_BUILTIN_SLASH_COMMANDS: SystemSlashCommand[] = [
{ slug: 'stop', name: 'Stop', descriptionKey: 'ai.chat.slashStopDesc' },
{ slug: 'rename', name: 'Rename chat', descriptionKey: 'ai.chat.slashRenameDesc' },
{ slug: 'chats', name: 'List chats', descriptionKey: 'ai.chat.slashChatsDesc' },
{ slug: 'rebind', name: 'Rebind chat', descriptionKey: 'ai.chat.slashRebindDesc' },
];
export function filterSystemSlashCommands(
@@ -206,9 +207,19 @@ export function getSystemSlashCommand(input: string): SystemSlashCommandSlug | n
if (trimmed === '/compact') return 'compact';
if (/^\/rename(?:\s|$)/.test(trimmed)) return 'rename';
if (trimmed === '/chats') return 'chats';
if (/^\/rebind(?:\s|$)/.test(trimmed)) return 'rebind';
return null;
}
/**
* Extracts the argument from a `/rebind <n>` submission (the 1-based number
* of a dangling entry in the last `/chats` listing). Returns the empty string
* when no argument was provided (the caller shows a usage hint).
*/
export function getRebindSlashCommandArg(input: string): string {
return input.trim().replace(/^\/rebind\s*/i, '').trim();
}
/**
* Extracts the new title from a `/rename <title>` submission. Returns the
* empty string when no title was provided (the caller shows a usage hint).
+7
View File
@@ -231,6 +231,13 @@ export interface AISession {
title: string;
agentId: string;
scope: AISessionScope;
/**
* The scope target (terminal/workspace) this chat is bound to no longer
* exists. The record and its active pointer stay intact so `/chats` can
* list the chat and offer a rebind; the flag clears when the chat is
* rebound or its target becomes live again.
*/
scopeDangling?: boolean;
messages: ChatMessage[];
contextCompaction?: AISessionContextCompaction;
externalSessionId?: string;