Add Project Home, Auth Bridge, shared auth token, and Tier-1 polish
Project Home (DESIGN-REVIEW §B2): the project is promoted from a 280px
sidebar card to a first-class main-area view. ProjectCard.tsx (1,257
lines) is replaced by a select-only ProjectRow plus tabs for Overview,
Sessions, Automation, Config and Files. The PortMappings, FileManager
and ContainerProgress modals are absorbed rather than reimplemented.
Config gains a Saved/Saving/Failed indicator — save-on-blur failures
previously reached only console.error.
Tier-1 polish (DESIGN-REVIEW §A): new elevation, muted-accent, disabled
and focus-ring tokens; a global :focus-visible ring with every
focus:outline-none removed; filled buttons moved to --accent-emphasis
and white-on-success toggles retired, fixing three WCAG AA failures
(2.1:1, 2.5:1, 2.4:1); a shared Modal primitive with role="dialog",
focus trap and restore, adopted by all remaining modals; status
indicators that carry a glyph and word rather than colour alone.
Ctrl+Shift+W closes a tab, deliberately not Ctrl+W — that is readline's
kill-word, used constantly in the terminal this app is built around.
Auth Bridge: a general loopback-callback bridge so browser logins run
inside a container (aws sso login, Concourse fly login, claude login)
can complete against the host browser. Listeners are discovered from
/proc/net/tcp{,6} — ss/netstat/lsof are absent from the image — bound on
host 127.0.0.1 only, and tunnelled in over the Docker API via socat,
which keeps working on Docker Desktop where container IPs are not
routable. Falls back to [::1] because Node resolves localhost to IPv6
first, so claude login often binds ::1 alone. Opt-in per project.
This extracts create_attached_exec() and moves the existing terminal
session path onto it, so there is one attached-exec implementation
rather than two.
Shared auth token: `claude setup-token` is run in a container, the token
is stored in the OS keychain and injected as CLAUDE_CODE_OAUTH_TOKEN
into Anthropic-backend projects. Contrary to the initial design note,
setup-token uses an Anthropic-hosted redirect and blocks on a stdin
paste prompt rather than a loopback callback, so a stdin command is
required for the flow to complete.
The token is never logged, never returned to the frontend, and is
redacted from the streamed output with a stateful matcher that withholds
any tail that could still grow into a secret. Change detection uses a
random rotation id rather than a hash, since a hash in a docker-inspect
readable label would be an offline verification oracle.
Frontend 33 -> 51 tests; Rust 34 tests. Both builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import { useProjectActions } from "../../../hooks/useProjectActions";
|
||||
import { useProjects } from "../../../hooks/useProjects";
|
||||
import { useProjectSave } from "../../../hooks/useSaveState";
|
||||
import { ProjectStatusIndicator } from "../../ui/StatusIndicator";
|
||||
import Button from "../../ui/Button";
|
||||
import OverflowMenu from "../../ui/OverflowMenu";
|
||||
import ConfirmRemoveModal from "../ConfirmRemoveModal";
|
||||
import OverviewTab from "./OverviewTab";
|
||||
import SessionsTab from "./SessionsTab";
|
||||
import AutomationTab from "./AutomationTab";
|
||||
import ConfigTab from "./ConfigTab";
|
||||
import FilesTab from "./FilesTab";
|
||||
import { formatUptime } from "./format";
|
||||
|
||||
const TABS = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "sessions", label: "Sessions" },
|
||||
{ id: "automation", label: "Automation" },
|
||||
{ id: "config", label: "Config" },
|
||||
{ id: "files", label: "Files" },
|
||||
] 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");
|
||||
const [confirmRemove, setConfirmRemove] = 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),
|
||||
);
|
||||
|
||||
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";
|
||||
|
||||
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: "Reset container",
|
||||
onSelect: actions.handleReset,
|
||||
disabled: !isStopped || actions.busy,
|
||||
},
|
||||
{
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
{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} />}
|
||||
</div>
|
||||
|
||||
{confirmRemove && (
|
||||
<ConfirmRemoveModal
|
||||
projectName={project.name}
|
||||
onCancel={() => setConfirmRemove(false)}
|
||||
onConfirm={async () => {
|
||||
setConfirmRemove(false);
|
||||
try {
|
||||
await remove(project.id);
|
||||
} catch (e) {
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: `Could not remove “${project.name}”`,
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user