144 lines
4.1 KiB
TypeScript
144 lines
4.1 KiB
TypeScript
|
|
import type {
|
||
|
|
AIPanelView,
|
||
|
|
AISession,
|
||
|
|
} from "../../infrastructure/ai/types.ts";
|
||
|
|
|
||
|
|
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: "draft" };
|
||
|
|
|
||
|
|
export function panelViewsEqual(
|
||
|
|
left: AIPanelView,
|
||
|
|
right: AIPanelView,
|
||
|
|
): boolean {
|
||
|
|
if (left === right) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
if (left.mode !== right.mode) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (left.mode === "session" && right.mode === "session") {
|
||
|
|
return left.sessionId === right.sessionId;
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface HistorySessionSelectionActions {
|
||
|
|
showSessionView: (sessionId: string) => void;
|
||
|
|
setActiveSessionId: (sessionId: string) => void;
|
||
|
|
closeHistory?: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface DraftEntrySelectionActions {
|
||
|
|
ensureDraft: () => void;
|
||
|
|
showDraftView: () => void;
|
||
|
|
preserveSessionView?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function resolveDisplayedPanelView(
|
||
|
|
panelView: AIPanelView | undefined,
|
||
|
|
hasDraft: boolean,
|
||
|
|
sessions: AISession[],
|
||
|
|
persistedSessionId?: string | null,
|
||
|
|
scopeType: "terminal" | "workspace" = "workspace",
|
||
|
|
): AIPanelView {
|
||
|
|
if (panelView) {
|
||
|
|
return normalizePanelView(panelView, sessions);
|
||
|
|
}
|
||
|
|
|
||
|
|
// New terminal sessions should always start from a blank draft. History is
|
||
|
|
// still available in the drawer, but never auto-resumed into a fresh SSH tab.
|
||
|
|
// Explicit panelView above is the only way a terminal scope shows a session
|
||
|
|
// (e.g. after dissolve handoff writes mode:session).
|
||
|
|
if (scopeType === "terminal") {
|
||
|
|
return DEFAULT_PANEL_VIEW;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Workspace: keep the inherited/persisted active chat when the user starts
|
||
|
|
// typing a follow-up. Merge seed often writes activeSessionIdMap only, with
|
||
|
|
// no explicit panelView — if unsent draft outranked that selection, send
|
||
|
|
// would createSession() while the main area still looked like the old chat.
|
||
|
|
// Explicit "New Chat" clears the active map and writes mode:draft, so it
|
||
|
|
// still wins via the panelView branch above.
|
||
|
|
if (persistedSessionId && sessions.some((s) => s.id === persistedSessionId)) {
|
||
|
|
return { mode: "session", sessionId: persistedSessionId };
|
||
|
|
}
|
||
|
|
|
||
|
|
if (hasDraft) {
|
||
|
|
return DEFAULT_PANEL_VIEW;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (sessions[0]) {
|
||
|
|
return { mode: "session", sessionId: sessions[0].id };
|
||
|
|
}
|
||
|
|
|
||
|
|
return DEFAULT_PANEL_VIEW;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function normalizePanelView(
|
||
|
|
panelView: AIPanelView,
|
||
|
|
sessions: AISession[],
|
||
|
|
): AIPanelView {
|
||
|
|
if (panelView.mode !== "session") {
|
||
|
|
return panelView;
|
||
|
|
}
|
||
|
|
|
||
|
|
return sessions.some((session) => session.id === panelView.sessionId)
|
||
|
|
? panelView
|
||
|
|
: DEFAULT_PANEL_VIEW;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Whether the panel should force `showDraftView` when the normalized view
|
||
|
|
* differs from the explicit store view.
|
||
|
|
*
|
||
|
|
* Explicit session views must stay put while the session is still present in
|
||
|
|
* the same scoped history list that `normalizePanelView` uses — otherwise a
|
||
|
|
* one-frame history lag after draft send demotes the new chat into history
|
||
|
|
* and reopens a blank draft (especially under StrictMode).
|
||
|
|
*
|
||
|
|
* `sessionExists` must NOT consult the global store alone: a session that
|
||
|
|
* exists but is out of this scope's history must demote so the panel does not
|
||
|
|
* stick on a blank draft with a ghost active-map entry.
|
||
|
|
*/
|
||
|
|
export function shouldForceDraftViewSync(
|
||
|
|
explicitPanelView: AIPanelView | undefined,
|
||
|
|
normalizedPanelView: AIPanelView,
|
||
|
|
sessionExists: (sessionId: string) => boolean,
|
||
|
|
): boolean {
|
||
|
|
if (!explicitPanelView || panelViewsEqual(normalizedPanelView, explicitPanelView)) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (explicitPanelView.mode === "session" && sessionExists(explicitPanelView.sessionId)) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function resolveDisplayedSession(
|
||
|
|
panelView: AIPanelView,
|
||
|
|
sessions: AISession[],
|
||
|
|
): AISession | null {
|
||
|
|
if (panelView.mode !== "session") {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return sessions.find((session) => session.id === panelView.sessionId) ?? null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function applyHistorySessionSelection(
|
||
|
|
sessionId: string,
|
||
|
|
actions: HistorySessionSelectionActions,
|
||
|
|
): void {
|
||
|
|
actions.showSessionView(sessionId);
|
||
|
|
actions.setActiveSessionId(sessionId);
|
||
|
|
actions.closeHistory?.();
|
||
|
|
}
|
||
|
|
|
||
|
|
export function applyDraftEntrySelection(
|
||
|
|
actions: DraftEntrySelectionActions,
|
||
|
|
): void {
|
||
|
|
actions.ensureDraft();
|
||
|
|
if (!actions.preserveSessionView) {
|
||
|
|
actions.showDraftView();
|
||
|
|
}
|
||
|
|
}
|