Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
import { buildAIScopeKey } from './aiSessionsForScope';
|
|
import { resolveInheritedAIActiveSessionId } from './aiWorkspaceScopeInherit';
|
|
|
|
export type AIActiveSessionIdMap = Readonly<Record<string, string | null | undefined>>;
|
|
|
|
export type AISessionScopeHandoffLike = {
|
|
id: string;
|
|
scope: {
|
|
type: string;
|
|
targetId?: string;
|
|
hostIds?: string[];
|
|
};
|
|
updatedAt?: number;
|
|
};
|
|
|
|
export type AIPanelViewHandoffLike = {
|
|
mode: 'draft' | 'session';
|
|
sessionId?: string;
|
|
};
|
|
|
|
export type SeedWorkspaceAIActiveSessionResult = {
|
|
activeSessionIdMap: Record<string, string | null>;
|
|
panelViewByScope: Record<string, AIPanelViewHandoffLike>;
|
|
panelViewChanged: boolean;
|
|
};
|
|
|
|
/**
|
|
* Seed a brand-new workspace scope from member terminal maps (focused first)
|
|
* so the first paint does not wait on a visible-panel write-back.
|
|
*
|
|
* Also materializes an explicit session panel view so follow-up typing does not
|
|
* fall through to draft-only resolution and create a new chat on send.
|
|
*/
|
|
export function seedWorkspaceAIActiveSessionFromMembers(input: {
|
|
activeSessionIdMap: AIActiveSessionIdMap;
|
|
panelViewByScope?: Readonly<Record<string, AIPanelViewHandoffLike | undefined>>;
|
|
workspaceId: string;
|
|
memberTerminalIds: readonly string[];
|
|
preferredTerminalId?: string | null;
|
|
}): SeedWorkspaceAIActiveSessionResult | null {
|
|
const workspaceKey = buildAIScopeKey('workspace', input.workspaceId);
|
|
const existing = input.activeSessionIdMap[workspaceKey];
|
|
if (typeof existing === 'string' && existing.length > 0) {
|
|
return null;
|
|
}
|
|
|
|
const inherited = resolveInheritedAIActiveSessionId({
|
|
scopeType: 'workspace',
|
|
scopeTargetId: input.workspaceId,
|
|
activeSessionIdMap: input.activeSessionIdMap,
|
|
memberTerminalIds: input.memberTerminalIds,
|
|
preferredTerminalId: input.preferredTerminalId,
|
|
});
|
|
if (!inherited) return null;
|
|
|
|
const previousPanelViewByScope = (
|
|
input.panelViewByScope as Record<string, AIPanelViewHandoffLike> | undefined
|
|
) ?? {};
|
|
const existingPanelView = previousPanelViewByScope[workspaceKey];
|
|
const needsPanelView = !(
|
|
existingPanelView?.mode === 'session'
|
|
&& existingPanelView.sessionId === inherited
|
|
);
|
|
|
|
return {
|
|
activeSessionIdMap: {
|
|
...(input.activeSessionIdMap as Record<string, string | null>),
|
|
[workspaceKey]: inherited,
|
|
},
|
|
panelViewByScope: needsPanelView
|
|
? {
|
|
...previousPanelViewByScope,
|
|
[workspaceKey]: { mode: 'session', sessionId: inherited },
|
|
}
|
|
: previousPanelViewByScope,
|
|
panelViewChanged: needsPanelView,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* When a workspace tab dissolves, copy its active chat onto the preferred
|
|
* surviving terminal before the workspace scope key is pruned. Also remint
|
|
* workspace-scoped chats and the handed-off active chat when it still lives
|
|
* under a different terminal (including one that also survives dissolve),
|
|
* clear that previous terminal selection so ownership filters do not hide the
|
|
* chat, and seed a session panel view so terminal scopes do not fall back to
|
|
* a blank draft.
|
|
*/
|
|
export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoffLike>(input: {
|
|
activeSessionIdMap: AIActiveSessionIdMap;
|
|
sessions: readonly T[];
|
|
workspaceId: string;
|
|
terminalIds: readonly string[];
|
|
preferredTerminalId?: string | null;
|
|
panelViewByScope?: Readonly<Record<string, AIPanelViewHandoffLike | undefined>>;
|
|
}): {
|
|
activeSessionIdMap: Record<string, string | null>;
|
|
sessions: T[];
|
|
panelViewByScope: Record<string, AIPanelViewHandoffLike>;
|
|
changed: boolean;
|
|
} {
|
|
const preferredTerminalId = (
|
|
input.preferredTerminalId
|
|
&& input.terminalIds.includes(input.preferredTerminalId)
|
|
? input.preferredTerminalId
|
|
: input.terminalIds.find(Boolean)
|
|
) ?? null;
|
|
|
|
const workspaceKey = buildAIScopeKey('workspace', input.workspaceId);
|
|
const workspaceActive = input.activeSessionIdMap[workspaceKey];
|
|
const hasWorkspaceActive = typeof workspaceActive === 'string' && workspaceActive.length > 0;
|
|
const previousPanelViewByScope = (
|
|
input.panelViewByScope as Record<string, AIPanelViewHandoffLike> | undefined
|
|
) ?? {};
|
|
|
|
let nextMap: Record<string, string | null> = {
|
|
...(input.activeSessionIdMap as Record<string, string | null>),
|
|
};
|
|
let mapChanged = false;
|
|
|
|
if (preferredTerminalId && hasWorkspaceActive) {
|
|
const terminalKey = buildAIScopeKey('terminal', preferredTerminalId);
|
|
let map = nextMap;
|
|
let changed = false;
|
|
if (map[terminalKey] !== workspaceActive) {
|
|
map = { ...map, [terminalKey]: workspaceActive };
|
|
changed = true;
|
|
}
|
|
// Drop duplicate selections so other terminal scopes do not claim this
|
|
// chat via the activeTerminalSessionIds ownership filter.
|
|
for (const [key, value] of Object.entries(map)) {
|
|
if (!key.startsWith('terminal:') || key === terminalKey) continue;
|
|
if (value !== workspaceActive) continue;
|
|
if (!changed) {
|
|
map = { ...map };
|
|
changed = true;
|
|
}
|
|
map[key] = null;
|
|
}
|
|
if (changed) {
|
|
nextMap = map;
|
|
mapChanged = true;
|
|
}
|
|
}
|
|
|
|
let sessionsChanged = false;
|
|
const nextSessions = input.sessions.map((session) => {
|
|
if (!preferredTerminalId) return session;
|
|
|
|
const isWorkspaceScoped = (
|
|
session.scope.type === 'workspace'
|
|
&& session.scope.targetId === input.workspaceId
|
|
);
|
|
// Inherited member chats stay terminal-scoped under the original pane.
|
|
// Prefer the focused survivor even when that original pane also survives
|
|
// dissolve, otherwise B's history ownership filter excludes A's chat.
|
|
const isInheritedActiveChatOnOtherTerminal = (
|
|
hasWorkspaceActive
|
|
&& session.id === workspaceActive
|
|
&& session.scope.type === 'terminal'
|
|
&& Boolean(session.scope.targetId)
|
|
&& session.scope.targetId !== preferredTerminalId
|
|
);
|
|
if (!isWorkspaceScoped && !isInheritedActiveChatOnOtherTerminal) return session;
|
|
if (session.scope.type === 'terminal' && session.scope.targetId === preferredTerminalId) {
|
|
return session;
|
|
}
|
|
|
|
sessionsChanged = true;
|
|
return {
|
|
...session,
|
|
scope: {
|
|
...session.scope,
|
|
type: 'terminal',
|
|
targetId: preferredTerminalId,
|
|
},
|
|
updatedAt: Date.now(),
|
|
};
|
|
});
|
|
|
|
let panelViewsChanged = false;
|
|
const nextPanelViewByScope: Record<string, AIPanelViewHandoffLike> = {
|
|
...previousPanelViewByScope,
|
|
};
|
|
if (preferredTerminalId && hasWorkspaceActive) {
|
|
const terminalKey = buildAIScopeKey('terminal', preferredTerminalId);
|
|
const workspacePanelView = previousPanelViewByScope[workspaceKey];
|
|
const terminalPanelView = previousPanelViewByScope[terminalKey];
|
|
const nextView: AIPanelViewHandoffLike = (
|
|
workspacePanelView?.mode === 'session'
|
|
&& workspacePanelView.sessionId === workspaceActive
|
|
)
|
|
? workspacePanelView
|
|
: { mode: 'session', sessionId: workspaceActive };
|
|
|
|
if (
|
|
terminalPanelView?.mode !== nextView.mode
|
|
|| (nextView.mode === 'session' && terminalPanelView.sessionId !== nextView.sessionId)
|
|
) {
|
|
nextPanelViewByScope[terminalKey] = nextView;
|
|
panelViewsChanged = true;
|
|
}
|
|
}
|
|
|
|
if (!mapChanged && !sessionsChanged && !panelViewsChanged) {
|
|
return {
|
|
activeSessionIdMap: input.activeSessionIdMap as Record<string, string | null>,
|
|
sessions: input.sessions as T[],
|
|
panelViewByScope: previousPanelViewByScope,
|
|
changed: false,
|
|
};
|
|
}
|
|
|
|
return {
|
|
activeSessionIdMap: nextMap,
|
|
sessions: sessionsChanged ? nextSessions : input.sessions as T[],
|
|
panelViewByScope: panelViewsChanged ? nextPanelViewByScope : previousPanelViewByScope,
|
|
changed: true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* When a still-live workspace loses a member pane whose terminal-scoped chat is
|
|
* the workspace active selection, remint that chat onto a remaining pane so
|
|
* member-history matching keeps it visible. Also clear the departed terminal's
|
|
* active-map entry when it still points at the same chat so detach does not
|
|
* leave two live scopes driving one agent thread.
|
|
*/
|
|
export function retargetWorkspaceActiveChatAfterMemberLoss<T extends AISessionScopeHandoffLike>(input: {
|
|
activeSessionIdMap: AIActiveSessionIdMap;
|
|
sessions: readonly T[];
|
|
workspaceId: string;
|
|
previousMemberTerminalIds: readonly string[];
|
|
currentMemberTerminalIds: readonly string[];
|
|
preferredTerminalId?: string | null;
|
|
}): {
|
|
activeSessionIdMap: Record<string, string | null>;
|
|
sessions: T[];
|
|
changed: boolean;
|
|
} {
|
|
const previousMap = input.activeSessionIdMap as Record<string, string | null>;
|
|
const workspaceKey = buildAIScopeKey('workspace', input.workspaceId);
|
|
const workspaceActive = previousMap[workspaceKey];
|
|
const survivorTerminalIds = new Set(input.currentMemberTerminalIds.filter(Boolean));
|
|
const departedTerminalIds = input.previousMemberTerminalIds.filter(
|
|
(sessionId) => sessionId && !survivorTerminalIds.has(sessionId),
|
|
);
|
|
|
|
let nextMap: Record<string, string | null> = { ...previousMap };
|
|
let mapChanged = false;
|
|
if (typeof workspaceActive === 'string' && workspaceActive.length > 0) {
|
|
for (const departedTerminalId of departedTerminalIds) {
|
|
const departedKey = buildAIScopeKey('terminal', departedTerminalId);
|
|
if (nextMap[departedKey] !== workspaceActive) continue;
|
|
nextMap = { ...nextMap, [departedKey]: null };
|
|
mapChanged = true;
|
|
}
|
|
}
|
|
|
|
if (typeof workspaceActive !== 'string' || workspaceActive.length === 0) {
|
|
return {
|
|
activeSessionIdMap: mapChanged ? nextMap : previousMap,
|
|
sessions: input.sessions as T[],
|
|
changed: mapChanged,
|
|
};
|
|
}
|
|
|
|
if (survivorTerminalIds.size === 0) {
|
|
return {
|
|
activeSessionIdMap: mapChanged ? nextMap : previousMap,
|
|
sessions: input.sessions as T[],
|
|
changed: mapChanged,
|
|
};
|
|
}
|
|
|
|
const preferredTerminalId = (
|
|
input.preferredTerminalId
|
|
&& survivorTerminalIds.has(input.preferredTerminalId)
|
|
? input.preferredTerminalId
|
|
: input.currentMemberTerminalIds.find((id) => survivorTerminalIds.has(id))
|
|
) ?? null;
|
|
if (!preferredTerminalId) {
|
|
return {
|
|
activeSessionIdMap: mapChanged ? nextMap : previousMap,
|
|
sessions: input.sessions as T[],
|
|
changed: mapChanged,
|
|
};
|
|
}
|
|
|
|
const activeSession = input.sessions.find((session) => session.id === workspaceActive);
|
|
if (!activeSession) {
|
|
return {
|
|
activeSessionIdMap: mapChanged ? nextMap : previousMap,
|
|
sessions: input.sessions as T[],
|
|
changed: mapChanged,
|
|
};
|
|
}
|
|
if (activeSession.scope.type !== 'terminal' || !activeSession.scope.targetId) {
|
|
return {
|
|
activeSessionIdMap: mapChanged ? nextMap : previousMap,
|
|
sessions: input.sessions as T[],
|
|
changed: mapChanged,
|
|
};
|
|
}
|
|
if (survivorTerminalIds.has(activeSession.scope.targetId)) {
|
|
return {
|
|
activeSessionIdMap: mapChanged ? nextMap : previousMap,
|
|
sessions: input.sessions as T[],
|
|
changed: mapChanged,
|
|
};
|
|
}
|
|
|
|
return {
|
|
activeSessionIdMap: mapChanged ? nextMap : previousMap,
|
|
sessions: input.sessions.map((session) => (
|
|
session.id !== workspaceActive
|
|
? session
|
|
: {
|
|
...session,
|
|
scope: {
|
|
...session.scope,
|
|
type: 'terminal',
|
|
targetId: preferredTerminalId,
|
|
},
|
|
updatedAt: Date.now(),
|
|
}
|
|
)),
|
|
changed: true,
|
|
};
|
|
}
|