Files
kandev/apps/web/lib/state/slices/ui/quick-chat-sync.ts
T
2026-08-06 11:16:29 +01:00

251 lines
9.3 KiB
TypeScript

import { getStoredQuickChatNames } from "@/lib/local-storage";
import { isQuickChatSetupSessionId } from "./quick-chat-session";
import type { QuickChatSession, QuickChatState, QuickTerminalTab } from "./types";
/**
* Applies locally stored tab renames over a server-provided session list.
*
* Renames live in localStorage, so the backend task title is only a fallback.
* Boot hydration and runtime resync both go through here so a reload and a
* WebSocket-driven refresh label the same tab the same way.
*/
export function applyStoredQuickChatNames(sessions: QuickChatSession[]): QuickChatSession[] {
const storedNames = getStoredQuickChatNames();
return sessions.map((session) => {
const localName = storedNames[session.sessionId];
return {
...session,
kind: session.kind ?? "chat",
...(localName ? { name: localName } : {}),
};
});
}
/**
* Reconciles one workspace's quick-chat tabs against the server's list.
*
* Quick chats are shared state: they are created and closed from any device,
* but a client only ever learned about them from its own boot payload. Without
* this reconcile, two long-lived clients drift — each keeps tabs the other
* never saw, and keeps tabs whose task the other already deleted.
*
* Membership is the server's call; position is not. Tabs already on screen keep
* their order and new ones are appended, because the server sorts by latest
* activity and that changes as sessions run — adopting it wholesale would
* reshuffle the strip under the user's cursor on every reconnect. The activity
* order still decides the initial restore, when there is nothing on screen yet.
*
* Local-only state is preserved: unstarted "New chat" setup tabs (which have no
* backing task yet) and per-session drafts on tabs that survive.
*/
export function reconcileQuickChatSessions(
state: QuickChatState,
workspaceId: string,
serverSessions: QuickChatSession[],
): QuickChatState {
const otherWorkspaces = state.sessions.filter((session) => session.workspaceId !== workspaceId);
const localSetupTabs = state.sessions.filter(
(session) =>
session.workspaceId === workspaceId && isQuickChatSetupSessionId(session.sessionId),
);
const named = applyStoredQuickChatNames(serverSessions);
const serverById = new Map(named.map((session) => [session.sessionId, session]));
// Tabs still on the server, in the order this client already shows them.
const survivors = state.sessions
.filter(
(session) => session.workspaceId === workspaceId && serverById.has(session.sessionId),
// Client-only fields (e.g. an unsent draft) survive via the spread below.
)
.map((session) => ({ ...session, ...serverById.get(session.sessionId) }));
const survivorIds = new Set(survivors.map((session) => session.sessionId));
const added = named.filter((session) => !survivorIds.has(session.sessionId));
return withValidActiveSession(state, [
...otherWorkspaces,
...survivors,
...added,
...localSetupTabs,
]);
}
/**
* Reconciles one workspace's server-owned terminal descriptors. A local tab
* that is still creating its descriptor is retained briefly so a socket
* reconnect cannot erase it before the POST response lands; every established
* descriptor follows the server list, including deletions from another client.
*/
export function reconcileQuickTerminalTabs(
state: QuickChatState,
workspaceId: string,
serverTabs: QuickTerminalTab[],
): QuickChatState {
const otherWorkspaces = state.terminalTabs.filter((tab) => tab.workspaceId !== workspaceId);
const serverById = new Map(serverTabs.map((tab) => [tab.tabId, tab]));
const pendingLocal = state.terminalTabs.filter(
(tab) =>
tab.workspaceId === workspaceId &&
!serverById.has(tab.tabId) &&
tab.status === "connecting" &&
!tab.sessionId,
);
const nextTabs = [
...otherWorkspaces,
...[...serverTabs].sort((a, b) => a.sequence - b.sequence),
...pendingLocal,
];
const validIds = new Set(nextTabs.map((tab) => tab.tabId));
const lastTerminalTabIdByWorkspace = { ...state.lastTerminalTabIdByWorkspace };
for (const [workspace, tabId] of Object.entries(lastTerminalTabIdByWorkspace)) {
if (!validIds.has(tabId)) delete lastTerminalTabIdByWorkspace[workspace];
}
const next: QuickChatState = {
...state,
terminalTabs: nextTabs,
lastTerminalTabIdByWorkspace,
};
if (next.activeKind !== "terminal") return next;
const active = nextTabs.find((tab) => tab.tabId === next.activeTerminalTabId);
if (active) {
next.lastTerminalTabIdByWorkspace[active.workspaceId] = active.tabId;
return next;
}
const previousWorkspaceId =
state.terminalTabs.find((tab) => tab.tabId === state.activeTerminalTabId)?.workspaceId ??
workspaceId;
const replacement = nextTabs
.filter((tab) => tab.workspaceId === previousWorkspaceId)
.sort((a, b) => a.sequence - b.sequence)[0];
if (replacement) {
next.activeTerminalTabId = replacement.tabId;
next.lastTerminalTabIdByWorkspace[replacement.workspaceId] = replacement.tabId;
return next;
}
const conversation = next.sessions.find((session) => session.workspaceId === previousWorkspaceId);
next.activeKind = "conversation";
next.activeTerminalTabId = null;
next.activeSessionId = conversation?.sessionId ?? null;
next.isOpen = conversation ? next.isOpen : false;
return next;
}
/**
* Adds or updates a single quick-chat tab observed on the wire.
*
* Passive by design: it never changes which tab is active or opens the modal,
* because the event describes something the user did on another device.
*/
export function upsertQuickChatSession(
state: QuickChatState,
session: QuickChatSession,
): QuickChatState {
const [named] = applyStoredQuickChatNames([session]);
const index = state.sessions.findIndex((item) => item.sessionId === named.sessionId);
if (index === -1) {
return { ...state, sessions: [...state.sessions, named] };
}
const sessions = [...state.sessions];
sessions[index] = { ...sessions[index], ...named };
return { ...state, sessions };
}
/** Drops the tabs backed by a task that no longer exists. */
export function removeQuickChatSessionsForTask(
state: QuickChatState,
taskId: string,
): QuickChatState {
const remaining = state.sessions.filter((session) => session.taskId !== taskId);
if (remaining.length === state.sessions.length) return state;
return withValidActiveSession(state, remaining);
}
/**
* Re-points `activeSessionId` when the tab it named is gone, mirroring what a
* local tab close does: fall back to another tab in the same workspace, and
* close the modal only when nothing is left to show.
*/
function preserveTerminalSelection(
state: QuickChatState,
sessions: QuickChatSession[],
): QuickChatState | null {
if (state.activeKind !== "terminal") return null;
const active = state.terminalTabs.find((tab) => tab.tabId === state.activeTerminalTabId);
if (active) return { ...state, sessions };
const previousWorkspaceId =
Object.entries(state.lastTerminalTabIdByWorkspace).find(
([, tabId]) => tabId === state.activeTerminalTabId,
)?.[0] ??
state.terminalTabs.find((tab) => tab.tabId === state.activeTerminalTabId)?.workspaceId;
const terminal = state.terminalTabs.find(
(tab) => !previousWorkspaceId || tab.workspaceId === previousWorkspaceId,
);
if (!terminal) {
const conversation =
sessions.find((session) => session.workspaceId === previousWorkspaceId) ??
sessions.find((session) => session.sessionId === state.activeSessionId) ??
sessions[0];
return {
...state,
sessions,
activeKind: "conversation",
activeTerminalTabId: null,
activeSessionId: conversation?.sessionId ?? null,
isOpen: conversation ? state.isOpen : false,
};
}
return {
...state,
sessions,
activeTerminalTabId: terminal.tabId,
lastTerminalTabIdByWorkspace: {
...state.lastTerminalTabIdByWorkspace,
[terminal.workspaceId]: terminal.tabId,
},
};
}
function withValidActiveSession(
state: QuickChatState,
sessions: QuickChatSession[],
): QuickChatState {
const terminalSelection = preserveTerminalSelection(state, sessions);
if (terminalSelection) return terminalSelection;
const active = state.activeSessionId;
// No tab was ever selected: leave it unset rather than silently promoting one
// on a background resync. Same invariant `hydrateUI` guards on.
if (!active) return { ...state, sessions };
if (sessions.some((session) => session.sessionId === active)) {
return { ...state, sessions };
}
const previousWorkspaceId =
state.sessions.find((session) => session.sessionId === active)?.workspaceId ??
state.terminalTabs.find((tab) => tab.tabId === state.activeTerminalTabId)?.workspaceId;
const fallback =
sessions.find((session) => session.workspaceId === previousWorkspaceId) ?? sessions[0];
const terminalFallback = state.terminalTabs.find(
(tab) => tab.workspaceId === previousWorkspaceId,
);
if (!fallback && terminalFallback) {
return {
...state,
sessions,
activeKind: "terminal",
activeTerminalTabId: terminalFallback.tabId,
isOpen: state.isOpen,
};
}
return {
...state,
sessions,
activeSessionId: fallback?.sessionId ?? null,
activeKind: "conversation",
activeTerminalTabId: null,
isOpen: fallback ? state.isOpen : false,
};
}