Files
kandev/apps/web/lib/state/slices/ui/ui-slice.ts
T
2026-08-04 22:15:32 +01:00

499 lines
18 KiB
TypeScript

import type { StateCreator } from "zustand";
import {
getStoredCollapsedSubtaskParents,
setLocalStorage,
setStoredCollapsedSubtaskParents,
setStoredAutoScrollEnabled,
setStoredAutoScrollTop,
} from "@/lib/local-storage";
import { buildDismissedAgentErrors } from "./dismissed-agent-errors-actions";
import {
DEFAULT_SECTION_EXPANDED,
buildAppSidebarActions,
loadAppSidebarState,
} from "./app-sidebar-actions";
import { APP_SIDEBAR_EXPANDED_WIDTH } from "@/components/app-sidebar/app-sidebar-constants";
import { buildSidebarTaskPrefsActions } from "./sidebar-task-prefs-actions";
import { buildSidebarViewActions } from "./sidebar-view-actions";
import { DEFAULT_VIEW } from "./sidebar-view-builtins";
import type { SidebarView, SortSpec } from "./sidebar-view-types";
import type { SystemHealthResponse } from "@/lib/types/health";
import type { ActiveDocument, QuickChatSession, UISlice, UISliceState } from "./types";
import { getQuickChatSetupSessionId } from "./quick-chat-session";
import {
reconcileQuickChatSessions,
removeQuickChatSessionsForTask,
upsertQuickChatSession,
} from "./quick-chat-sync";
/** Default sidebar view state: the single built-in "All tasks" view, active, no draft. */
function createDefaultSidebarState(): UISliceState["sidebarViews"] {
return { views: [DEFAULT_VIEW], activeViewId: DEFAULT_VIEW.id, draft: null, syncError: null };
}
export const KNOWN_DIMENSIONS = new Set<string>([
"archived",
"state",
"workflow",
"workflowStep",
"executorType",
"repository",
"hasDiff",
"hasPR",
"isPRReview",
"isIssueWatch",
"titleMatch",
]);
export const KNOWN_SORT_KEYS = new Set<string>([
"state",
"updatedAt",
"createdAt",
"title",
"custom",
]);
/**
* Drops clauses whose dimension is no longer known (e.g. renamed or removed
* in an upgrade), and resets stale sort keys, so the popover does not crash
* when rendering stored views.
*/
export function migrateView(view: SidebarView): SidebarView {
const sort: SortSpec = KNOWN_SORT_KEYS.has(view.sort.key)
? view.sort
: { key: "state", direction: view.sort.direction };
return {
...view,
filters: view.filters.filter((c) => KNOWN_DIMENSIONS.has(c.dimension)),
sort,
};
}
export const defaultUIState: UISliceState = {
previewPanel: {
openBySessionId: {},
viewBySessionId: {},
deviceBySessionId: {},
stageBySessionId: {},
urlBySessionId: {},
urlDraftBySessionId: {},
},
rightPanel: { activeTabBySessionId: {} },
diffs: { files: [] },
connection: { status: "disconnected", error: null, issueSeverity: "none" },
mobileKanban: { activeStepIdByWorkflowId: {}, isMenuOpen: false, isSearchOpen: false },
mobileSession: {
activePanelBySessionId: {},
reviewMRKeyBySessionId: {},
isTaskSwitcherOpen: false,
},
chatInput: { planModeBySessionId: {}, cancellingBySessionId: {} },
transcriptAutoScroll: {
enabledBySessionId: {},
scrollTopBySessionId: {},
},
reviewPRSelection: { selectedKeyByTaskId: {} },
documentPanel: { activeDocumentBySessionId: {} },
systemHealth: { issues: [], checks: [], healthy: true, loaded: false, loading: false },
quickChat: { isOpen: false, sessions: [], activeSessionId: null },
sessionFailureNotification: null,
taskDeletedNotification: null,
updateAvailableNotification: null,
bottomTerminal: { isOpen: false, pendingCommand: null },
sidebarViews: createDefaultSidebarState(),
collapsedSubtaskParents: [],
kanbanPreviewedTaskId: null,
sidebarTaskPrefs: { pinnedTaskIds: [], orderedTaskIds: [], subtaskOrderByParentId: {} },
appSidebar: {
collapsed: false,
sectionExpanded: { ...DEFAULT_SECTION_EXPANDED },
width: APP_SIDEBAR_EXPANDED_WIDTH,
settingsMode: false,
},
acknowledgedAgentErrors: {},
dismissedAgentErrors: {},
};
type ImmerSet = Parameters<typeof createUISlice>[0];
/** Builds the preview-panel actions (open/toggle/view/device/stage/url), each persisting to localStorage where noted. */
function buildPreviewActions(set: ImmerSet) {
return {
setPreviewOpen: (sessionId: string, open: boolean) =>
set((draft) => {
draft.previewPanel.openBySessionId[sessionId] = open;
setLocalStorage(`preview-open-${sessionId}`, open);
}),
togglePreviewOpen: (sessionId: string) =>
set((draft) => {
const current = draft.previewPanel.openBySessionId[sessionId] ?? false;
draft.previewPanel.openBySessionId[sessionId] = !current;
setLocalStorage(`preview-open-${sessionId}`, !current);
}),
setPreviewView: (
sessionId: string,
view: UISliceState["previewPanel"]["viewBySessionId"][string],
) =>
set((draft) => {
draft.previewPanel.viewBySessionId[sessionId] = view;
setLocalStorage(`preview-view-${sessionId}`, view);
}),
setPreviewDevice: (
sessionId: string,
device: UISliceState["previewPanel"]["deviceBySessionId"][string],
) =>
set((draft) => {
draft.previewPanel.deviceBySessionId[sessionId] = device;
setLocalStorage(`preview-device-${sessionId}`, device);
}),
setPreviewStage: (
sessionId: string,
stage: UISliceState["previewPanel"]["stageBySessionId"][string],
) =>
set((draft) => {
draft.previewPanel.stageBySessionId[sessionId] = stage;
}),
setPreviewUrl: (sessionId: string, url: string) =>
set((draft) => {
draft.previewPanel.urlBySessionId[sessionId] = url;
}),
setPreviewUrlDraft: (sessionId: string, url: string) =>
set((draft) => {
draft.previewPanel.urlDraftBySessionId[sessionId] = url;
}),
};
}
/** Builds the mobile Kanban view's column-index, menu, and search-open actions. */
function buildMobileActions(set: ImmerSet) {
return {
setMobileKanbanActiveStep: (workflowId: string, stepId: string) =>
set((draft) => {
draft.mobileKanban.activeStepIdByWorkflowId[workflowId] = stepId;
}),
setMobileKanbanMenuOpen: (open: boolean) =>
set((draft) => {
draft.mobileKanban.isMenuOpen = open;
}),
setMobileKanbanSearchOpen: (open: boolean) =>
set((draft) => {
draft.mobileKanban.isSearchOpen = open;
}),
setMobileSessionPanel: (
sessionId: string,
panel: UISliceState["mobileSession"]["activePanelBySessionId"][string],
) =>
set((draft) => {
draft.mobileSession.activePanelBySessionId[sessionId] = panel;
}),
setMobileSessionReview: (sessionId: string, mrKey: string | null) =>
set((draft) => {
if (mrKey) {
draft.mobileSession.reviewMRKeyBySessionId[sessionId] = mrKey;
draft.mobileSession.activePanelBySessionId[sessionId] = "review";
return;
}
delete draft.mobileSession.reviewMRKeyBySessionId[sessionId];
if (draft.mobileSession.activePanelBySessionId[sessionId] === "review") {
draft.mobileSession.activePanelBySessionId[sessionId] = "chat";
}
}),
setMobileSessionTaskSwitcherOpen: (open: boolean) =>
set((draft) => {
draft.mobileSession.isTaskSwitcherOpen = open;
}),
};
}
/** Builds the bottom terminal's open/toggle and pending-command actions, persisting open state to localStorage. */
function buildBottomTerminalActions(set: ImmerSet) {
return {
toggleBottomTerminal: () =>
set((draft) => {
const newValue = !draft.bottomTerminal.isOpen;
draft.bottomTerminal.isOpen = newValue;
setLocalStorage("bottom-terminal-open", String(newValue));
}),
openBottomTerminalWithCommand: (command: string) =>
set((draft) => {
draft.bottomTerminal.isOpen = true;
draft.bottomTerminal.pendingCommand = command;
setLocalStorage("bottom-terminal-open", "true");
}),
clearBottomTerminalCommand: () =>
set((draft) => {
draft.bottomTerminal.pendingCommand = null;
}),
};
}
/** Builds the system-health cache's set/loading/invalidate actions. */
function buildSystemHealthActions(set: ImmerSet) {
return {
setSystemHealth: (response: SystemHealthResponse) =>
set((draft) => {
draft.systemHealth.issues = response.issues;
draft.systemHealth.checks = response.checks ?? [];
draft.systemHealth.healthy = response.healthy;
draft.systemHealth.loaded = true;
}),
setSystemHealthLoading: (loading: boolean) =>
set((draft) => {
draft.systemHealth.loading = loading;
}),
invalidateSystemHealth: () =>
set((draft) => {
draft.systemHealth.loaded = false;
}),
};
}
/**
* Builds the collapsed-subtask-parents toggle action. Tab-scoped collapse of
* a parent task's subtasks, persisted via sessionStorage (survives reload /
* task switch within the tab, resets on tab close). Not per-view and not
* synced to the backend — purely visual.
*/
function buildCollapsedSubtaskActions(set: ImmerSet, get: () => UISlice) {
return {
// Tab-scoped collapse of a parent task's subtasks. Persisted via
// sessionStorage (survives reload / task switch within the tab, resets on
// tab close). Not per-view and not synced to the backend — purely visual.
toggleSubtaskCollapsed: (parentTaskId: string) => {
set((draft) => {
const list = draft.collapsedSubtaskParents;
const idx = list.indexOf(parentTaskId);
if (idx === -1) list.push(parentTaskId);
else list.splice(idx, 1);
});
setStoredCollapsedSubtaskParents(get().collapsedSubtaskParents);
},
};
}
/** Builds the setters for session-failure, task-deleted, and update-available notifications. */
function buildNotificationActions(set: ImmerSet) {
return {
setSessionFailureNotification: (n: UISlice["sessionFailureNotification"]) =>
set((draft) => {
draft.sessionFailureNotification = n;
}),
setTaskDeletedNotification: (n: UISlice["taskDeletedNotification"]) =>
set((draft) => {
draft.taskDeletedNotification = n;
}),
setUpdateAvailableNotification: (n: UISlice["updateAvailableNotification"]) =>
set((draft) => {
draft.updateAvailableNotification = n;
}),
};
}
/** Finds this workspace's quick-chat "config" session, if one is already open. */
function findWorkspaceConfigSession(
sessions: UISliceState["quickChat"]["sessions"],
workspaceId: string,
) {
return sessions.find(
(session) => session.workspaceId === workspaceId && session.kind === "config",
);
}
/**
* Builds the `openQuickChat` action: opens (creating if needed) a quick-chat
* session, reusing an existing workspace config session for `kind: "config"`
* rather than creating a duplicate.
*/
function buildOpenQuickChatAction(set: ImmerSet) {
return (
sessionId: string,
workspaceId: string,
agentProfileId?: string,
kind: "chat" | "config" = "chat",
taskId?: string,
) =>
set((draft) => {
if (!sessionId) {
const existingConfigSession =
kind === "config"
? findWorkspaceConfigSession(draft.quickChat.sessions, workspaceId)
: undefined;
if (existingConfigSession) {
draft.quickChat.isOpen = true;
draft.quickChat.activeSessionId = existingConfigSession.sessionId;
return;
}
const setupSessionId = getQuickChatSetupSessionId(workspaceId, kind);
if (!draft.quickChat.sessions.some((session) => session.sessionId === setupSessionId)) {
draft.quickChat.sessions.push({ sessionId: setupSessionId, workspaceId, kind });
}
draft.quickChat.isOpen = true;
draft.quickChat.activeSessionId = setupSessionId;
return;
}
const existing = draft.quickChat.sessions.find((session) => session.sessionId === sessionId);
if (existing) {
if (existing.workspaceId !== workspaceId) return;
if (agentProfileId) existing.agentProfileId = agentProfileId;
if (taskId) existing.taskId = taskId;
} else {
draft.quickChat.sessions.push({ sessionId, workspaceId, agentProfileId, kind, taskId });
}
draft.quickChat.isOpen = true;
draft.quickChat.activeSessionId = sessionId;
});
}
/** Builds the remaining quick-chat session CRUD actions (add/remove/rename/close). */
function buildQuickChatActions(set: ImmerSet) {
return {
addQuickChatSession: (
sessionId: string,
workspaceId: string,
agentProfileId?: string,
kind: "chat" | "config" = "chat",
taskId?: string,
) =>
set((draft) => {
const activeWorkspaceId = draft.quickChat.sessions.find(
(session) => session.sessionId === draft.quickChat.activeSessionId,
)?.workspaceId;
const shouldActivate =
!draft.quickChat.isOpen || !activeWorkspaceId || activeWorkspaceId === workspaceId;
const existing = draft.quickChat.sessions.find(
(session) => session.sessionId === sessionId,
);
if (existing) {
if (existing.workspaceId !== workspaceId) return;
if (agentProfileId) existing.agentProfileId = agentProfileId;
if (taskId) existing.taskId = taskId;
} else {
draft.quickChat.sessions.push({ sessionId, workspaceId, agentProfileId, kind, taskId });
}
if (shouldActivate) draft.quickChat.activeSessionId = sessionId;
}),
openQuickChat: buildOpenQuickChatAction(set),
closeQuickChat: () =>
set((draft) => {
draft.quickChat.isOpen = false;
}),
closeQuickChatSession: (sessionId: string) =>
set((draft) => {
const closingSession = draft.quickChat.sessions.find(
(session) => session.sessionId === sessionId,
);
draft.quickChat.sessions = draft.quickChat.sessions.filter(
(session) => session.sessionId !== sessionId,
);
if (draft.quickChat.activeSessionId !== sessionId) return;
const nextSession = draft.quickChat.sessions.find(
(session) => session.workspaceId === closingSession?.workspaceId,
);
draft.quickChat.activeSessionId = nextSession?.sessionId ?? null;
if (!nextSession) draft.quickChat.isOpen = false;
}),
setActiveQuickChatSession: (sessionId: string, workspaceId: string) =>
set((draft) => {
const session = draft.quickChat.sessions.find((item) => item.sessionId === sessionId);
if (!session || session.workspaceId !== workspaceId) return;
draft.quickChat.activeSessionId = sessionId;
}),
// Optimistic only. Persisting the name is `persistQuickChatRename`'s job,
// so the backing task title stays the shared source of truth.
renameQuickChatSession: (sessionId: string, name: string) =>
set((draft) => {
const session = draft.quickChat.sessions.find((item) => item.sessionId === sessionId);
if (session) session.name = name;
}),
syncQuickChatSessions: (workspaceId: string, sessions: QuickChatSession[]) =>
set((draft) => {
draft.quickChat = reconcileQuickChatSessions(draft.quickChat, workspaceId, sessions);
}),
upsertQuickChatSessionFromEvent: (session: QuickChatSession) =>
set((draft) => {
draft.quickChat = upsertQuickChatSession(draft.quickChat, session);
}),
removeQuickChatSessionsForTask: (taskId: string) =>
set((draft) => {
draft.quickChat = removeQuickChatSessionsForTask(draft.quickChat, taskId);
}),
setQuickChatInitialPrompt: (sessionId: string, prompt?: string) =>
set((draft) => {
const session = draft.quickChat.sessions.find((item) => item.sessionId === sessionId);
if (session) session.initialPrompt = prompt;
}),
};
}
export const createUISlice: StateCreator<UISlice, [["zustand/immer", never]], [], UISlice> = (
set,
get,
) => ({
...defaultUIState,
// Hydrate from sessionStorage at slice creation (runs in the browser, after
// the default static state) so tests and SSR both see a fresh read.
collapsedSubtaskParents: getStoredCollapsedSubtaskParents(),
sidebarTaskPrefs: { pinnedTaskIds: [], orderedTaskIds: [], subtaskOrderByParentId: {} },
appSidebar: loadAppSidebarState(),
...buildAppSidebarActions(set),
...buildPreviewActions(set),
...buildMobileActions(set),
...buildBottomTerminalActions(set),
...buildSidebarViewActions(set, get),
...buildSidebarTaskPrefsActions(set, get),
...buildCollapsedSubtaskActions(set, get),
...buildSystemHealthActions(set),
...buildDismissedAgentErrors(set),
...buildNotificationActions(set),
...buildQuickChatActions(set),
setRightPanelActiveTab: (sessionId, tab) =>
set((draft) => {
draft.rightPanel.activeTabBySessionId[sessionId] = tab;
}),
setConnectionStatus: (status, error) =>
set((draft) => {
draft.connection.status = status;
draft.connection.error = error ?? null;
}),
setConnectionIssueSeverity: (severity) =>
set((draft) => {
draft.connection.issueSeverity = severity;
}),
setPlanMode: (sessionId, enabled) =>
set((draft) => {
draft.chatInput.planModeBySessionId[sessionId] = enabled;
}),
setCancelTurnPending: (sessionId, pending) =>
set((draft) => {
if (pending) {
draft.chatInput.cancellingBySessionId[sessionId] = true;
} else {
delete draft.chatInput.cancellingBySessionId[sessionId];
}
}),
setTranscriptAutoScrollEnabled: (sessionId, enabled) =>
set((draft) => {
draft.transcriptAutoScroll.enabledBySessionId[sessionId] = enabled;
setStoredAutoScrollEnabled(sessionId, enabled);
}),
setTranscriptScrollTop: (sessionId, scrollTop) =>
set((draft) => {
draft.transcriptAutoScroll.scrollTopBySessionId[sessionId] = scrollTop;
setStoredAutoScrollTop(sessionId, scrollTop);
}),
setReviewPRSelection: (taskId, selectedKey) =>
set((draft) => {
draft.reviewPRSelection.selectedKeyByTaskId[taskId] = selectedKey;
}),
setActiveDocument: (sessionId, doc) =>
set((draft) => {
draft.documentPanel.activeDocumentBySessionId[sessionId] = doc;
setLocalStorage(`active-document-${sessionId}`, doc as ActiveDocument | null);
}),
setKanbanPreviewedTaskId: (taskId) =>
set((draft) => {
if (draft.kanbanPreviewedTaskId === taskId) return;
draft.kanbanPreviewedTaskId = taskId;
}),
});