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:
+212
-22
@@ -19,6 +19,38 @@ function persistSidebarCollapsed(value: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
@@ -35,6 +67,26 @@ interface AppState {
|
||||
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;
|
||||
closeHomeTab: (projectId: string) => void;
|
||||
setActiveTabKey: (key: string) => void;
|
||||
cycleTab: (delta: number) => void;
|
||||
focusTabIndex: (index: 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;
|
||||
@@ -72,45 +124,183 @@ interface AppState {
|
||||
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({ projects }),
|
||||
setProjects: (projects) =>
|
||||
set((state) => ({
|
||||
projects,
|
||||
runningSince: trackRunning(state.projects, projects, state.runningSince),
|
||||
})),
|
||||
setSelectedProject: (id) => set({ selectedProjectId: id }),
|
||||
updateProjectInList: (project) =>
|
||||
set((state) => ({
|
||||
projects: state.projects.map((p) =>
|
||||
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) => ({
|
||||
projects: state.projects.filter((p) => p.id !== id),
|
||||
selectedProjectId:
|
||||
state.selectedProjectId === id ? null : state.selectedProjectId,
|
||||
})),
|
||||
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) => ({
|
||||
sessions: [...state.sessions, session],
|
||||
activeSessionId: session.id,
|
||||
})),
|
||||
removeSession: (id) =>
|
||||
set((state) => {
|
||||
const sessions = state.sessions.filter((s) => s.id !== id);
|
||||
const key = terminalTabKey(session.id);
|
||||
return {
|
||||
sessions,
|
||||
activeSessionId:
|
||||
state.activeSessionId === id
|
||||
? (sessions[sessions.length - 1]?.id ?? null)
|
||||
: state.activeSessionId,
|
||||
sessions: [...state.sessions, session],
|
||||
tabOrder: state.tabOrder.includes(key)
|
||||
? state.tabOrder
|
||||
: [...state.tabOrder, key],
|
||||
...activation(key),
|
||||
};
|
||||
}),
|
||||
setActiveSession: (id) => set({ activeSessionId: id }),
|
||||
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),
|
||||
};
|
||||
}),
|
||||
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;
|
||||
}),
|
||||
|
||||
// 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,
|
||||
|
||||
Reference in New Issue
Block a user