Files
NetMesh/components/ai/aiPanelViewState.ts

144 lines
4.1 KiB
TypeScript
Raw Normal View History

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();
}
}