Files
Triple-C/app/src/App.tsx
T
shadowdaoandClaude Opus 5.5 eba0d95780
Secret Scan / scan (push) Successful in 7s
Build App (Preview) / compute-version (pull_request) Successful in 12s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / test (pull_request) Successful in 4m45s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 3m16s
Build App (Preview) / build-linux (pull_request) Successful in 5m36s
Build App (Preview) / build-windows (pull_request) Failing after 6m38s
Build App (Preview) / prune-previews (pull_request) Skipped
Build Container / build-container (pull_request) Successful in 23m46s
Keep containers alive as root; notice containers that exit on their own
The entrypoint ended in `exec su -s /bin/bash claude -c "exec sleep
infinity"`, so a project container's whole lifetime was a sleep owned by
the unprivileged claude user. Claude Code runs as claude and routinely runs
`pkill sleep`, `pkill -f`, `kill -9 -1` while testing, and any of those
stopped the container: every terminal disconnected and the app kept
showing Running.

Entrypoint: stay resident as root and wait on a root-owned sleep run as
`triple-c-keepalive infinity`, respawned if killed, with a TERM/INT/HUP
trap so `docker stop` returns at once (550 ms, exit 0, vs 2.6 s / 143
before). Unprivileged kills now get EPERM; `sudo pkill sleep` is survived
by the respawn. The drop to claude dated from the initial commit with no
stated purpose; its only reader was the marketplace readiness probe, which
now accepts either generation of entrypoint.

App: container_watch.rs holds one Docker event subscription filtered
daemon-side to container `die`. A die naming a Running project's container
is confirmed with an inspect, then ProjectsStore::mark_exited flips it to
Stopped as a compare-and-set that skips any project a command holds the
project lock on. Each (re)subscription re-checks Running projects and asks
for events since just before it. The frontend applies the new
`project-status-changed` event and toasts why the sessions died.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:57:12 -08:00

298 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { listen } from "@tauri-apps/api/event";
import Sidebar from "./components/layout/Sidebar";
import TopBar from "./components/layout/TopBar";
import StatusBar from "./components/layout/StatusBar";
import NotesDock from "./components/layout/NotesDock";
import TerminalView from "./components/terminal/TerminalView";
import DockerInstallDialog from "./components/DockerInstallDialog";
import ProjectHome from "./components/projects/home/ProjectHome";
import AddProjectDialog from "./components/projects/AddProjectDialog";
import ToastHost from "./components/ui/ToastHost";
import { PaneVisibilityProvider } from "./components/ui/PaneVisibility";
import StatusIndicator from "./components/ui/StatusIndicator";
import Button from "./components/ui/Button";
import { useDocker } from "./hooks/useDocker";
import { useSettings } from "./hooks/useSettings";
import { useProjects } from "./hooks/useProjects";
import { useUpdates } from "./hooks/useUpdates";
import { useTerminal } from "./hooks/useTerminal";
import { useSTT } from "./hooks/useSTT";
import { useContainerProgress } from "./hooks/useContainerProgress";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
import { useMarketplaceSyncToasts } from "./hooks/useMarketplace";
import { useProjectStatusEvents } from "./hooks/useProjectStatusEvents";
import MarketplaceView from "./components/marketplace/MarketplaceView";
import { useAppState, isHomeTab, tabKeyId, homeTabKey, MARKETPLACE_TAB_KEY } from "./store/appState";
import { reconcileProjectStatuses } from "./lib/tauri-commands";
export default function App() {
const { checkDocker, checkImage, startDockerPolling } = useDocker();
const { loadSettings } = useSettings();
const { refresh } = useProjects();
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
const { sessions, activeSessionId, tabOrder, activeTabKey, setProjects, setSttToggle } =
useAppState(
useShallow(s => ({
sessions: s.sessions,
activeSessionId: s.activeSessionId,
tabOrder: s.tabOrder,
activeTabKey: s.activeTabKey,
setProjects: s.setProjects,
setSttToggle: s.setSttToggle,
}))
);
const [showInstallDialog, setShowInstallDialog] = useState(false);
const [shuttingDown, setShuttingDown] = useState(false);
/**
* Everything that can only be done once Docker answers. Called from the
* startup check *and* from the poller when the daemon shows up later — a
* session that launched before Docker was ready otherwise never reconciles
* container state or recovers an interrupted migration.
*/
const onDockerReady = useCallback(async () => {
checkImage();
// Reconcile project statuses against actual Docker container state,
// then refresh the project list so the UI reflects reality.
try {
setProjects(await reconcileProjectStatuses());
} catch {
// If reconciliation fails (e.g. Docker hiccup), just load from store
refresh();
}
}, [checkImage, setProjects, refresh]);
// Single STT instance bound to the active session. The mic lives in the
// StatusBar; the terminal's Ctrl+Shift+M shortcut calls stt.toggle via the
// store (registered below).
const { sendInput } = useTerminal();
const stt = useSTT(activeSessionId ?? "", sendInput);
useEffect(() => {
setSttToggle(stt.toggle);
}, [stt.toggle, setSttToggle]);
useContainerProgress();
useKeyboardShortcuts();
useMarketplaceSyncToasts();
useProjectStatusEvents();
// Initialize on mount
useEffect(() => {
loadSettings();
let stopPolling: (() => void) | undefined;
checkDocker().then((available) => {
if (available) {
onDockerReady();
} else {
setShowInstallDialog(true);
stopPolling = startDockerPolling(onDockerReady);
}
});
refresh();
// Update detection
loadVersion();
const updateTimer = setTimeout(() => {
checkForUpdates();
checkImageUpdate();
}, 3000);
const cleanup = startPeriodicCheck();
return () => {
clearTimeout(updateTimer);
cleanup?.();
stopPolling?.();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// The backend prevents the window closing so it can stop containers first,
// which freezes the UI for several seconds. This says why.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
listen("app-shutting-down", () => setShuttingDown(true))
.then((fn) => {
if (cancelled) fn();
else unlisten = fn;
})
.catch((e) => console.error("Failed to listen for shutdown:", e));
return () => {
cancelled = true;
unlisten?.();
};
}, []);
const homeProjectIds = tabOrder.filter(isHomeTab).map(tabKeyId);
return (
<div className="flex flex-col h-screen p-3 gap-3 bg-[var(--bg-primary)]">
<TopBar />
<div className="flex flex-1 min-h-0 gap-3">
<Sidebar />
<main className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] min-w-0 overflow-hidden">
{tabOrder.length === 0 ? (
<WelcomeScreen />
) : (
<div className="w-full h-full">
{/* Every tab stays mounted and the inactive ones are merely
`hidden`, which a dialog's portal to `document.body` does not
inherit: a confirmation opened in one project stayed painted
over whatever tab the user switched to, kept its focus trap,
and — being a blocking overlay — refused every native file
drop in the window. `PaneVisibilityProvider` is how a `Modal`
inside a pane finds out the pane stepped aside. */}
{homeProjectIds.map((projectId) => (
<PaneVisibilityProvider
key={projectId}
visible={activeTabKey === homeTabKey(projectId)}
>
<ProjectHome
projectId={projectId}
active={activeTabKey === homeTabKey(projectId)}
/>
</PaneVisibilityProvider>
))}
{sessions.map((session) => (
<PaneVisibilityProvider
key={session.id}
visible={session.id === activeSessionId}
>
<TerminalView
sessionId={session.id}
active={session.id === activeSessionId}
/>
</PaneVisibilityProvider>
))}
{tabOrder.includes(MARKETPLACE_TAB_KEY) && (
<PaneVisibilityProvider visible={activeTabKey === MARKETPLACE_TAB_KEY}>
<MarketplaceView active={activeTabKey === MARKETPLACE_TAB_KEY} />
</PaneVisibilityProvider>
)}
</div>
)}
</main>
<NotesDock />
</div>
<StatusBar stt={stt} />
<ToastHost />
{showInstallDialog && (
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
)}
{shuttingDown && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg-primary)]/95 backdrop-blur-sm"
role="status"
aria-live="polite"
/* Covers the whole window, so no pane underneath may accept a
native file drop while it is up — see `lib/dropTarget.ts`. */
data-blocks-drop="true"
data-testid="shutdown-overlay"
>
<div className="flex flex-col items-center gap-2 px-6 text-center">
<StatusIndicator tone="busy" label="Shutting down" className="text-sm" />
<p className="text-[13px] text-[var(--text-secondary)]">
Stopping containers before quitting. This window will close on its own.
</p>
</div>
</div>
)}
</div>
);
}
/**
* First run is a checklist, not a paragraph: it reuses state the app already
* tracks and ends in a real button.
*/
function WelcomeScreen() {
const { dockerAvailable, imageExists, projects, openProjectHome } = useAppState(
useShallow((s) => ({
dockerAvailable: s.dockerAvailable,
imageExists: s.imageExists,
projects: s.projects,
openProjectHome: s.openProjectHome,
})),
);
const [showAdd, setShowAdd] = useState(false);
const steps: {
label: string;
state: boolean | null;
pendingLabel: string;
failLabel: string;
}[] = [
{
label: "Docker detected",
state: dockerAvailable,
pendingLabel: "Checking for Docker…",
failLabel: "Docker not available",
},
{
label: "Container image ready",
state: imageExists,
pendingLabel: "Checking for the image…",
failLabel: "Image not pulled yet — see Settings › Container",
},
{
label: `${projects.length} project${projects.length === 1 ? "" : "s"} configured`,
state: projects.length > 0 ? true : false,
pendingLabel: "",
failLabel: "No projects yet",
},
];
return (
<div className="flex items-center justify-center h-full p-6">
<div className="w-full max-w-md">
<h1 className="text-xl font-semibold text-[var(--text-primary)]">Triple-C</h1>
<p className="text-[13px] text-[var(--text-secondary)] mb-5">
Claude Code, sandboxed in a container.
</p>
<ol className="space-y-2 mb-5">
{steps.map((step) => (
<li
key={step.label}
className="flex items-center gap-2 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
>
<StatusIndicator
tone={step.state === true ? "ok" : step.state === false ? "error" : "unknown"}
label={
step.state === true
? step.label
: step.state === false
? step.failLabel
: step.pendingLabel
}
className="text-[13px]"
/>
</li>
))}
</ol>
<div className="flex items-center gap-2">
<Button size="md" variant="primary" onClick={() => setShowAdd(true)}>
{projects.length === 0 ? "Add your first project" : "Add a project"}
</Button>
{projects.length > 0 && (
<Button size="md" onClick={() => openProjectHome(projects[0].id)}>
Open {projects[0].name}
</Button>
)}
</div>
<p className="mt-4 text-xs text-[var(--text-secondary)]">
Then start its container and press{" "}
<kbd className="px-1 py-0.5 font-mono bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-[4px]">
Ctrl+T
</kbd>{" "}
to open a Claude terminal.
</p>
{showAdd && <AddProjectDialog onClose={() => setShowAdd(false)} />}
</div>
</div>
);
}