Compare commits

...
Author SHA1 Message Date
bincxz 832c221aff fix(ai): preserve resumed terminal chats after workspace merge 2026-08-31 14:53:16 +08:00
5 changed files with 113 additions and 7 deletions

No files matched your search

@@ -333,7 +333,7 @@ test('AI side panel skips re-render when only the composer draft text changes',
assert.equal(aiChatSidePanelPropsAreEqual(prev, next), true);
});
test('workspace AI panel memo follows visible inherited session, not hidden terminal maps', () => {
test('workspace AI panel memo follows visible inherited session, not nonmember terminal maps', () => {
const visibleMemberChat = session({
id: 'chat-visible',
scope: { type: 'terminal', targetId: 'terminal-b', hostIds: ['host-b'] },
@@ -364,7 +364,7 @@ test('workspace AI panel memo follows visible inherited session, not hidden term
},
],
activeSessionIdMap: {
'terminal:terminal-a': 'chat-hidden',
'terminal:terminal-outside': 'chat-hidden',
'terminal:terminal-b': 'chat-visible',
},
sessions: [visibleMemberChat, hiddenFocusedChat],
@@ -380,3 +380,39 @@ test('workspace AI panel memo follows visible inherited session, not hidden term
false,
);
});
test('merged AI panel displays a member chat resumed from an older terminal', () => {
const resumed = session({
id: 'resumed-chat',
scope: { type: 'terminal', targetId: 'closed-terminal', hostIds: ['host-a'] },
messages: [{ id: 'm1', role: 'user', content: 'keep-resumed-conversation', timestamp: 1 }],
});
const props = baseProps({
isVisible: true,
scopeType: 'workspace',
scopeTargetId: 'merged',
focusedSessionId: 'terminal-a',
scopeHostIds: ['host-a'],
terminalSessions: [{ sessionId: 'terminal-a', hostId: 'host-a', hostname: 'a', label: 'A', connected: true }],
sessions: [resumed],
activeSessionIdMap: { 'terminal:terminal-a': resumed.id },
});
const render = (panelProps: AIChatSidePanelProps) => renderToStaticMarkup(
React.createElement(I18nProvider, { locale: 'en' },
React.createElement(TooltipProvider, null,
React.createElement(AIChatSidePanel, panelProps))),
);
// Check both the first workspace paint and the explicit merge handoff.
assert.match(render(props), /keep-resumed-conversation/);
assert.match(render({
...props,
activeSessionIdMap: { ...props.activeSessionIdMap, 'workspace:merged': resumed.id },
panelViewByScope: { 'workspace:merged': { mode: 'session', sessionId: resumed.id } },
}), /keep-resumed-conversation/);
assert.equal(aiChatSidePanelPropsAreEqual(props, {
...props,
sessions: [{ ...resumed, messages: [...resumed.messages, {
id: 'm2', role: 'assistant', content: 'continued response', timestamp: 2,
}] }],
}), false);
});
+3 -1
View File
@@ -374,9 +374,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
scopeHostIds,
activeTerminalSessionIds,
workspaceMemberTerminalIds,
activeSessionIdMap,
),
),
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds, workspaceMemberTerminalIds],
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds, workspaceMemberTerminalIds, activeSessionIdMap],
);
const explicitPanelView = panelViewByScope[scopeKey];
@@ -1850,6 +1851,7 @@ export function aiChatSidePanelPropsAreEqual(
props.scopeHostIds,
activeTerminalSessionIds,
workspaceMemberTerminalIds,
props.activeSessionIdMap,
).map((session) => session.id),
);
return resolveInheritedAIActiveSessionId({
@@ -173,3 +173,52 @@ test("terminal scoped history cache has a hard LRU bound", () => {
}
assert.ok(_getScopedHistoryCacheSizeForTests(sessions) <= 64);
});
test("workspace history retains chats resumed by members from older terminals", () => {
const resumed = createSession("resumed", {
type: "terminal", targetId: "closed-terminal", hostIds: ["host-a"],
}, 1);
const unrelated = createSession("unrelated", {
type: "terminal", targetId: "another-closed-terminal", hostIds: ["host-a"],
}, 2);
const staleWorkspace = createSession("stale-workspace", {
type: "workspace", targetId: "old-workspace",
}, 99);
const sessions = [staleWorkspace, unrelated, resumed];
const members = new Set(["terminal-a", "terminal-b"]);
const selected = {
"terminal:terminal-a": "resumed",
"terminal:terminal-outside": "unrelated",
};
assert.ok(getScopedHistorySessions(
sessions, "terminal", "terminal-a", ["host-a"], new Set(["unrelated"]),
).includes(resumed));
assert.deepEqual(getScopedHistorySessions(
sessions, "workspace", "merged", ["host-a"], new Set(Object.values(selected)),
members, selected,
), [resumed, staleWorkspace]);
// Returning to A makes the same stored conversation available again.
assert.ok(getScopedHistorySessions(
sessions, "terminal", "terminal-a", ["host-a"], new Set(["unrelated"]),
).includes(resumed));
assert.equal(resumed.scope.targetId, "closed-terminal");
});
test("workspace history cache tracks member selections but ignores unrelated selections", () => {
const sessions = [createSession("resumed", { type: "terminal", targetId: "closed" }, 1)];
const members = new Set(["terminal-a"]);
const history = (selected: Record<string, string | null>) => getScopedHistorySessions(
sessions, "workspace", "merged", undefined, new Set(), members, selected,
);
const empty = history({});
assert.deepEqual(empty, []);
const inherited = history({ "terminal:terminal-a": "resumed" });
assert.deepEqual(inherited, sessions);
assert.equal(history({
"terminal:terminal-a": "resumed", "terminal:outside": "other",
}), inherited);
assert.equal(history({ "terminal:terminal-a": null }), empty);
assert.equal(history({ "workspace:unrelated": "resumed" }), empty);
assert.equal(_getScopedHistoryCacheSizeForTests(sessions), 2);
});
+16 -1
View File
@@ -11,6 +11,7 @@ function buildHistoryCacheKey(
scopeHostIds: string[] | undefined,
activeTerminalSessionIds: Set<string>,
workspaceMemberTerminalIds: Set<string> | undefined,
workspaceMemberActiveSessionIds: Set<string>,
): HistoryCacheKey {
const hostKey = scopeHostIds ? [...scopeHostIds].sort().join(',') : '';
const terminalKey = scopeType === 'terminal'
@@ -19,7 +20,8 @@ function buildHistoryCacheKey(
const memberKey = scopeType === 'workspace' && workspaceMemberTerminalIds
? [...workspaceMemberTerminalIds].sort().join(',')
: '';
return `${scopeType}:${scopeTargetId ?? ''}:${hostKey}:${terminalKey}:${memberKey}`;
const memberActiveKey = [...workspaceMemberActiveSessionIds].sort().join(',');
return `${scopeType}:${scopeTargetId ?? ''}:${hostKey}:${terminalKey}:${memberKey}:${memberActiveKey}`;
}
export function getScopedHistorySessions(
@@ -29,7 +31,18 @@ export function getScopedHistorySessions(
scopeHostIds: string[] | undefined,
activeTerminalSessionIds: Set<string>,
workspaceMemberTerminalIds?: Set<string>,
activeSessionIdMap?: Readonly<Record<string, string | null | undefined>>,
): AISession[] {
// A member can be continuing history created on an older terminal. Its
// selected chat belongs in workspace history even though scope.targetId
// still identifies that older terminal. Do not include nonmember selections.
const workspaceMemberActiveSessionIds = new Set<string>();
if (scopeType === 'workspace' && workspaceMemberTerminalIds && activeSessionIdMap) {
for (const terminalId of workspaceMemberTerminalIds) {
const sessionId = activeSessionIdMap[`terminal:${terminalId}`];
if (sessionId) workspaceMemberActiveSessionIds.add(sessionId);
}
}
let scopeCache = historyCache.get(sessions);
if (!scopeCache) {
scopeCache = new Map();
@@ -42,6 +55,7 @@ export function getScopedHistorySessions(
scopeHostIds,
activeTerminalSessionIds,
workspaceMemberTerminalIds,
workspaceMemberActiveSessionIds,
);
const cached = scopeCache.get(cacheKey);
if (cached) {
@@ -60,6 +74,7 @@ export function getScopedHistorySessions(
scopeHostIds,
activeTerminalSessionIds,
workspaceMemberTerminalIds,
workspaceMemberActiveSessionIds,
),
}))
.filter(({ matchRank }) => matchRank > 0)
+7 -3
View File
@@ -18,15 +18,19 @@ export function getSessionScopeMatchRank(
* member chats as exact workspace matches keeps history/resume working.
*/
workspaceMemberTerminalIds?: Set<string>,
/** Chats currently selected by workspace members, including resumed history. */
workspaceMemberActiveSessionIds?: Set<string>,
): number {
// After a terminal merge the AI panel flips to workspace scope, but chats
// created on the member terminals remain terminal-scoped. Rank those as
// created or resumed on member terminals remain terminal-scoped. Rank them as
// exact matches so they stay visible and preferred over stale workspaces.
if (
scopeType === "workspace"
&& session.scope.type === "terminal"
&& session.scope.targetId
&& workspaceMemberTerminalIds?.has(session.scope.targetId)
&& (
(session.scope.targetId && workspaceMemberTerminalIds?.has(session.scope.targetId))
|| workspaceMemberActiveSessionIds?.has(session.id)
)
) {
return 3;
}