Files
Triple-C/app/src/store/appState.ts
T
shadow-testandClaude Opus 5 ab747ce53d
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-linux (pull_request) Successful in 5m33s
Build App (Preview) / build-windows (pull_request) Successful in 5m40s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Say what "open in container" is doing, and land on the pane doing it
Opening a page is a container probe, a browser launch, a page load and
often a viewer start — several seconds during which the only feedback
was the click itself. Worse from a terminal, where the result appears in
a pane the user is not looking at.

So: the backend emits progress on the existing `container-progress`
channel at each step, the Browser tab renders that line whenever it is
set — the progress belongs to the project, not to whoever pressed the
button, which is what lets a terminal-initiated open report anywhere at
all — and the terminal's "In container" now selects the project's
Browser tab before starting, so the line has somewhere to appear.

Selecting a sub-tab from outside needed a route: `ProjectHome` keeps it
in local state, so `openProjectHomeTab` parks a request in the store and
the pane consumes it once. Consumed once, so it cannot fight the user's
own clicking afterwards.

Preview releases now prune themselves to the newest KEEP_PREVIEWS (2),
in a job that runs only if all three platforms published — a
half-finished run must not evict a good older build. The cleanup
workflow's manual sweep stays as the backstop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:13:21 -07:00

404 lines
14 KiB
TypeScript

import { create } from "zustand";
import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo } from "../lib/types";
const SIDEBAR_COLLAPSED_KEY = "triple-c.sidebar.collapsed";
function loadSidebarCollapsed(): boolean {
try {
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1";
} catch {
return false;
}
}
function persistSidebarCollapsed(value: boolean) {
try {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, value ? "1" : "0");
} catch {
// ignore — storage may be unavailable
}
}
/**
* The main area hosts two tab kinds — terminals and Project Home views — in a
* single ordered strip. Tabs are addressed by a string key so one array can
* hold both kinds.
*/
export const terminalTabKey = (sessionId: string) => `term:${sessionId}`;
export const homeTabKey = (projectId: string) => `home:${projectId}`;
export const isTerminalTab = (key: string) => key.startsWith("term:");
export const isHomeTab = (key: string) => key.startsWith("home:");
export const tabKeyId = (key: string) => key.slice(key.indexOf(":") + 1);
/** activeSessionId is derived from the active tab so exactly one thing is "current". */
function activation(activeTabKey: string | null) {
return {
activeTabKey,
activeSessionId:
activeTabKey && isTerminalTab(activeTabKey) ? tabKeyId(activeTabKey) : null,
};
}
export type ToastKind = "error" | "success" | "info";
export interface Toast {
id: string;
kind: ToastKind;
message: string;
/** Long text (e.g. a bollard error) shown behind a "Details" disclosure. */
detail?: string;
}
let toastCounter = 0;
interface AppState {
// Projects
projects: Project[];
selectedProjectId: string | null;
setProjects: (projects: Project[]) => void;
setSelectedProject: (id: string | null) => void;
updateProjectInList: (project: Project) => void;
removeProjectFromList: (id: string) => void;
// Terminal sessions
sessions: TerminalSession[];
activeSessionId: string | null;
addSession: (session: TerminalSession) => void;
removeSession: (id: string) => void;
setActiveSession: (id: string | null) => void;
// Main-area tab strip (terminals + Project Home views)
tabOrder: string[];
activeTabKey: string | null;
openProjectHome: (projectId: string) => void;
/**
* Open a project's home tab *on a particular sub-tab*.
*
* The sub-tab is local state inside `ProjectHome`, so this parks a request
* here for it to pick up: an action taken somewhere else entirely — opening a
* page in the container's browser from a terminal — has to be able to land
* the user on the pane that shows the result.
*/
openProjectHomeTab: (projectId: string, tab: string) => void;
/** Consumed once by `ProjectHome`, then cleared. */
pendingHomeTab: { projectId: string; tab: string } | null;
clearPendingHomeTab: () => void;
closeHomeTab: (projectId: string) => void;
setActiveTabKey: (key: string) => void;
cycleTab: (delta: number) => void;
focusTabIndex: (index: number) => void;
/** Reorder: put `key` at `toIndex` in the strip. Never changes what's active. */
moveTab: (key: string, toIndex: number) => void;
/** Nudge the active tab left/right — the keyboard route to the same thing. */
moveActiveTab: (delta: number) => void;
// Inline container progress, replacing the blocking progress modal.
containerProgress: Record<string, string>;
setContainerProgress: (projectId: string, message: string | null) => void;
/** Wall-clock ms when a project was observed transitioning to "running". */
runningSince: Record<string, number>;
// Toasts
toasts: Toast[];
pushToast: (toast: Omit<Toast, "id">) => string;
dismissToast: (id: string) => void;
// UI state
terminalHasSelection: boolean;
setTerminalHasSelection: (has: boolean) => void;
// STT toggle for the active session, registered by App so the terminal's
// Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
sttToggle: () => void;
setSttToggle: (fn: () => void) => void;
// Active terminal scroll state, surfaced so the status bar can host the
// "Jump to Current" control. Only the active TerminalView writes these.
terminalAtBottom: boolean;
setTerminalAtBottom: (v: boolean) => void;
scrollActiveToBottom: () => void;
setScrollActiveToBottom: (fn: () => void) => void;
sidebarView: "projects" | "settings";
setSidebarView: (view: "projects" | "settings") => void;
sidebarCollapsed: boolean;
setSidebarCollapsed: (collapsed: boolean) => void;
toggleSidebarCollapsed: () => void;
dockerAvailable: boolean | null;
setDockerAvailable: (available: boolean | null) => void;
imageExists: boolean | null;
setImageExists: (exists: boolean | null) => void;
// App settings
appSettings: AppSettings | null;
setAppSettings: (settings: AppSettings) => void;
// Update info
updateInfo: UpdateInfo | null;
setUpdateInfo: (info: UpdateInfo | null) => void;
appVersion: string;
setAppVersion: (version: string) => void;
// Image update info
imageUpdateInfo: ImageUpdateInfo | null;
setImageUpdateInfo: (info: ImageUpdateInfo | null) => void;
}
/** Track running-since transitions so Overview can show an uptime. */
function trackRunning(
previous: Project[],
next: Project[],
runningSince: Record<string, number>,
): Record<string, number> {
let changed = false;
const result = { ...runningSince };
const now = Date.now();
for (const project of next) {
const before = previous.find((p) => p.id === project.id);
if (project.status === "running") {
if (result[project.id] === undefined) {
result[project.id] = now;
changed = true;
}
} else if (before?.status === "running" || result[project.id] !== undefined) {
delete result[project.id];
changed = true;
}
}
return changed ? result : runningSince;
}
export const useAppState = create<AppState>((set) => ({
// Projects
projects: [],
selectedProjectId: null,
setProjects: (projects) =>
set((state) => ({
projects,
runningSince: trackRunning(state.projects, projects, state.runningSince),
})),
setSelectedProject: (id) => set({ selectedProjectId: id }),
updateProjectInList: (project) =>
set((state) => {
const projects = state.projects.map((p) =>
p.id === project.id ? project : p,
);
return {
projects,
runningSince: trackRunning(state.projects, projects, state.runningSince),
};
}),
removeProjectFromList: (id) =>
set((state) => {
const key = homeTabKey(id);
const tabOrder = state.tabOrder.filter((k) => k !== key);
const activeTabKey =
state.activeTabKey === key
? (tabOrder[tabOrder.length - 1] ?? null)
: state.activeTabKey;
return {
projects: state.projects.filter((p) => p.id !== id),
selectedProjectId:
state.selectedProjectId === id ? null : state.selectedProjectId,
tabOrder,
...activation(activeTabKey),
};
}),
// Terminal sessions
sessions: [],
activeSessionId: null,
addSession: (session) =>
set((state) => {
const key = terminalTabKey(session.id);
return {
sessions: [...state.sessions, session],
tabOrder: state.tabOrder.includes(key)
? state.tabOrder
: [...state.tabOrder, key],
...activation(key),
};
}),
removeSession: (id) =>
set((state) => {
const key = terminalTabKey(id);
const index = state.tabOrder.indexOf(key);
const tabOrder = state.tabOrder.filter((k) => k !== key);
const activeTabKey =
state.activeTabKey === key
? (tabOrder[Math.min(Math.max(index, 0), tabOrder.length - 1)] ?? null)
: state.activeTabKey;
return {
sessions: state.sessions.filter((s) => s.id !== id),
tabOrder,
...activation(activeTabKey),
};
}),
setActiveSession: (id) =>
set(() => activation(id === null ? null : terminalTabKey(id))),
// Main-area tabs
tabOrder: [],
activeTabKey: null,
openProjectHome: (projectId) =>
set((state) => {
const key = homeTabKey(projectId);
return {
selectedProjectId: projectId,
tabOrder: state.tabOrder.includes(key)
? state.tabOrder
: [...state.tabOrder, key],
...activation(key),
};
}),
openProjectHomeTab: (projectId, tab) =>
set((state) => {
const key = homeTabKey(projectId);
return {
selectedProjectId: projectId,
tabOrder: state.tabOrder.includes(key)
? state.tabOrder
: [...state.tabOrder, key],
pendingHomeTab: { projectId, tab },
...activation(key),
};
}),
pendingHomeTab: null,
clearPendingHomeTab: () => set({ pendingHomeTab: null }),
closeHomeTab: (projectId) =>
set((state) => {
const key = homeTabKey(projectId);
const index = state.tabOrder.indexOf(key);
if (index === -1) return {};
const tabOrder = state.tabOrder.filter((k) => k !== key);
const activeTabKey =
state.activeTabKey === key
? (tabOrder[Math.min(index, tabOrder.length - 1)] ?? null)
: state.activeTabKey;
return { tabOrder, ...activation(activeTabKey) };
}),
setActiveTabKey: (key) =>
set((state) => {
if (!state.tabOrder.includes(key)) return {};
const patch = activation(key);
return isHomeTab(key)
? { ...patch, selectedProjectId: tabKeyId(key) }
: patch;
}),
cycleTab: (delta) =>
set((state) => {
if (state.tabOrder.length === 0) return {};
const current = state.activeTabKey
? state.tabOrder.indexOf(state.activeTabKey)
: -1;
const next =
(current + delta + state.tabOrder.length) % state.tabOrder.length;
const key = state.tabOrder[next];
const patch = activation(key);
return isHomeTab(key)
? { ...patch, selectedProjectId: tabKeyId(key) }
: patch;
}),
focusTabIndex: (index) =>
set((state) => {
const key = state.tabOrder[index];
if (!key) return {};
const patch = activation(key);
return isHomeTab(key)
? { ...patch, selectedProjectId: tabKeyId(key) }
: patch;
}),
// Reordering is deliberately *only* a reordering: dragging a tab does not
// select it, so a drag can be aimed at a background tab without yanking the
// main area (and a running terminal's focus) away mid-gesture.
moveTab: (key, toIndex) =>
set((state) => {
const from = state.tabOrder.indexOf(key);
if (from === -1) return {};
const to = Math.max(0, Math.min(toIndex, state.tabOrder.length - 1));
if (from === to) return {};
const tabOrder = [...state.tabOrder];
tabOrder.splice(from, 1);
tabOrder.splice(to, 0, key);
return { tabOrder };
}),
moveActiveTab: (delta) =>
set((state) => {
const key = state.activeTabKey;
if (!key) return {};
const from = state.tabOrder.indexOf(key);
if (from === -1) return {};
// Clamped, not wrapped: a tab dragged off the end would otherwise
// reappear at the other end, which reads as a bug on a held-down key.
const to = Math.max(0, Math.min(from + delta, state.tabOrder.length - 1));
if (from === to) return {};
const tabOrder = [...state.tabOrder];
tabOrder.splice(from, 1);
tabOrder.splice(to, 0, key);
return { tabOrder };
}),
// Container progress
containerProgress: {},
setContainerProgress: (projectId, message) =>
set((state) => {
if (message === null) {
if (state.containerProgress[projectId] === undefined) return {};
const next = { ...state.containerProgress };
delete next[projectId];
return { containerProgress: next };
}
if (state.containerProgress[projectId] === message) return {};
return {
containerProgress: { ...state.containerProgress, [projectId]: message },
};
}),
runningSince: {},
// Toasts
toasts: [],
pushToast: (toast) => {
const id = `toast-${++toastCounter}`;
set((state) => ({ toasts: [...state.toasts, { ...toast, id }] }));
return id;
},
dismissToast: (id) =>
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })),
// UI state
terminalHasSelection: false,
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
sttToggle: () => {},
setSttToggle: (fn) => set({ sttToggle: fn }),
terminalAtBottom: true,
setTerminalAtBottom: (v) => set({ terminalAtBottom: v }),
scrollActiveToBottom: () => {},
setScrollActiveToBottom: (fn) => set({ scrollActiveToBottom: fn }),
sidebarView: "projects",
setSidebarView: (view) => set({ sidebarView: view }),
sidebarCollapsed: loadSidebarCollapsed(),
setSidebarCollapsed: (collapsed) => {
persistSidebarCollapsed(collapsed);
set({ sidebarCollapsed: collapsed });
},
toggleSidebarCollapsed: () =>
set((state) => {
const next = !state.sidebarCollapsed;
persistSidebarCollapsed(next);
return { sidebarCollapsed: next };
}),
dockerAvailable: null,
setDockerAvailable: (available) => set({ dockerAvailable: available }),
imageExists: null,
setImageExists: (exists) => set({ imageExists: exists }),
// App settings
appSettings: null,
setAppSettings: (settings) => set({ appSettings: settings }),
// Update info
updateInfo: null,
setUpdateInfo: (info) => set({ updateInfo: info }),
appVersion: "",
setAppVersion: (version) => set({ appVersion: version }),
// Image update info
imageUpdateInfo: null,
setImageUpdateInfo: (info) => set({ imageUpdateInfo: info }),
}));