Files
Triple-C/app/src/components/projects/home/ProjectHome.tsx
T
shadow-testandClaude Sonnet 5 61bdbc4a5b
Secret Scan / scan (push) Successful in 16s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m37s
Build App (Preview) / build-windows (pull_request) Successful in 4m52s
Build App (Preview) / build-linux (pull_request) Successful in 6m17s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Close the crash-window gap and exec-session leak a third review found
A third Opus review pass confirmed round 2's fixes hold up, then found:

- The pending-cleanup record `remove_project` writes is fully durable
  (fsync'd); the projects_store.remove() that follows it is a plain
  fs::write with no fsync. A crash or power loss in that window — or that
  store write failing outright, beyond what the previous round's in-process
  rollback catches — leaves a record on disk naming a project
  projects.json still lists as present. The very next startup retry would
  then delete that project's container, snapshot image, and both volumes
  (including the one holding the OAuth credential and every session
  transcript) out from under a project the user still sees in the sidebar.
  retry_pending_cleanup_logged now takes the ProjectsStore and refuses to
  touch — clearing instead — any record whose project id still exists.
  Also stopped swallowing the round-2 rollback's own failure.
- Resolving the container through find_existing_container instead of
  project.container_id (round 2's stale-id fix) changed what drove
  close_sessions_for_container in remove_project and rebuild_project_
  container: sessions are now leaked when Docker is unreachable (nothing
  resolves, so nothing closes, and the project record is gone a moment
  later) and in the stale-id race itself (sessions were opened against the
  container that actually exists, not the id find_existing_container
  bypasses). Both functions now close sessions for the stored id
  unconditionally, and again for the resolved id if it differs.
- A pronoun-agreement bug in the no-retry removal toast ("remove them
  manually" for a single leftover) that was fixed one line above for verb
  agreement but not for the pronoun.

Also closed the test gaps the review named: the pending-cleanup
corrupt-record aside-move had no test, the Reset toast's leftover copy
was inline and untested (extracted to lib/resetOutcome.ts, mirroring
components/projects/home/removalReport.ts, with unit tests), and nothing
asserted rebuild()'s success path maps outcome.project into the list
rather than the whole outcome.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 09:47:45 -07:00

319 lines
12 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { projectRemovalIsClean } from "../../../lib/types";
import { useAppState } from "../../../store/appState";
import { useProjectActions } from "../../../hooks/useProjectActions";
import { useProjects } from "../../../hooks/useProjects";
import { useProjectSave } from "../../../hooks/useSaveState";
import { useContainerMigration } from "../../../hooks/useContainerMigration";
import { ProjectStatusIndicator } from "../../ui/StatusIndicator";
import Button from "../../ui/Button";
import OverflowMenu from "../../ui/OverflowMenu";
import ConfirmRemoveModal from "../ConfirmRemoveModal";
import ConfirmResetModal from "../ConfirmResetModal";
import MigrateContainerModal from "../MigrateContainerModal";
import OverviewTab from "./OverviewTab";
import SessionsTab from "./SessionsTab";
import AutomationTab from "./AutomationTab";
import ConfigTab from "./ConfigTab";
import FilesTab from "./FilesTab";
import BrowserTab from "./BrowserTab";
import { formatUptime } from "./format";
import { describeLeftovers, leftoverPronoun, leftoverVerb } from "./removalReport";
const TABS = [
{ id: "overview", label: "Overview" },
{ id: "sessions", label: "Sessions" },
{ id: "automation", label: "Automation" },
{ id: "config", label: "Config" },
{ id: "files", label: "Files" },
{ id: "browser", label: "Browser" },
] as const;
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
interface Props {
projectId: string;
active: boolean;
}
/**
* The project promoted from a sidebar card to a first-class main-area view.
* Everything that used to spray out of `ProjectCard` as a modal lives here.
*/
export default function ProjectHome({ projectId, active }: Props) {
const { projects, remove } = useProjects();
const project = projects.find((p) => p.id === projectId);
const [tab, setTab] = useState<ProjectHomeTabId>("overview");
// Somewhere else asked for this project on a particular sub-tab — currently
// "I opened a page in the container's browser, show me it". Consumed once, so
// it cannot fight the user's own clicking afterwards.
const pendingHomeTab = useAppState((s) => s.pendingHomeTab);
useEffect(() => {
if (pendingHomeTab?.projectId !== projectId) return;
setTab(pendingHomeTab.tab as ProjectHomeTabId);
useAppState.getState().clearPendingHomeTab();
}, [pendingHomeTab, projectId]);
const [confirmRemove, setConfirmRemove] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const [showMigration, setShowMigration] = useState(false);
const { runningSince, progress } = useAppState(
useShallow((s) => ({
runningSince: s.runningSince[projectId],
progress: s.containerProgress[projectId],
})),
);
// Re-render once a minute so the uptime line stays honest.
const [, setTick] = useState(0);
useEffect(() => {
if (!active || runningSince === undefined) return;
const timer = setInterval(() => setTick((t) => t + 1), 60_000);
return () => clearInterval(timer);
}, [active, runningSince]);
const actions = useProjectActions(
project ?? ({ id: projectId, name: "", container_id: null } as never),
);
const { save, saveState } = useProjectSave(
project ?? ({ id: projectId, name: "" } as never),
);
// Owned here, not in the modal: the run outlives the dialog, and the Overview
// banner has to keep showing progress and the report after it is dismissed.
const migration = useContainerMigration(
project ?? ({ id: projectId, name: "", container_id: null } as never),
);
const uptime = useMemo(() => formatUptime(runningSince), [runningSince]);
if (!project) {
return (
<div className={`h-full flex items-center justify-center ${active ? "" : "hidden"}`}>
<p className="text-[13px] text-[var(--text-secondary)]">
This project is no longer available.
</p>
</div>
);
}
const isRunning = project.status === "running";
const isTransitioning =
project.status === "starting" || project.status === "stopping";
const isStopped = project.status === "stopped" || project.status === "error";
// Rebuilding on a new base swaps the container out, so it gates exactly like
// Reset does — with the extra condition that there is a container to migrate.
// An interrupted migration is excluded too: its action is Resume, on the
// Overview banner, not a fresh pre-flight.
//
// `probeSettled` is the fourth condition and it is not cosmetic. The probe
// takes ~6 s, and until it lands every delta the pre-flight renders reads as
// empty — so the dialog would tell the user there was nothing to copy while
// the backend was told not to copy anything.
const canMigrate =
isStopped &&
!actions.busy &&
!migration.running &&
!migration.interrupted &&
migration.probeSettled &&
!!project.container_id;
return (
<div className={`flex flex-col h-full min-h-0 ${active ? "" : "hidden"}`}>
{/* Header */}
<header className="flex-shrink-0 px-4 pt-3 pb-2 border-b border-[var(--border-color)]">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0">
<h1 className="text-base font-semibold text-[var(--text-primary)] truncate">
{project.name}
</h1>
<div className="mt-0.5 flex items-center gap-2 text-xs">
<ProjectStatusIndicator status={project.status} />
{isRunning && uptime && (
<span className="text-[var(--text-secondary)]">· {uptime}</span>
)}
{isTransitioning && progress && (
<span className="text-[var(--warning)] truncate">· {progress}</span>
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{isRunning ? (
<Button
size="md"
variant="primary"
disabled={actions.busy}
onClick={actions.openClaudeTerminal}
>
Open Claude Terminal
</Button>
) : (
<Button
size="md"
variant="primary"
disabled={actions.busy || isTransitioning}
onClick={actions.handleStart}
>
Start
</Button>
)}
{isRunning && (
<>
<Button size="md" onClick={actions.openShell}>
Shell
</Button>
<Button size="md" onClick={() => setTab("files")}>
Files
</Button>
<Button size="md" disabled={actions.busy} onClick={actions.handleStop}>
Stop
</Button>
</>
)}
{isTransitioning && (
<Button size="md" variant="danger" onClick={actions.handleStop}>
Force stop
</Button>
)}
<OverflowMenu
items={[
{
label: actions.backingUp ? "Backing up…" : "Back up container",
onSelect: actions.handleBackup,
disabled: actions.backingUp || !project.container_id,
},
{
label: "Update container base…",
onSelect: () => setShowMigration(true),
disabled: !canMigrate,
},
{
label: "Reset container…",
onSelect: () => setConfirmReset(true),
disabled: !isStopped || actions.busy,
danger: true,
},
{
label: "Remove project…",
onSelect: () => setConfirmRemove(true),
danger: true,
},
]}
/>
</div>
</div>
{/* Tabs */}
<div role="tablist" aria-label="Project sections" className="flex gap-1 mt-3 -mb-2">
{TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`project-tab-${projectId}-${t.id}`}
aria-selected={tab === t.id}
aria-controls={`project-panel-${projectId}-${t.id}`}
onClick={() => setTab(t.id)}
className={`px-3 h-8 text-[13px] font-medium rounded-t-[var(--radius-control)] border-b-2 transition-colors ${
tab === t.id
? "text-[var(--text-primary)] border-[var(--accent)]"
: "text-[var(--text-secondary)] border-transparent hover:text-[var(--text-primary)]"
}`}
>
{t.label}
</button>
))}
</div>
</header>
{/* Panel */}
<div
role="tabpanel"
id={`project-panel-${projectId}-${tab}`}
aria-labelledby={`project-tab-${projectId}-${tab}`}
className="flex-1 min-h-0 overflow-y-auto"
>
{tab === "overview" && (
<OverviewTab
project={project}
save={save}
saveState={saveState}
actions={actions}
onOpenTab={setTab}
migration={migration}
canMigrate={canMigrate}
onOpenMigration={() => setShowMigration(true)}
/>
)}
{tab === "sessions" && <SessionsTab project={project} actions={actions} />}
{tab === "automation" && <AutomationTab project={project} />}
{tab === "config" && (
<ConfigTab project={project} save={save} saveState={saveState} />
)}
{tab === "files" && <FilesTab project={project} />}
{tab === "browser" && (
<BrowserTab project={project} active={active && tab === "browser"} />
)}
</div>
{showMigration && (
<MigrateContainerModal
projectName={project.name}
staleness={migration.staleness}
migration={migration}
// Closing is not cancelling — the run keeps going and the Overview
// banner keeps reporting it.
onClose={() => setShowMigration(false)}
/>
)}
{confirmReset && (
<ConfirmResetModal
projectName={project.name}
onCancel={() => setConfirmReset(false)}
onConfirm={() => {
setConfirmReset(false);
actions.handleReset();
}}
/>
)}
{confirmRemove && (
<ConfirmRemoveModal
projectName={project.name}
onCancel={() => setConfirmRemove(false)}
onConfirm={async () => {
setConfirmRemove(false);
try {
const report = await remove(project.id);
if (!projectRemovalIsClean(report)) {
const verb = leftoverVerb(report);
if (report.retry_scheduled) {
useAppState.getState().pushToast({
kind: "info",
message: `“${project.name}” was removed, but Triple-C could not confirm all its Docker resources were removed`,
detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed. It will check again the next time it starts.`,
});
} else {
// The pending-cleanup record itself failed to save — no
// retry will happen, so this must not promise one.
useAppState.getState().pushToast({
kind: "error",
message: `“${project.name}” was removed, but Triple-C could not confirm its Docker resources were removed`,
detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed, and could not record this for a retry. You may need to remove ${leftoverPronoun(report)} manually (\`docker rm\` / \`docker rmi\` / \`docker volume rm\`).`,
});
}
}
} catch (e) {
useAppState.getState().pushToast({
kind: "error",
message: `Could not remove “${project.name}”`,
detail: String(e),
});
}
}}
/>
)}
</div>
);
}