diff --git a/application/i18n/locales/en/ai.ts b/application/i18n/locales/en/ai.ts index 10f04a7a3..80dcbbaa2 100644 --- a/application/i18n/locales/en/ai.ts +++ b/application/i18n/locales/en/ai.ts @@ -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 )', + 'ai.chat.chatsRebindHint': 'Disconnected chats can be restored with /rebind .', + 'ai.chat.rebindUsage': 'Usage: /rebind ', + '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', diff --git a/application/i18n/locales/es/ai.ts b/application/i18n/locales/es/ai.ts index 828145284..88d877506 100644 --- a/application/i18n/locales/es/ai.ts +++ b/application/i18n/locales/es/ai.ts @@ -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 )', + 'ai.chat.chatsRebindHint': 'Los chats desconectados se pueden restaurar con /rebind .', + 'ai.chat.rebindUsage': 'Uso: /rebind ', + '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', diff --git a/application/i18n/locales/ru/ai.ts b/application/i18n/locales/ru/ai.ts index 34b64f643..44fecc942 100644 --- a/application/i18n/locales/ru/ai.ts +++ b/application/i18n/locales/ru/ai.ts @@ -545,6 +545,11 @@ export const ruAiMessages: Messages = { 'ai.chat.chatsScopeTerminal': 'Терминал', 'ai.chat.chatsScopeGlobal': 'Глобально', 'ai.chat.chatsScopeDangling': 'Связь потеряна', + 'ai.chat.slashRebindDesc': 'Привязать потерянный чат к этой панели (/rebind )', + 'ai.chat.chatsRebindHint': 'Потерянные чаты можно восстановить командой /rebind .', + 'ai.chat.rebindUsage': 'Использование: /rebind <номер из /chats>', + 'ai.chat.rebindDone': 'Чат «{title}» привязан к этой панели.', + 'ai.chat.rebindUnavailable': 'В области этой панели нет цели для привязки.', 'ai.chat.justNow': 'Только что', 'ai.chat.minutesAgo': '{n}м назад', 'ai.chat.hoursAgo': '{n}ч назад', diff --git a/application/i18n/locales/zh-CN/ai.ts b/application/i18n/locales/zh-CN/ai.ts index 654be27b5..f9c251314 100644 --- a/application/i18n/locales/zh-CN/ai.ts +++ b/application/i18n/locales/zh-CN/ai.ts @@ -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}小时前', diff --git a/application/i18n/locales/zh-TW/ai.ts b/application/i18n/locales/zh-TW/ai.ts index ba19dced5..71c33e338 100644 --- a/application/i18n/locales/zh-TW/ai.ts +++ b/application/i18n/locales/zh-TW/ai.ts @@ -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}小時前', diff --git a/application/state/aiScopeCleanup.test.ts b/application/state/aiScopeCleanup.test.ts index b9b800122..c453a7a29 100644 --- a/application/state/aiScopeCleanup.test.ts +++ b/application/state/aiScopeCleanup.test.ts @@ -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; - - 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", { diff --git a/application/state/aiScopeCleanup.ts b/application/state/aiScopeCleanup.ts index 0d861da66..f68da5385 100644 --- a/application/state/aiScopeCleanup.ts +++ b/application/state/aiScopeCleanup.ts @@ -6,7 +6,6 @@ import type { type DraftsByScope = Partial>; type PanelViewByScope = Partial>; -type ActiveSessionIdMap = Record; function isInactiveScopedTarget( scopeKey: string, @@ -55,41 +54,6 @@ export function pruneInactiveScopedState( }; } -export function pruneInactiveScopedTransientState( - activeSessionIdMap: ActiveSessionIdMap, - draftsByScope: DraftsByScope, - panelViewByScope: PanelViewByScope, - activeTargetIds: Set, -): { - 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, diff --git a/application/state/aiSessionsBackup.test.ts b/application/state/aiSessionsBackup.test.ts index 146c8ab18..db1400e9b 100644 --- a/application/state/aiSessionsBackup.test.ts +++ b/application/state/aiSessionsBackup.test.ts @@ -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); +}); diff --git a/application/state/aiSessionsBackup.ts b/application/state/aiSessionsBackup.ts index 84d19454c..e00fe5c3e 100644 --- a/application/state/aiSessionsBackup.ts +++ b/application/state/aiSessionsBackup.ts @@ -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; diff --git a/application/state/aiStateSnapshots.test.ts b/application/state/aiStateSnapshots.test.ts index e15d30353..0cd800465 100644 --- a/application/state/aiStateSnapshots.test.ts +++ b/application/state/aiStateSnapshots.test.ts @@ -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' }, + ); +}); diff --git a/application/state/aiStateSnapshots.ts b/application/state/aiStateSnapshots.ts index 48b0663a9..e4955acd9 100644 --- a/application/state/aiStateSnapshots.ts +++ b/application/state/aiStateSnapshots.ts @@ -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) { return activeTargetIds.has(targetId); } +/** Last live terminal/workspace target ids observed by orphan cleanup. */ +export let latestAIActiveTargetIdsSnapshot: Set = new Set(); + export function cleanupOrphanedAISessions(activeTargetIds: Set) { + latestAIActiveTargetIdsSnapshot = activeTargetIds; const currentSessions = latestAISessionsSnapshot - ?? localStorageAdapter.read(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) { 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 = {}; let latestAIDraftUploadGenerationByScopeSnapshot: Record = {}; + +/** + * 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, + sessionId: string, + scopeKey: string, +): Record { + 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; } diff --git a/application/state/useAIState.ts b/application/state/useAIState.ts index 429c96236..35bf34a6b 100644 --- a/application/state/useAIState.ts +++ b/application/state/useAIState.ts @@ -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>(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>(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, diff --git a/application/state/useSessionState.ts b/application/state/useSessionState.ts index 4be68273a..b3122ec99 100644 --- a/application/state/useSessionState.ts +++ b/application/state/useSessionState.ts @@ -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. diff --git a/application/state/useSessionStateWorkspaceIdReuse.test.ts b/application/state/useSessionStateWorkspaceIdReuse.test.ts new file mode 100644 index 000000000..f2b9c7b9c --- /dev/null +++ b/application/state/useSessionStateWorkspaceIdReuse.test.ts @@ -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; + +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; + 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(); + }); +}); diff --git a/components/AIChatPanelContent.tsx b/components/AIChatPanelContent.tsx index 061391f26..0fa90d042 100644 --- a/components/AIChatPanelContent.tsx +++ b/components/AIChatPanelContent.tsx @@ -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 `: 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 = ({ onRenameSession, onRenameActiveChat, onListChats, + onRebindChat, panelCommandNote, onDismissPanelCommandNote, onExportSessions, @@ -435,6 +438,7 @@ export const AIChatPanelContent: React.FC = ({ onCompact={handleCompact} onRenameSession={onRenameActiveChat} onListChats={onListChats} + onRebindChat={onRebindChat} canCompact={canCompact} onSteer={handleSteer} onStop={handleStop} diff --git a/components/AIChatSidePanel.tsx b/components/AIChatSidePanel.tsx index dcd5f274d..bb10a9088 100644 --- a/components/AIChatSidePanel.tsx +++ b/components/AIChatSidePanel.tsx @@ -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 = ({ undoLastTurnInSession, deleteSession, updateSessionTitle, + rebindSessionScope, exportSessionsBackup, importSessionsBackup, updateSessionExternalSessionId, @@ -317,6 +318,7 @@ const AIChatSidePanelActive: React.FC = ({ focusedSessionId, workspaces = [], terminalSessions = [], + liveScopeTargetIds, resolveExecutorContext, isVisible = true, notes = [], @@ -334,6 +336,8 @@ const AIChatSidePanelActive: React.FC = ({ // Inline system-style note above the composer (e.g. `/chats` listing). // Panel-command output, never agent content. const [panelCommandNote, setPanelCommandNote] = useState(null); + // Entries of the last `/chats` listing; `/rebind ` resolves against them. + const lastChatsListingRef = useRef([]); 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 = ({ }, [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 = ({ 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 `: 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 + // 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 = ({ 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; diff --git a/components/AIChatSidePanel.types.ts b/components/AIChatSidePanel.types.ts index c0d93a75f..af6e6388b 100644 --- a/components/AIChatSidePanel.types.ts +++ b/components/AIChatSidePanel.types.ts @@ -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; /** 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; // Terminal session context (from parent) terminalSessions?: Array<{ diff --git a/components/ai/ChatInput.test.tsx b/components/ai/ChatInput.test.tsx index cfc8c57bf..063e5ba8a 100644 --- a/components/ai/ChatInput.test.tsx +++ b/components/ai/ChatInput.test.tsx @@ -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', () => { diff --git a/components/ai/ChatInput.tsx b/components/ai/ChatInput.tsx index e6a8c8b19..3408d3609 100644 --- a/components/ai/ChatInput.tsx +++ b/components/ai/ChatInput.tsx @@ -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 `: rebind a dangling chat to this panel's scope (panel action, never sent to the agent). */ + onRebindChat?: (arg: string) => void; } const ChatInput: React.FC = ({ @@ -324,6 +336,7 @@ const ChatInput: React.FC = ({ 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 = ({ } 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 = ({ commitComposerText(''); return; } + if (systemCommand === 'rebind') { + onRebindChat?.(getRebindSlashCommandArg(submittedText)); + commitComposerText(''); + return; + } return; } if (isStreaming && canSteer) { @@ -999,7 +1025,7 @@ const ChatInput: React.FC = ({ // 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'; diff --git a/components/ai/aiChatsListing.test.ts b/components/ai/aiChatsListing.test.ts index 9a17fca43..407ec8f91 100644 --- a/components/ai/aiChatsListing.test.ts +++ b/components/ai/aiChatsListing.test.ts @@ -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 = {}, @@ -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', () => { diff --git a/components/ai/aiChatsListing.ts b/components/ai/aiChatsListing.ts index 93ba15275..6321153c9 100644 --- a/components/ai/aiChatsListing.ts +++ b/components/ai/aiChatsListing.ts @@ -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 `. */ + 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 `. + let danglingCounter = 0; + for (const entry of entries) { + if (!entry.isDangling) continue; + danglingCounter += 1; + entry.rebindNumber = danglingCounter; + } + + return entries; +} + +/** + * Resolve a `/rebind ` 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): 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'); } diff --git a/components/terminalLayer/TerminalLayerSupport.tsx b/components/terminalLayer/TerminalLayerSupport.tsx index 2ee43dfa7..e8cfdd30f 100644 --- a/components/terminalLayer/TerminalLayerSupport.tsx +++ b/components/terminalLayer/TerminalLayerSupport.tsx @@ -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; contextsByTabId: Map; 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 = ({ mountedTabIds, activeTabId, activeSidePanelTab, + liveScopeTargetIds, contextsByTabId, resolveExecutorContext, pendingTerminalSelection, @@ -596,6 +600,9 @@ const AIChatPanelsHostInner: React.FC = ({ 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 = ({ focusedSessionId={context.focusedSessionId} workspaces={aiPanelWorkspaces} terminalSessions={context.terminalSessions} + liveScopeTargetIds={liveScopeTargetIds} resolveExecutorContext={resolveExecutorContext} isVisible={isVisible} notes={notes} diff --git a/components/terminalLayer/terminalLayerSidePanelSlots.tsx b/components/terminalLayer/terminalLayerSidePanelSlots.tsx index aefd27bef..c6065932e 100644 --- a/components/terminalLayer/terminalLayerSidePanelSlots.tsx +++ b/components/terminalLayer/terminalLayerSidePanelSlots.tsx @@ -654,6 +654,7 @@ function SidePanelAiSlotInner({ mountedTabIds={mountedAiTabIds} activeTabId={activeTabId} activeSidePanelTab={isVisible ? 'ai' : null} + liveScopeTargetIds={validAIScopeTargetIds} contextsByTabId={aiContextsByTabId} resolveExecutorContext={resolveAIExecutorContext} pendingTerminalSelection={pendingTerminalSelectionForAI} diff --git a/domain/workspace.ts b/domain/workspace.ts index f985f3953..e83e43847 100644 --- a/domain/workspace.ts +++ b/domain/workspace.ts @@ -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; diff --git a/infrastructure/ai/quickMessages.test.ts b/infrastructure/ai/quickMessages.test.ts index 4549ee42e..5468ff4f6 100644 --- a/infrastructure/ai/quickMessages.test.ts +++ b/infrastructure/ai/quickMessages.test.ts @@ -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'], ); }); diff --git a/infrastructure/ai/quickMessages.ts b/infrastructure/ai/quickMessages.ts index 3cd5924b6..a73cc0808 100644 --- a/infrastructure/ai/quickMessages.ts +++ b/infrastructure/ai/quickMessages.ts @@ -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 ` 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 ` submission. Returns the * empty string when no title was provided (the caller shows a usage hint). diff --git a/infrastructure/ai/types.ts b/infrastructure/ai/types.ts index 85751df90..0491ede81 100644 --- a/infrastructure/ai/types.ts +++ b/infrastructure/ai/types.ts @@ -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;