import { Fragment, type ReactNode, createElement, useEffect } from "react";import { DEFAULT_MODEL, type ProviderKind, ThreadId, type OrchestrationReadModel, type OrchestrationSessionStatus,} from "@t3tools/contracts";import { getModelOptions, normalizeModelSlug, resolveModelSlug, resolveModelSlugForProvider,} from "@t3tools/shared/model";import { create } from "zustand";import { type ChatMessage, type Project, type Thread,} from "./types"; // ── State ──────────────────────────────────────────────────────────── export interface AppState { projects: Project[]; threads: Thread[]; threadsHydrated: boolean;} const PERSISTED_STATE_KEY = "t3code:renderer-state:v8";const LEGACY_PERSISTED_STATE_KEYS = [ "t3code:renderer-state:v6", "t3code:renderer-state:v5", "t3code:renderer-state:v4", "t3code:renderer-state:v3", "codething:renderer-state:v4", "codething:renderer-state:v3", "codething:renderer-state:v2", "codething:renderer-state:v1",] as const; const initialState: AppState = { projects: [], threads: [], threadsHydrated: false,};const persistedExpandedProjectCwds = new Set<string>(); // ── Persist helpers ────────────────────────────────────────────────── function readPersistedState(): AppState { if (typeof window === "undefined") return initialState; try { const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); if (!raw) return initialState; const parsed = JSON.parse(raw) as { expandedProjectCwds?: string[] }; persistedExpandedProjectCwds.clear(); for (const cwd of parsed.expandedProjectCwds ?? []) { if (typeof cwd === "string" && cwd.length > 0) { persistedExpandedProjectCwds.add(cwd); } } return { ...initialState }; } catch { return initialState; }} function persistState(state: AppState): void { if (typeof window === "undefined") return; try { window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ expandedProjectCwds: state.projects .filter((project) => project.expanded) .map((project) => project.cwd), }), ); for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { window.localStorage.removeItem(legacyKey); } } catch { // Ignore quota/storage errors to avoid breaking chat UX. }} // ── Pure helpers ────────────────────────────────────────────────────── function updateThread( threads: Thread[], threadId: ThreadId, updater: (t: Thread) => Thread,): Thread[] { let changed = false; const next = threads.map((t) => { if (t.id !== threadId) return t; const updated = updater(t); if (updated !== t) changed = true; return updated; }); return changed ? next : threads;} function mapProjectsFromReadModel( incoming: OrchestrationReadModel["projects"], previous: Project[],): Project[] { return incoming.map((project) => { const existing = previous.find((entry) => entry.id === project.id) ?? previous.find((entry) => entry.cwd === project.workspaceRoot); return { id: project.id, name: project.title, cwd: project.workspaceRoot, model: existing?.model ?? resolveModelSlug(project.defaultModel ?? DEFAULT_MODEL), expanded: existing?.expanded ?? (persistedExpandedProjectCwds.size > 0 ? persistedExpandedProjectCwds.has(project.workspaceRoot) : true), scripts: project.scripts.map((script) => ({ ...script })), }; });} function toLegacySessionStatus( status: OrchestrationSessionStatus,): "connecting" | "ready" | "running" | "error" | "closed" { switch (status) { case "starting": return "connecting"; case "running": return "running"; case "error": return "error"; case "ready": case "interrupted": return "ready"; case "idle": case "stopped": return "closed"; }} function toLegacyProvider(providerName: string | null): ProviderKind { if (providerName === "codex" || providerName === "claudeCode" || providerName === "cursor") { return providerName; } return "codex";} const CODEX_MODEL_SLUGS = new Set<string>(getModelOptions("codex").map((option) => option.slug));const CLAUDE_MODEL_SLUGS = new Set<string>(getModelOptions("claudeCode").map((option) => option.slug));const CURSOR_MODEL_SLUGS = new Set<string>(getModelOptions("cursor").map((option) => option.slug));const CURSOR_DISTINCT_MODEL_SLUGS = new Set( [...CURSOR_MODEL_SLUGS].filter( (slug) => !CODEX_MODEL_SLUGS.has(slug) && !CLAUDE_MODEL_SLUGS.has(slug), ),); function inferProviderForThreadModel(input: { readonly model: string; readonly sessionProviderName: string | null;}): ProviderKind { if ( input.sessionProviderName === "codex" || input.sessionProviderName === "claudeCode" || input.sessionProviderName === "cursor" ) { return input.sessionProviderName; } const normalizedCursor = normalizeModelSlug(input.model, "cursor"); if (normalizedCursor && CURSOR_DISTINCT_MODEL_SLUGS.has(normalizedCursor)) { return "cursor"; } const normalizedClaude = normalizeModelSlug(input.model, "claudeCode"); if (normalizedClaude && CLAUDE_MODEL_SLUGS.has(normalizedClaude)) { return "claudeCode"; } const normalizedCodex = normalizeModelSlug(input.model, "codex"); if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) { return "codex"; } if ( input.model.trim().startsWith("composer-") || input.model.trim().startsWith("gemini-") || input.model.trim().endsWith("-thinking") ) { return "cursor"; } return input.model.trim().startsWith("claude-") ? "claudeCode" : "codex";} function resolveWsHttpOrigin(): string { if (typeof window === "undefined") return ""; const bridgeWsUrl = window.desktopBridge?.getWsUrl?.(); const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined; const wsCandidate = typeof bridgeWsUrl === "string" && bridgeWsUrl.length > 0 ? bridgeWsUrl : typeof envWsUrl === "string" && envWsUrl.length > 0 ? envWsUrl : null; if (!wsCandidate) return window.location.origin; try { const wsUrl = new URL(wsCandidate); const protocol = wsUrl.protocol === "wss:" ? "https:" : wsUrl.protocol === "ws:" ? "http:" : wsUrl.protocol; return `${protocol}//${wsUrl.host}`; } catch { return window.location.origin; }} function toAttachmentPreviewUrl(rawUrl: string): string { if (rawUrl.startsWith("/")) { return `${resolveWsHttpOrigin()}${rawUrl}`; } return rawUrl;} function attachmentPreviewRoutePath(attachmentId: string): string { return `/attachments/${encodeURIComponent(attachmentId)}`;} // ── Pure state transition functions ──────────────────────────────────── export function syncServerReadModel( state: AppState, readModel: OrchestrationReadModel,): AppState { const projects = mapProjectsFromReadModel( readModel.projects.filter((project) => project.deletedAt === null), state.projects, ); const existingThreadById = new Map( state.threads.map((thread) => [thread.id, thread] as const), ); const threads = readModel.threads .filter((thread) => thread.deletedAt === null) .map((thread) => { const existing = existingThreadById.get(thread.id); return { id: thread.id, codexThreadId: null, projectId: thread.projectId, title: thread.title, model: resolveModelSlugForProvider( inferProviderForThreadModel({ model: thread.model, sessionProviderName: thread.session?.providerName ?? null, }), thread.model, ), runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode, session: thread.session ? { provider: toLegacyProvider(thread.session.providerName), status: toLegacySessionStatus(thread.session.status), orchestrationStatus: thread.session.status, activeTurnId: thread.session.activeTurnId ?? undefined, createdAt: thread.session.updatedAt, updatedAt: thread.session.updatedAt, ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), } : null, messages: thread.messages.map((message) => { const attachments = message.attachments?.map((attachment) => ({ type: "image" as const, id: attachment.id, name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), })); const normalizedMessage: ChatMessage = { id: message.id, role: message.role, text: message.text, createdAt: message.createdAt, streaming: message.streaming, ...(message.streaming ? {} : { completedAt: message.updatedAt }), ...(attachments && attachments.length > 0 ? { attachments } : {}), }; return normalizedMessage; }), proposedPlans: thread.proposedPlans.map((proposedPlan) => ({ id: proposedPlan.id, turnId: proposedPlan.turnId, planMarkdown: proposedPlan.planMarkdown, createdAt: proposedPlan.createdAt, updatedAt: proposedPlan.updatedAt, })), error: thread.session?.lastError ?? null, createdAt: thread.createdAt, latestTurn: thread.latestTurn, lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, branch: thread.branch, worktreePath: thread.worktreePath, turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({ turnId: checkpoint.turnId, completedAt: checkpoint.completedAt, status: checkpoint.status, assistantMessageId: checkpoint.assistantMessageId ?? undefined, checkpointTurnCount: checkpoint.checkpointTurnCount, checkpointRef: checkpoint.checkpointRef, files: checkpoint.files.map((file) => ({ ...file })), })), activities: thread.activities.map((activity) => ({ ...activity })), }; }); return { ...state, projects, threads, threadsHydrated: true, };} export function markThreadVisited( state: AppState, threadId: ThreadId, visitedAt?: string,): AppState { const at = visitedAt ?? new Date().toISOString(); const visitedAtMs = Date.parse(at); const threads = updateThread(state.threads, threadId, (thread) => { const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN; if ( Number.isFinite(previousVisitedAtMs) && Number.isFinite(visitedAtMs) && previousVisitedAtMs >= visitedAtMs ) { return thread; } return { ...thread, lastVisitedAt: at }; }); return threads === state.threads ? state : { ...state, threads };} export function markThreadUnread(state: AppState, threadId: ThreadId): AppState { const threads = updateThread(state.threads, threadId, (thread) => { if (!thread.latestTurn?.completedAt) return thread; const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt); if (Number.isNaN(latestTurnCompletedAtMs)) return thread; const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString(); if (thread.lastVisitedAt === unreadVisitedAt) return thread; return { ...thread, lastVisitedAt: unreadVisitedAt }; }); return threads === state.threads ? state : { ...state, threads };} export function toggleProject(state: AppState, projectId: Project["id"]): AppState { return { ...state, projects: state.projects.map((p) => p.id === projectId ? { ...p, expanded: !p.expanded } : p, ), };} export function setProjectExpanded( state: AppState, projectId: Project["id"], expanded: boolean,): AppState { let changed = false; const projects = state.projects.map((p) => { if (p.id !== projectId || p.expanded === expanded) return p; changed = true; return { ...p, expanded }; }); return changed ? { ...state, projects } : state;} export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { const threads = updateThread(state.threads, threadId, (t) => { if (t.error === error) return t; return { ...t, error }; }); return threads === state.threads ? state : { ...state, threads };} export function setThreadBranch( state: AppState, threadId: ThreadId, branch: string | null, worktreePath: string | null,): AppState { const threads = updateThread(state.threads, threadId, (t) => { if (t.branch === branch && t.worktreePath === worktreePath) return t; const cwdChanged = t.worktreePath !== worktreePath; return { ...t, branch, worktreePath, ...(cwdChanged ? { session: null } : {}), }; }); return threads === state.threads ? state : { ...state, threads };} // ── Zustand store ──────────────────────────────────────────────────── interface AppStore extends AppState { syncServerReadModel: (readModel: OrchestrationReadModel) => void; markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void; markThreadUnread: (threadId: ThreadId) => void; toggleProject: (projectId: Project["id"]) => void; setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void; setError: (threadId: ThreadId, error: string | null) => void; setThreadBranch: ( threadId: ThreadId, branch: string | null, worktreePath: string | null, ) => void;} export const useStore = create<AppStore>((set) => ({ ...readPersistedState(), syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)), markThreadVisited: (threadId, visitedAt) => set((state) => markThreadVisited(state, threadId, visitedAt)), markThreadUnread: (threadId) => set((state) => markThreadUnread(state, threadId)), toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), setProjectExpanded: (projectId, expanded) => set((state) => setProjectExpanded(state, projectId, expanded)), setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadId, branch, worktreePath) => set((state) => setThreadBranch(state, threadId, branch, worktreePath)),})); // Persist on every state changeuseStore.subscribe((state) => persistState(state)); export function StoreProvider({ children }: { children: ReactNode }) { useEffect(() => { persistState(useStore.getState()); }, []); return createElement(Fragment, null, children);}
import { Fragment, type ReactNode, createElement, useEffect } from "react";
import {
DEFAULT_MODEL,
type ProviderKind,
ThreadId,
type OrchestrationReadModel,
type OrchestrationSessionStatus,
} from "@t3tools/contracts";
import {
getModelOptions,
normalizeModelSlug,
resolveModelSlug,
resolveModelSlugForProvider,
} from "@t3tools/shared/model";
import { create } from "zustand";
import {
type ChatMessage,
type Project,
type Thread,
} from "./types";
// ── State ────────────────────────────────────────────────────────────
export interface AppState {
projects: Project[];
threads: Thread[];
threadsHydrated: boolean;
}
const PERSISTED_STATE_KEY = "t3code:renderer-state:v8";
const LEGACY_PERSISTED_STATE_KEYS = [
"t3code:renderer-state:v6",
"t3code:renderer-state:v5",
"t3code:renderer-state:v4",
"t3code:renderer-state:v3",
"codething:renderer-state:v4",
"codething:renderer-state:v3",
"codething:renderer-state:v2",
"codething:renderer-state:v1",
] as const;
const initialState: AppState = {
projects: [],
threads: [],
threadsHydrated: false,
};
const persistedExpandedProjectCwds = new Set<string>();
// ── Persist helpers ──────────────────────────────────────────────────
function readPersistedState(): AppState {
if (typeof window === "undefined") return initialState;
try {
const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);
if (!raw) return initialState;
const parsed = JSON.parse(raw) as { expandedProjectCwds?: string[] };
persistedExpandedProjectCwds.clear();
for (const cwd of parsed.expandedProjectCwds ?? []) {
if (typeof cwd === "string" && cwd.length > 0) {
persistedExpandedProjectCwds.add(cwd);
}
}
return { ...initialState };
} catch {
return initialState;
}
}
function persistState(state: AppState): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
PERSISTED_STATE_KEY,
JSON.stringify({
expandedProjectCwds: state.projects
.filter((project) => project.expanded)
.map((project) => project.cwd),
}),
);
for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
window.localStorage.removeItem(legacyKey);
}
} catch {
// Ignore quota/storage errors to avoid breaking chat UX.
}
}
// ── Pure helpers ──────────────────────────────────────────────────────
function updateThread(
threads: Thread[],
threadId: ThreadId,
updater: (t: Thread) => Thread,
): Thread[] {
let changed = false;
const next = threads.map((t) => {
if (t.id !== threadId) return t;
const updated = updater(t);
if (updated !== t) changed = true;
return updated;
});
return changed ? next : threads;
}
function mapProjectsFromReadModel(
incoming: OrchestrationReadModel["projects"],
previous: Project[],
): Project[] {
return incoming.map((project) => {
const existing =
previous.find((entry) => entry.id === project.id) ??
previous.find((entry) => entry.cwd === project.workspaceRoot);
return {
id: project.id,
name: project.title,
cwd: project.workspaceRoot,
model: existing?.model ?? resolveModelSlug(project.defaultModel ?? DEFAULT_MODEL),
expanded:
existing?.expanded ??
(persistedExpandedProjectCwds.size > 0
? persistedExpandedProjectCwds.has(project.workspaceRoot)
: true),
scripts: project.scripts.map((script) => ({ ...script })),
};
});
}
function toLegacySessionStatus(
status: OrchestrationSessionStatus,
): "connecting" | "ready" | "running" | "error" | "closed" {
switch (status) {
case "starting":
return "connecting";
case "running":
return "running";
case "error":
return "error";
case "ready":
case "interrupted":
return "ready";
case "idle":
case "stopped":
return "closed";
}
}
function toLegacyProvider(providerName: string | null): ProviderKind {
if (providerName === "codex" || providerName === "claudeCode" || providerName === "cursor") {
return providerName;
}
return "codex";
}
const CODEX_MODEL_SLUGS = new Set<string>(getModelOptions("codex").map((option) => option.slug));
const CLAUDE_MODEL_SLUGS = new Set<string>(getModelOptions("claudeCode").map((option) => option.slug));
const CURSOR_MODEL_SLUGS = new Set<string>(getModelOptions("cursor").map((option) => option.slug));
const CURSOR_DISTINCT_MODEL_SLUGS = new Set(
[...CURSOR_MODEL_SLUGS].filter(
(slug) => !CODEX_MODEL_SLUGS.has(slug) && !CLAUDE_MODEL_SLUGS.has(slug),
),
);
function inferProviderForThreadModel(input: {
readonly model: string;
readonly sessionProviderName: string | null;
}): ProviderKind {
if (
input.sessionProviderName === "codex" ||
input.sessionProviderName === "claudeCode" ||
input.sessionProviderName === "cursor"
) {
return input.sessionProviderName;
}
const normalizedCursor = normalizeModelSlug(input.model, "cursor");
if (normalizedCursor && CURSOR_DISTINCT_MODEL_SLUGS.has(normalizedCursor)) {
return "cursor";
}
const normalizedClaude = normalizeModelSlug(input.model, "claudeCode");
if (normalizedClaude && CLAUDE_MODEL_SLUGS.has(normalizedClaude)) {
return "claudeCode";
}
const normalizedCodex = normalizeModelSlug(input.model, "codex");
if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) {
return "codex";
}
if (
input.model.trim().startsWith("composer-") ||
input.model.trim().startsWith("gemini-") ||
input.model.trim().endsWith("-thinking")
) {
return "cursor";
}
return input.model.trim().startsWith("claude-") ? "claudeCode" : "codex";
}
function resolveWsHttpOrigin(): string {
if (typeof window === "undefined") return "";
const bridgeWsUrl = window.desktopBridge?.getWsUrl?.();
const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined;
const wsCandidate =
typeof bridgeWsUrl === "string" && bridgeWsUrl.length > 0
? bridgeWsUrl
: typeof envWsUrl === "string" && envWsUrl.length > 0
? envWsUrl
: null;
if (!wsCandidate) return window.location.origin;
try {
const wsUrl = new URL(wsCandidate);
const protocol =
wsUrl.protocol === "wss:"
? "https:"
: wsUrl.protocol === "ws:"
? "http:"
: wsUrl.protocol;
return `${protocol}//${wsUrl.host}`;
} catch {
return window.location.origin;
}
}
function toAttachmentPreviewUrl(rawUrl: string): string {
if (rawUrl.startsWith("/")) {
return `${resolveWsHttpOrigin()}${rawUrl}`;
}
return rawUrl;
}
function attachmentPreviewRoutePath(attachmentId: string): string {
return `/attachments/${encodeURIComponent(attachmentId)}`;
}
// ── Pure state transition functions ────────────────────────────────────
export function syncServerReadModel(
state: AppState,
readModel: OrchestrationReadModel,
): AppState {
const projects = mapProjectsFromReadModel(
readModel.projects.filter((project) => project.deletedAt === null),
state.projects,
);
const existingThreadById = new Map(
state.threads.map((thread) => [thread.id, thread] as const),
);
const threads = readModel.threads
.filter((thread) => thread.deletedAt === null)
.map((thread) => {
const existing = existingThreadById.get(thread.id);
return {
id: thread.id,
codexThreadId: null,
projectId: thread.projectId,
title: thread.title,
model: resolveModelSlugForProvider(
inferProviderForThreadModel({
model: thread.model,
sessionProviderName: thread.session?.providerName ?? null,
}),
thread.model,
),
runtimeMode: thread.runtimeMode,
interactionMode: thread.interactionMode,
session: thread.session
? {
provider: toLegacyProvider(thread.session.providerName),
status: toLegacySessionStatus(thread.session.status),
orchestrationStatus: thread.session.status,
activeTurnId: thread.session.activeTurnId ?? undefined,
createdAt: thread.session.updatedAt,
updatedAt: thread.session.updatedAt,
...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),
}
: null,
messages: thread.messages.map((message) => {
const attachments = message.attachments?.map((attachment) => ({
type: "image" as const,
id: attachment.id,
name: attachment.name,
mimeType: attachment.mimeType,
sizeBytes: attachment.sizeBytes,
previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)),
}));
const normalizedMessage: ChatMessage = {
id: message.id,
role: message.role,
text: message.text,
createdAt: message.createdAt,
streaming: message.streaming,
...(message.streaming ? {} : { completedAt: message.updatedAt }),
...(attachments && attachments.length > 0 ? { attachments } : {}),
};
return normalizedMessage;
}),
proposedPlans: thread.proposedPlans.map((proposedPlan) => ({
id: proposedPlan.id,
turnId: proposedPlan.turnId,
planMarkdown: proposedPlan.planMarkdown,
createdAt: proposedPlan.createdAt,
updatedAt: proposedPlan.updatedAt,
})),
error: thread.session?.lastError ?? null,
createdAt: thread.createdAt,
latestTurn: thread.latestTurn,
lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,
branch: thread.branch,
worktreePath: thread.worktreePath,
turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({
turnId: checkpoint.turnId,
completedAt: checkpoint.completedAt,
status: checkpoint.status,
assistantMessageId: checkpoint.assistantMessageId ?? undefined,
checkpointTurnCount: checkpoint.checkpointTurnCount,
checkpointRef: checkpoint.checkpointRef,
files: checkpoint.files.map((file) => ({ ...file })),
})),
activities: thread.activities.map((activity) => ({ ...activity })),
};
});
return {
...state,
projects,
threads,
threadsHydrated: true,
};
}
export function markThreadVisited(
state: AppState,
threadId: ThreadId,
visitedAt?: string,
): AppState {
const at = visitedAt ?? new Date().toISOString();
const visitedAtMs = Date.parse(at);
const threads = updateThread(state.threads, threadId, (thread) => {
const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN;
if (
Number.isFinite(previousVisitedAtMs) &&
Number.isFinite(visitedAtMs) &&
previousVisitedAtMs >= visitedAtMs
) {
return thread;
}
return { ...thread, lastVisitedAt: at };
});
return threads === state.threads ? state : { ...state, threads };
}
export function markThreadUnread(state: AppState, threadId: ThreadId): AppState {
const threads = updateThread(state.threads, threadId, (thread) => {
if (!thread.latestTurn?.completedAt) return thread;
const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt);
if (Number.isNaN(latestTurnCompletedAtMs)) return thread;
const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString();
if (thread.lastVisitedAt === unreadVisitedAt) return thread;
return { ...thread, lastVisitedAt: unreadVisitedAt };
});
return threads === state.threads ? state : { ...state, threads };
}
export function toggleProject(state: AppState, projectId: Project["id"]): AppState {
return {
...state,
projects: state.projects.map((p) =>
p.id === projectId ? { ...p, expanded: !p.expanded } : p,
),
};
}
export function setProjectExpanded(
state: AppState,
projectId: Project["id"],
expanded: boolean,
): AppState {
let changed = false;
const projects = state.projects.map((p) => {
if (p.id !== projectId || p.expanded === expanded) return p;
changed = true;
return { ...p, expanded };
});
return changed ? { ...state, projects } : state;
}
export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
const threads = updateThread(state.threads, threadId, (t) => {
if (t.error === error) return t;
return { ...t, error };
});
return threads === state.threads ? state : { ...state, threads };
}
export function setThreadBranch(
state: AppState,
threadId: ThreadId,
branch: string | null,
worktreePath: string | null,
): AppState {
const threads = updateThread(state.threads, threadId, (t) => {
if (t.branch === branch && t.worktreePath === worktreePath) return t;
const cwdChanged = t.worktreePath !== worktreePath;
return {
...t,
branch,
worktreePath,
...(cwdChanged ? { session: null } : {}),
};
});
return threads === state.threads ? state : { ...state, threads };
}
// ── Zustand store ────────────────────────────────────────────────────
interface AppStore extends AppState {
syncServerReadModel: (readModel: OrchestrationReadModel) => void;
markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void;
markThreadUnread: (threadId: ThreadId) => void;
toggleProject: (projectId: Project["id"]) => void;
setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
setError: (threadId: ThreadId, error: string | null) => void;
setThreadBranch: (
threadId: ThreadId,
branch: string | null,
worktreePath: string | null,
) => void;
}
export const useStore = create<AppStore>((set) => ({
...readPersistedState(),
syncServerReadModel: (readModel) =>
set((state) => syncServerReadModel(state, readModel)),
markThreadVisited: (threadId, visitedAt) =>
set((state) => markThreadVisited(state, threadId, visitedAt)),
markThreadUnread: (threadId) =>
set((state) => markThreadUnread(state, threadId)),
toggleProject: (projectId) =>
set((state) => toggleProject(state, projectId)),
setProjectExpanded: (projectId, expanded) =>
set((state) => setProjectExpanded(state, projectId, expanded)),
setError: (threadId, error) =>
set((state) => setError(state, threadId, error)),
setThreadBranch: (threadId, branch, worktreePath) =>
set((state) => setThreadBranch(state, threadId, branch, worktreePath)),
}));
// Persist on every state change
useStore.subscribe((state) => persistState(state));
export function StoreProvider({ children }: { children: ReactNode }) {
useEffect(() => {
persistState(useStore.getState());
}, []);
return createElement(Fragment, null, children);
}