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:
2026-08-09 11:35:42 -07:00
co-authored by Claude Opus 5
parent f68d10d5c2
commit 01a2f6aec8
83 changed files with 8042 additions and 3064 deletions
+105
View File
@@ -0,0 +1,105 @@
import { useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppState, type Toast } from "../../store/appState";
const TONE: Record<Toast["kind"], { border: string; bg: string; fg: string; glyph: string }> = {
error: {
border: "var(--error)",
bg: "var(--error-muted)",
fg: "var(--error)",
glyph: "▲",
},
success: {
border: "var(--success)",
bg: "var(--success-muted)",
fg: "var(--success)",
glyph: "✓",
},
info: {
border: "var(--border-color)",
bg: "var(--accent-muted)",
fg: "var(--accent)",
glyph: "●",
},
};
function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
const [expanded, setExpanded] = useState(false);
const tone = TONE[toast.kind];
// Errors stay until dismissed; transient confirmations time out.
useEffect(() => {
if (toast.kind === "error") return;
const timer = setTimeout(onDismiss, 6000);
return () => clearTimeout(timer);
}, [toast.kind, onDismiss]);
return (
<div
className="animate-toast-in flex items-start gap-2 w-[24rem] max-w-[calc(100vw-2rem)] px-3 py-2 rounded-[var(--radius-panel)] border text-xs"
style={{
borderColor: tone.border,
background: `color-mix(in srgb, var(--bg-overlay) 88%, ${tone.bg})`,
boxShadow: "var(--shadow-overlay)",
}}
>
<span aria-hidden="true" className="mt-[1px] leading-none" style={{ color: tone.fg }}>
{tone.glyph}
</span>
<div className="flex-1 min-w-0">
<div className="text-[var(--text-primary)] break-words">{toast.message}</div>
{toast.detail && (
<>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
aria-expanded={expanded}
className="mt-1 text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
>
{expanded ? "Hide details" : "Details"}
</button>
{expanded && (
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2">
{toast.detail}
</pre>
)}
</>
)}
</div>
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss notification"
className="flex-shrink-0 w-5 h-5 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
<span aria-hidden="true">✕</span>
</button>
</div>
);
}
/** Bottom-right stack. Errors get a home here instead of a 12px card line. */
export default function ToastHost() {
const { toasts, dismissToast } = useAppState(
useShallow((s) => ({ toasts: s.toasts, dismissToast: s.dismissToast })),
);
if (toasts.length === 0) return null;
return (
<div
className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 items-end"
role="region"
aria-label="Notifications"
aria-live="polite"
>
{toasts.map((toast) => (
<ToastCard
key={toast.id}
toast={toast}
onDismiss={() => dismissToast(toast.id)}
/>
))}
</div>
);
}