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:
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useInstallHelper } from "../hooks/useInstallHelper";
|
||||
import { useDocker } from "../hooks/useDocker";
|
||||
import Modal from "./ui/Modal";
|
||||
import Button from "./ui/Button";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@@ -16,27 +18,11 @@ export default function DockerInstallDialog({ onClose }: Props) {
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadOptions();
|
||||
}, [loadOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && phase !== "installing") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose, phase]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current && phase !== "installing") onClose();
|
||||
},
|
||||
[onClose, phase],
|
||||
);
|
||||
|
||||
const handleInstall = async () => {
|
||||
setPhase("installing");
|
||||
setLog([]);
|
||||
@@ -70,142 +56,122 @@ export default function DockerInstallDialog({ onClose }: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const installVerb = phase === "installing" ? "Installing…" : `Install ${options.product_name}`;
|
||||
const installVerb =
|
||||
phase === "installing" ? "Installing…" : `Install ${options.product_name}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
<Modal
|
||||
title="Docker not detected"
|
||||
onClose={onClose}
|
||||
widthClassName="w-[34rem]"
|
||||
// Closing mid-install would orphan a privileged installer.
|
||||
dismissible={phase !== "installing"}
|
||||
footer={
|
||||
phase === "idle" ? (
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Dismiss
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[32rem] max-h-[85vh] overflow-y-auto shadow-xl">
|
||||
<h2 className="text-lg font-semibold mb-1">Docker not detected</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
||||
Triple-C needs a Docker-compatible runtime to manage sandboxed project containers.
|
||||
We can install <span className="text-[var(--text-primary)]">{options.product_name}</span>{" "}
|
||||
for you, or you can follow the official instructions.
|
||||
</p>
|
||||
<p className="text-[13px] text-[var(--text-secondary)] mb-4">
|
||||
Triple-C needs a Docker-compatible runtime to manage sandboxed project
|
||||
containers. We can install{" "}
|
||||
<span className="text-[var(--text-primary)]">{options.product_name}</span> for
|
||||
you, or you can follow the official instructions.
|
||||
</p>
|
||||
|
||||
{phase === "idle" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.can_auto_install ? (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="px-3 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
{installVerb} ({options.auto_install_method})
|
||||
</button>
|
||||
) : (
|
||||
<div className="text-xs text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2">
|
||||
One-click install unavailable:{" "}
|
||||
<span className="text-[var(--text-primary)]">
|
||||
{options.auto_install_blocker ?? "required tooling missing."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowManual((s) => !s)}
|
||||
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
{showManual ? "Hide manual instructions" : "Show manual instructions"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleOpenDocs}
|
||||
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Open official documentation ↗
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "installing" && (
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
Installing… a system password prompt may appear. Do not close this window.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "done" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm text-[var(--success)]">Install finished.</div>
|
||||
{options.post_install_notes.length > 0 && (
|
||||
<ul className="text-xs text-[var(--text-secondary)] list-disc list-inside space-y-1">
|
||||
{options.post_install_notes.map((note, i) => (
|
||||
<li key={i}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={handleRecheck}
|
||||
className="px-3 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
Re-check Docker
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
{phase === "idle" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.can_auto_install ? (
|
||||
<Button size="md" variant="primary" onClick={handleInstall}>
|
||||
{installVerb} ({options.auto_install_method})
|
||||
</Button>
|
||||
) : (
|
||||
<div className="text-xs text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2">
|
||||
One-click install unavailable:{" "}
|
||||
<span className="text-[var(--text-primary)]">
|
||||
{options.auto_install_blocker ?? "required tooling missing."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{phase === "error" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm text-[var(--error)]">Install failed.</div>
|
||||
{error && <div className="text-xs font-mono text-[var(--error)]">{error}</div>}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => setPhase("idle")}
|
||||
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenDocs}
|
||||
className="px-3 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
Open official docs ↗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button size="md" onClick={() => setShowManual((s) => !s)}>
|
||||
{showManual ? "Hide manual instructions" : "Show manual instructions"}
|
||||
</Button>
|
||||
|
||||
{(showManual || phase === "error") && (
|
||||
<div className="mt-4">
|
||||
<div className="text-xs font-medium mb-1.5 text-[var(--text-secondary)]">
|
||||
Manual install steps
|
||||
</div>
|
||||
<ol className="text-xs text-[var(--text-secondary)] list-decimal list-inside space-y-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2">
|
||||
{options.manual_steps.map((step, i) => (
|
||||
<li key={i}>{step}</li>
|
||||
<Button size="md" onClick={handleOpenDocs}>
|
||||
Open official documentation ↗
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "installing" && (
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
Installing… a system password prompt may appear. Do not close this window.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "done" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-[13px] text-[var(--success)]">Install finished.</div>
|
||||
{options.post_install_notes.length > 0 && (
|
||||
<ul className="text-xs text-[var(--text-secondary)] list-disc list-inside space-y-1">
|
||||
{options.post_install_notes.map((note, i) => (
|
||||
<li key={i}>{note}</li>
|
||||
))}
|
||||
</ol>
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button size="md" variant="primary" onClick={handleRecheck}>
|
||||
Re-check Docker
|
||||
</Button>
|
||||
<Button size="md" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.length > 0 && (
|
||||
<div className="mt-4 max-h-48 overflow-y-auto bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2 text-xs font-mono text-[var(--text-secondary)]">
|
||||
{log.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
{phase === "error" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-[13px] text-[var(--error)]">Install failed.</div>
|
||||
{error && (
|
||||
<div className="text-xs font-mono text-[var(--error)] break-words">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button size="md" onClick={() => setPhase("idle")}>
|
||||
Back
|
||||
</Button>
|
||||
<Button size="md" variant="primary" onClick={handleOpenDocs}>
|
||||
Open official docs ↗
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(showManual || phase === "error") && (
|
||||
<div className="mt-4">
|
||||
<div className="text-xs font-medium mb-1.5 text-[var(--text-secondary)]">
|
||||
Manual install steps
|
||||
</div>
|
||||
<ol className="text-xs text-[var(--text-secondary)] list-decimal list-inside space-y-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2">
|
||||
{options.manual_steps.map((step, i) => (
|
||||
<li key={i}>{step}</li>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "idle" && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{log.length > 0 && (
|
||||
<div className="mt-4 max-h-48 overflow-y-auto bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2 text-xs font-mono text-[var(--text-secondary)]">
|
||||
{log.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { getHelpContent } from "../../lib/tauri-commands";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@@ -140,32 +142,16 @@ function renderMarkdown(md: string): string {
|
||||
}
|
||||
|
||||
export default function HelpDialog({ onClose }: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [markdown, setMarkdown] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
getHelpContent()
|
||||
.then(setMarkdown)
|
||||
.catch((e) => setError(String(e)));
|
||||
}, []);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
// Handle anchor link clicks to scroll within the dialog
|
||||
const handleContentClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -179,40 +165,25 @@ export default function HelpDialog({ onClose }: Props) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
<Modal
|
||||
title="How to Use Triple-C"
|
||||
onClose={onClose}
|
||||
widthClassName="w-[48rem]"
|
||||
footer={<Button onClick={onClose}>Close</Button>}
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg shadow-xl w-[48rem] max-w-[90vw] max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)] flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold">How to Use Triple-C</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
onClick={handleContentClick}
|
||||
className="flex-1 overflow-y-auto px-6 py-4 help-content"
|
||||
>
|
||||
{error && (
|
||||
<p className="text-[var(--error)] text-sm">Failed to load help content: {error}</p>
|
||||
)}
|
||||
{!markdown && !error && (
|
||||
<p className="text-[var(--text-secondary)] text-sm">Loading...</p>
|
||||
)}
|
||||
{markdown && (
|
||||
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown) }} />
|
||||
)}
|
||||
</div>
|
||||
<div ref={contentRef} onClick={handleContentClick} className="help-content">
|
||||
{error && (
|
||||
<p className="text-[var(--error)] text-sm">
|
||||
Failed to load help content: {error}
|
||||
</p>
|
||||
)}
|
||||
{!markdown && !error && (
|
||||
<p className="text-[var(--text-secondary)] text-sm">Loading…</p>
|
||||
)}
|
||||
{markdown && (
|
||||
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown) }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useProjects } from "../../hooks/useProjects";
|
||||
import {
|
||||
useAppState,
|
||||
isHomeTab,
|
||||
tabKeyId,
|
||||
terminalTabKey,
|
||||
} from "../../store/appState";
|
||||
import { effectivePermissionMode } from "../projects/PermissionModeControl";
|
||||
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||
import type { PermissionMode } from "../../lib/types";
|
||||
|
||||
interface ContextMenuState {
|
||||
sessionId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> = {
|
||||
plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
||||
default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
||||
acceptEdits: { text: "edits", className: "bg-[var(--accent-muted)] text-[var(--accent)]" },
|
||||
bypass: { text: "bypass", className: "bg-[var(--warning-muted)] text-[var(--warning)]" },
|
||||
};
|
||||
|
||||
/**
|
||||
* One strip for both main-area tab kinds: Project Home views (⌂) and
|
||||
* terminals (▣).
|
||||
*/
|
||||
export default function MainTabs() {
|
||||
const { sessions, close } = useTerminal();
|
||||
const { projects, update } = useProjects();
|
||||
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab } = useAppState(
|
||||
useShallow((s) => ({
|
||||
tabOrder: s.tabOrder,
|
||||
activeTabKey: s.activeTabKey,
|
||||
setActiveTabKey: s.setActiveTabKey,
|
||||
closeHomeTab: s.closeHomeTab,
|
||||
})),
|
||||
);
|
||||
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const dismiss = () => setMenu(null);
|
||||
window.addEventListener("click", dismiss);
|
||||
window.addEventListener("scroll", dismiss, true);
|
||||
return () => {
|
||||
window.removeEventListener("click", dismiss);
|
||||
window.removeEventListener("scroll", dismiss, true);
|
||||
};
|
||||
}, [menu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (renamingId) {
|
||||
renameInputRef.current?.focus();
|
||||
renameInputRef.current?.select();
|
||||
}
|
||||
}, [renamingId]);
|
||||
|
||||
if (tabOrder.length === 0) {
|
||||
return (
|
||||
<div className="px-3 text-xs text-[var(--text-secondary)] leading-10">
|
||||
No open tabs — select a project to open its home view.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getCustomName = (projectId: string, sessionId: string): string | null => {
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
return project?.renamed_session_names?.[sessionId] ?? null;
|
||||
};
|
||||
|
||||
const startRename = (sessionId: string) => {
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (!session) return;
|
||||
const current =
|
||||
getCustomName(session.projectId, sessionId) ??
|
||||
session.sessionName ??
|
||||
session.projectName;
|
||||
setRenameDraft(current);
|
||||
setRenamingId(sessionId);
|
||||
setMenu(null);
|
||||
};
|
||||
|
||||
const commitRename = async (sessionId: string) => {
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (!session) {
|
||||
setRenamingId(null);
|
||||
return;
|
||||
}
|
||||
const project = projects.find((p) => p.id === session.projectId);
|
||||
if (!project) {
|
||||
setRenamingId(null);
|
||||
return;
|
||||
}
|
||||
const trimmed = renameDraft.trim();
|
||||
const map = { ...(project.renamed_session_names ?? {}) };
|
||||
if (trimmed) {
|
||||
map[sessionId] = trimmed;
|
||||
} else {
|
||||
delete map[sessionId];
|
||||
}
|
||||
try {
|
||||
await update({ ...project, renamed_session_names: map });
|
||||
} catch (err) {
|
||||
console.error("Failed to rename terminal tab:", err);
|
||||
} finally {
|
||||
setRenamingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCustomName = async (sessionId: string) => {
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (!session) return;
|
||||
const project = projects.find((p) => p.id === session.projectId);
|
||||
if (!project) return;
|
||||
const map = { ...(project.renamed_session_names ?? {}) };
|
||||
if (!(sessionId in map)) {
|
||||
setMenu(null);
|
||||
return;
|
||||
}
|
||||
delete map[sessionId];
|
||||
try {
|
||||
await update({ ...project, renamed_session_names: map });
|
||||
} catch (err) {
|
||||
console.error("Failed to reset terminal tab name:", err);
|
||||
} finally {
|
||||
setMenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
const tabClass = (active: boolean) =>
|
||||
`flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${
|
||||
active
|
||||
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-full" role="tablist" aria-label="Open tabs">
|
||||
{tabOrder.map((key) => {
|
||||
const active = activeTabKey === key;
|
||||
|
||||
if (isHomeTab(key)) {
|
||||
const projectId = tabKeyId(key);
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
if (!project) return null;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={0}
|
||||
onClick={() => setActiveTabKey(key)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setActiveTabKey(key);
|
||||
}
|
||||
}}
|
||||
className={tabClass(active)}
|
||||
>
|
||||
<span aria-hidden="true" className="text-[var(--text-secondary)]">⌂</span>
|
||||
<span className="truncate max-w-[160px]" title={`${project.name} — project home`}>
|
||||
{project.name}
|
||||
</span>
|
||||
<ProjectStatusIndicator status={project.status} iconOnly />
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeHomeTab(projectId);
|
||||
}}
|
||||
aria-label={`Close ${project.name} home tab`}
|
||||
title="Close tab"
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sessionId = tabKeyId(key);
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (!session) return null;
|
||||
const project = projects.find((p) => p.id === session.projectId);
|
||||
const customName = getCustomName(session.projectId, session.id);
|
||||
const baseLabel =
|
||||
(session.sessionName ?? session.projectName) +
|
||||
(session.sessionType === "bash" ? " (bash)" : "");
|
||||
const displayLabel = customName
|
||||
? `${session.projectName}: ${customName}`
|
||||
: baseLabel;
|
||||
const isRenaming = renamingId === session.id;
|
||||
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={0}
|
||||
onClick={() => setActiveTabKey(terminalTabKey(session.id))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setActiveTabKey(terminalTabKey(session.id));
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
onDoubleClick={() => startRename(session.id)}
|
||||
className={tabClass(active)}
|
||||
>
|
||||
<span aria-hidden="true" className="text-[var(--text-secondary)]">▣</span>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameDraft}
|
||||
aria-label="Rename tab"
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onBlur={() => commitRename(session.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
if (e.key === "Escape") setRenamingId(null);
|
||||
}}
|
||||
className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate max-w-[180px]" title={displayLabel}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
)}
|
||||
{badge && (
|
||||
<span
|
||||
className={`px-1 py-0.5 rounded-[4px] text-[10px] leading-none font-medium ${badge.className}`}
|
||||
title={`Permission mode: ${badge.text}`}
|
||||
>
|
||||
{badge.text}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
close(session.id);
|
||||
}}
|
||||
aria-label={`Close ${displayLabel}`}
|
||||
title="Close terminal"
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{menu && (() => {
|
||||
const session = sessions.find((s) => s.id === menu.sessionId);
|
||||
const hasCustom = session
|
||||
? !!getCustomName(session.projectId, menu.sessionId)
|
||||
: false;
|
||||
return (
|
||||
<div
|
||||
role="menu"
|
||||
className="fixed z-50 min-w-[160px] py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs"
|
||||
style={{ top: menu.y, left: menu.x, boxShadow: "var(--shadow-overlay)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
onClick={() => startRename(menu.sessionId)}
|
||||
>
|
||||
Rename tab
|
||||
</button>
|
||||
{hasCustom && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="w-full text-left px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
onClick={() => clearCustomName(menu.sessionId)}
|
||||
>
|
||||
Reset name
|
||||
</button>
|
||||
)}
|
||||
{session && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
onClick={() => {
|
||||
useAppState.getState().openProjectHome(session.projectId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
Open project home
|
||||
</button>
|
||||
)}
|
||||
<div className="border-t border-[var(--border-color)] my-1" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="w-full text-left px-3 py-1.5 text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
onClick={() => {
|
||||
close(menu.sessionId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
Close tab
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,12 @@ describe("Sidebar", () => {
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the project list, not a settings form, in the projects view", () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByTestId("project-list")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("settings-panel")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("content area has min-w-0 to prevent flex overflow", () => {
|
||||
const { container } = render(<Sidebar />);
|
||||
const contentArea = container.querySelector(".overflow-y-auto");
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function Sidebar() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-12 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||
<div className="flex flex-col h-full w-12 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden">
|
||||
<button
|
||||
onClick={toggleSidebarCollapsed}
|
||||
title="Expand sidebar"
|
||||
@@ -89,7 +89,7 @@ export default function Sidebar() {
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-[25%] min-w-56 max-w-80 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||
<div className="flex flex-col h-full w-[25%] min-w-56 max-w-80 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden">
|
||||
{/* Nav tabs */}
|
||||
<div className="flex border-b border-[var(--border-color)]">
|
||||
<button onClick={() => setSidebarView("projects")} className={tabCls("projects")}>
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function StatusBar({ stt }: Props) {
|
||||
const running = projects.filter((p) => p.status === "running").length;
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg text-xs text-[var(--text-secondary)]">
|
||||
<div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs text-[var(--text-secondary)]">
|
||||
<span>
|
||||
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import TerminalTabs from "../terminal/TerminalTabs";
|
||||
import MainTabs from "./MainTabs";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import UpdateDialog from "../settings/UpdateDialog";
|
||||
import ImageUpdateDialog from "../settings/ImageUpdateDialog";
|
||||
import HelpDialog from "./HelpDialog";
|
||||
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
||||
|
||||
export default function TopBar() {
|
||||
const { dockerAvailable, imageExists, updateInfo, imageUpdateInfo, appVersion, setUpdateInfo, setImageUpdateInfo } = useAppState(
|
||||
@@ -48,34 +49,48 @@ export default function TopBar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||
<div className="flex-1 overflow-x-auto pl-2">
|
||||
<TerminalTabs />
|
||||
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden">
|
||||
<div className="flex-1 overflow-x-auto pl-1">
|
||||
<MainTabs />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 flex-shrink-0 text-xs text-[var(--text-secondary)]">
|
||||
<div className="flex items-center gap-3 px-3 flex-shrink-0 text-xs text-[var(--text-secondary)]">
|
||||
{updateInfo && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUpdateDialog(true)}
|
||||
className="px-2 py-0.5 rounded text-xs font-medium bg-[var(--accent)] text-white animate-pulse hover:bg-[var(--accent-hover)] transition-colors"
|
||||
className="h-6 px-2 rounded-[var(--radius-control)] text-xs font-medium bg-[var(--accent-emphasis)] text-white hover:bg-[var(--accent-emphasis-hover)] transition-colors"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
{imageUpdateInfo && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowImageUpdateDialog(true)}
|
||||
className="px-2 py-0.5 rounded text-xs font-medium bg-[var(--warning,#f59e0b)] text-white hover:opacity-80 transition-colors"
|
||||
className="h-6 px-2 rounded-[var(--radius-control)] text-xs font-medium bg-[var(--warning-emphasis)] text-white hover:opacity-90 transition-colors"
|
||||
title="A newer container image is available"
|
||||
>
|
||||
Image Update
|
||||
</button>
|
||||
)}
|
||||
<StatusDot ok={dockerAvailable === true} label="Docker" />
|
||||
<StatusDot ok={imageExists === true} label="Image" />
|
||||
<HealthDot
|
||||
state={dockerAvailable}
|
||||
okLabel="Docker"
|
||||
failLabel="Docker unavailable"
|
||||
pendingLabel="Docker — checking"
|
||||
/>
|
||||
<HealthDot
|
||||
state={imageExists}
|
||||
okLabel="Image"
|
||||
failLabel="Image missing"
|
||||
pendingLabel="Image — checking"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHelpDialog(true)}
|
||||
title="Help"
|
||||
className="ml-1 w-5 h-5 flex items-center justify-center rounded-full border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] transition-colors text-xs font-semibold leading-none"
|
||||
aria-label="Help"
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] transition-colors text-xs font-semibold leading-none"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
@@ -103,15 +118,29 @@ export default function TopBar() {
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ ok, label }: { ok: boolean; label: string }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full ${
|
||||
ok ? "bg-[var(--success)]" : "bg-[var(--text-secondary)]"
|
||||
}`}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
/**
|
||||
* `null` (still checking) is visually distinct and pulses; `false` is an
|
||||
* outage and renders red — previously both fell through to the same gray dot.
|
||||
*/
|
||||
function HealthDot({
|
||||
state,
|
||||
okLabel,
|
||||
failLabel,
|
||||
pendingLabel,
|
||||
}: {
|
||||
state: boolean | null;
|
||||
okLabel: string;
|
||||
failLabel: string;
|
||||
pendingLabel: string;
|
||||
}) {
|
||||
let tone: StatusTone = "unknown";
|
||||
let label = pendingLabel;
|
||||
if (state === true) {
|
||||
tone = "ok";
|
||||
label = okLabel;
|
||||
} else if (state === false) {
|
||||
tone = "error";
|
||||
label = failLabel;
|
||||
}
|
||||
return <StatusIndicator tone={tone} label={label} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { useProjects } from "../../hooks/useProjects";
|
||||
import type { ProjectPath } from "../../lib/types";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import { inputClass, monoInputClass } from "../ui/Field";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@@ -25,26 +28,7 @@ export default function AddProjectDialog({ onClose }: Props) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
nameInputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const formId = useId();
|
||||
|
||||
const handleBrowse = async (index: number) => {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
@@ -63,24 +47,12 @@ export default function AddProjectDialog({ onClose }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const updateEntry = (
|
||||
index: number,
|
||||
field: keyof PathEntry,
|
||||
value: string,
|
||||
) => {
|
||||
const updateEntry = (index: number, field: keyof PathEntry, value: string) => {
|
||||
const entries = [...pathEntries];
|
||||
entries[index] = { ...entries[index], [field]: value };
|
||||
setPathEntries(entries);
|
||||
};
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
setPathEntries(pathEntries.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
setPathEntries([...pathEntries, { host_path: "", mount_name: "" }]);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
@@ -115,98 +87,106 @@ export default function AddProjectDialog({ onClose }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
<Modal
|
||||
title="Add Project"
|
||||
onClose={onClose}
|
||||
widthClassName="w-[30rem]"
|
||||
initialFocusRef={nameInputRef}
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="md" variant="primary" type="submit" form={formId} disabled={loading}>
|
||||
{loading ? "Adding…" : "Add Project"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[28rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
||||
<h2 className="text-lg font-semibold mb-4">Add Project</h2>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
||||
Project Name
|
||||
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`${formId}-name`}
|
||||
className="block text-[13px] font-medium mb-1"
|
||||
>
|
||||
Project name
|
||||
</label>
|
||||
<input
|
||||
id={`${formId}-name`}
|
||||
ref={nameInputRef}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="my-project"
|
||||
className="w-full px-3 py-2 mb-3 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
||||
Folders
|
||||
</label>
|
||||
<div className="space-y-2 mb-3">
|
||||
<div>
|
||||
<span className="block text-[13px] font-medium mb-1">Folders</span>
|
||||
<div className="space-y-2">
|
||||
{pathEntries.map((entry, i) => (
|
||||
<div key={i} className="space-y-1 p-2 bg-[var(--bg-primary)] rounded border border-[var(--border-color)]">
|
||||
<div className="flex gap-1">
|
||||
<div
|
||||
key={i}
|
||||
className="space-y-1.5 p-2 bg-[var(--bg-primary)] rounded-[var(--radius-control)] border border-[var(--border-color)]"
|
||||
>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
value={entry.host_path}
|
||||
onChange={(e) => updateEntry(i, "host_path", e.target.value)}
|
||||
placeholder="/path/to/folder"
|
||||
className="flex-1 px-2 py-1.5 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
aria-label={`Folder ${i + 1} host path`}
|
||||
className={inputClass}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBrowse(i)}
|
||||
className="px-2 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
<Button size="md" onClick={() => handleBrowse(i)}>
|
||||
Browse
|
||||
</button>
|
||||
</Button>
|
||||
{pathEntries.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEntry(i)}
|
||||
className="px-1.5 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
||||
<Button
|
||||
size="md"
|
||||
variant="danger"
|
||||
aria-label={`Remove folder ${i + 1}`}
|
||||
onClick={() =>
|
||||
setPathEntries(pathEntries.filter((_, j) => j !== i))
|
||||
}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">/workspace/</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0 font-mono">
|
||||
/workspace/
|
||||
</span>
|
||||
<input
|
||||
value={entry.mount_name}
|
||||
onChange={(e) => updateEntry(i, "mount_name", e.target.value)}
|
||||
placeholder="mount-name"
|
||||
className="flex-1 px-2 py-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] font-mono"
|
||||
aria-label={`Folder ${i + 1} mount name`}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addEntry}
|
||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] mb-4 transition-colors"
|
||||
<Button
|
||||
className="mt-2"
|
||||
onClick={() =>
|
||||
setPathEntries([...pathEntries, { host_path: "", mount_name: "" }])
|
||||
}
|
||||
>
|
||||
+ Add folder
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-[var(--error)] mb-3">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? "Adding..." : "Add Project"}
|
||||
</button>
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="px-2 py-1.5 text-xs text-[var(--error)] bg-[var(--error-muted)] border border-[var(--error)]/30 rounded-[var(--radius-control)]"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ClaudeCodeSettings } from "../../lib/types";
|
||||
import Toggle from "../ui/Toggle";
|
||||
import { SwitchRow, selectClass } from "../ui/Field";
|
||||
|
||||
interface Props {
|
||||
settings: ClaudeCodeSettings | null;
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
onSave: (settings: ClaudeCodeSettings | null) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
|
||||
tui_mode: null,
|
||||
effort: null,
|
||||
auto_scroll_disabled: false,
|
||||
focus_mode: false,
|
||||
show_thinking_summaries: false,
|
||||
enable_session_recap: false,
|
||||
env_scrub: false,
|
||||
prompt_caching_1h: false,
|
||||
};
|
||||
|
||||
function isAllDefaults(s: ClaudeCodeSettings): boolean {
|
||||
return (
|
||||
s.tui_mode === null &&
|
||||
s.effort === null &&
|
||||
s.auto_scroll_disabled === false &&
|
||||
s.focus_mode === false &&
|
||||
s.show_thinking_summaries === false &&
|
||||
s.enable_session_recap === false &&
|
||||
s.env_scrub === false &&
|
||||
s.prompt_caching_1h === false
|
||||
);
|
||||
}
|
||||
|
||||
const BOOLEAN_FIELDS: {
|
||||
key: keyof Omit<ClaudeCodeSettings, "tui_mode" | "effort">;
|
||||
label: string;
|
||||
hint: string;
|
||||
}[] = [
|
||||
{ key: "focus_mode", label: "Focus mode", hint: "Collapses tool output to one-line summaries." },
|
||||
{
|
||||
key: "show_thinking_summaries",
|
||||
label: "Thinking summaries",
|
||||
hint: "Shows Claude's thinking process as summaries.",
|
||||
},
|
||||
{
|
||||
key: "enable_session_recap",
|
||||
label: "Session recap",
|
||||
hint: "Provides context when returning to a session.",
|
||||
},
|
||||
{
|
||||
key: "auto_scroll_disabled",
|
||||
label: "Auto-scroll disabled",
|
||||
hint: "Disables auto-scroll when in fullscreen TUI mode.",
|
||||
},
|
||||
{
|
||||
key: "env_scrub",
|
||||
label: "Env scrub",
|
||||
hint: "Strips credentials from subprocess environments.",
|
||||
},
|
||||
{
|
||||
key: "prompt_caching_1h",
|
||||
label: "Prompt caching (1h)",
|
||||
hint: "Uses a 1-hour prompt cache TTL instead of 5 minutes.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function ClaudeCodeSettingsEditor({
|
||||
settings,
|
||||
disabled,
|
||||
disabledReason,
|
||||
onSave,
|
||||
}: Props) {
|
||||
const [local, setLocal] = useState<ClaudeCodeSettings>(
|
||||
settings ?? { ...CLAUDE_CODE_DEFAULTS },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLocal(settings ?? { ...CLAUDE_CODE_DEFAULTS });
|
||||
}, [settings]);
|
||||
|
||||
const apply = (patch: Partial<ClaudeCodeSettings>) => {
|
||||
const next = { ...local, ...patch };
|
||||
setLocal(next);
|
||||
onSave(isAllDefaults(next) ? null : next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{disabled && disabledReason && (
|
||||
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
|
||||
{disabledReason}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<SwitchRow
|
||||
label="TUI mode"
|
||||
hint="Enables flicker-free alt-screen rendering."
|
||||
control={
|
||||
<select
|
||||
value={local.tui_mode ?? ""}
|
||||
aria-label="TUI mode"
|
||||
onChange={(e) => apply({ tui_mode: e.target.value || null })}
|
||||
disabled={disabled}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option value="fullscreen">Fullscreen</option>
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
|
||||
<SwitchRow
|
||||
label="Effort level"
|
||||
hint="Controls how much reasoning Claude applies."
|
||||
control={
|
||||
<select
|
||||
value={local.effort ?? ""}
|
||||
aria-label="Effort level"
|
||||
onChange={(e) => apply({ effort: e.target.value || null })}
|
||||
disabled={disabled}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
|
||||
{BOOLEAN_FIELDS.map(({ key, label, hint }) => (
|
||||
<SwitchRow
|
||||
key={key}
|
||||
label={label}
|
||||
hint={hint}
|
||||
control={
|
||||
<Toggle
|
||||
label={label}
|
||||
checked={local[key]}
|
||||
disabled={disabled}
|
||||
onChange={(v) => apply({ [key]: v } as Partial<ClaudeCodeSettings>)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { ClaudeCodeSettings } from "../../lib/types";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import ClaudeCodeSettingsEditor from "./ClaudeCodeSettingsEditor";
|
||||
|
||||
interface Props {
|
||||
settings: ClaudeCodeSettings | null;
|
||||
@@ -8,184 +10,32 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DEFAULTS: ClaudeCodeSettings = {
|
||||
tui_mode: null,
|
||||
effort: null,
|
||||
auto_scroll_disabled: false,
|
||||
focus_mode: false,
|
||||
show_thinking_summaries: false,
|
||||
enable_session_recap: false,
|
||||
env_scrub: false,
|
||||
prompt_caching_1h: false,
|
||||
};
|
||||
|
||||
function isAllDefaults(s: ClaudeCodeSettings): boolean {
|
||||
/** Global Claude Code settings (Settings). Per-project lives in Config → Runtime. */
|
||||
export default function ClaudeCodeSettingsModal({
|
||||
settings,
|
||||
disabled,
|
||||
onSave,
|
||||
onClose,
|
||||
}: Props) {
|
||||
return (
|
||||
s.tui_mode === null &&
|
||||
s.effort === null &&
|
||||
s.auto_scroll_disabled === false &&
|
||||
s.focus_mode === false &&
|
||||
s.show_thinking_summaries === false &&
|
||||
s.enable_session_recap === false &&
|
||||
s.env_scrub === false &&
|
||||
s.prompt_caching_1h === false
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClaudeCodeSettingsModal({ settings, disabled, onSave, onClose }: Props) {
|
||||
const [local, setLocal] = useState<ClaudeCodeSettings>(settings ?? { ...DEFAULTS });
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const update = async (patch: Partial<ClaudeCodeSettings>) => {
|
||||
const next = { ...local, ...patch };
|
||||
setLocal(next);
|
||||
try {
|
||||
await onSave(isAllDefaults(next) ? null : next);
|
||||
} catch (err) {
|
||||
console.error("Failed to save Claude Code settings:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleButton = (label: string, description: string, value: boolean, onChange: (v: boolean) => void) => (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">{label}</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">{description}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onChange(!value)}
|
||||
disabled={disabled}
|
||||
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 shrink-0 ${
|
||||
value
|
||||
? "bg-[var(--success)] text-white"
|
||||
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{value ? "ON" : "OFF"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
<Modal
|
||||
title="Claude Code Settings"
|
||||
onClose={onClose}
|
||||
widthClassName="w-[34rem]"
|
||||
footer={<Button onClick={onClose}>Close</Button>}
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[32rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
||||
<h2 className="text-lg font-semibold mb-4">Claude Code Settings</h2>
|
||||
|
||||
{disabled && (
|
||||
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
||||
Container must be stopped to change Claude Code settings.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* TUI Mode */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">TUI Mode</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">Enables flicker-free alt-screen rendering</div>
|
||||
</div>
|
||||
<select
|
||||
value={local.tui_mode ?? ""}
|
||||
onChange={(e) => update({ tui_mode: e.target.value || null })}
|
||||
disabled={disabled}
|
||||
className="px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 shrink-0"
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option value="fullscreen">Fullscreen</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Effort Level */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">Effort Level</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">Controls how much reasoning Claude applies</div>
|
||||
</div>
|
||||
<select
|
||||
value={local.effort ?? ""}
|
||||
onChange={(e) => update({ effort: e.target.value || null })}
|
||||
disabled={disabled}
|
||||
className="px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 shrink-0"
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Boolean toggles */}
|
||||
{toggleButton(
|
||||
"Focus Mode",
|
||||
"Collapses tool output to one-line summaries",
|
||||
local.focus_mode,
|
||||
(v) => update({ focus_mode: v }),
|
||||
)}
|
||||
|
||||
{toggleButton(
|
||||
"Thinking Summaries",
|
||||
"Shows thinking process as summaries",
|
||||
local.show_thinking_summaries,
|
||||
(v) => update({ show_thinking_summaries: v }),
|
||||
)}
|
||||
|
||||
{toggleButton(
|
||||
"Session Recap",
|
||||
"Provides context when returning to a session",
|
||||
local.enable_session_recap,
|
||||
(v) => update({ enable_session_recap: v }),
|
||||
)}
|
||||
|
||||
{toggleButton(
|
||||
"Auto-Scroll Disabled",
|
||||
"Disables auto-scroll when in fullscreen TUI mode",
|
||||
local.auto_scroll_disabled,
|
||||
(v) => update({ auto_scroll_disabled: v }),
|
||||
)}
|
||||
|
||||
{toggleButton(
|
||||
"Env Scrub",
|
||||
"Strips credentials from subprocess environments for security",
|
||||
local.env_scrub,
|
||||
(v) => update({ env_scrub: v }),
|
||||
)}
|
||||
|
||||
{toggleButton(
|
||||
"Prompt Caching (1h)",
|
||||
"Enables 1-hour prompt cache TTL instead of 5 minutes",
|
||||
local.prompt_caching_1h,
|
||||
(v) => update({ prompt_caching_1h: v }),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ClaudeCodeSettingsEditor
|
||||
settings={settings}
|
||||
disabled={disabled}
|
||||
disabledReason="Container must be stopped to change Claude Code settings."
|
||||
onSave={async (next) => {
|
||||
try {
|
||||
await onSave(next);
|
||||
} catch (err) {
|
||||
console.error("Failed to save Claude Code settings:", err);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
instructions: string;
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
onSave: (instructions: string) => Promise<unknown>;
|
||||
rows?: number;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export default function ClaudeInstructionsEditor({
|
||||
instructions: initial,
|
||||
disabled,
|
||||
disabledReason,
|
||||
onSave,
|
||||
rows = 10,
|
||||
autoFocus = false,
|
||||
}: Props) {
|
||||
const [instructions, setInstructions] = useState(initial);
|
||||
|
||||
useEffect(() => {
|
||||
setInstructions(initial);
|
||||
}, [initial]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{disabled && disabledReason && (
|
||||
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
|
||||
{disabledReason}
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
autoFocus={autoFocus}
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
onBlur={() => onSave(instructions)}
|
||||
placeholder="Enter instructions for Claude Code in this project's container..."
|
||||
aria-label="Claude instructions"
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] disabled:text-[var(--text-disabled)] disabled:bg-[var(--bg-secondary)] resize-y font-mono transition-colors"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import ClaudeInstructionsEditor from "./ClaudeInstructionsEditor";
|
||||
|
||||
interface Props {
|
||||
instructions: string;
|
||||
@@ -7,74 +9,35 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ClaudeInstructionsModal({ instructions: initial, disabled, onSave, onClose }: Props) {
|
||||
const [instructions, setInstructions] = useState(initial);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handleBlur = async () => {
|
||||
try { await onSave(instructions); } catch (err) {
|
||||
console.error("Failed to update Claude instructions:", err);
|
||||
}
|
||||
};
|
||||
|
||||
/** Global Claude instructions (Settings). Per-project lives in Config → Runtime. */
|
||||
export default function ClaudeInstructionsModal({
|
||||
instructions,
|
||||
disabled,
|
||||
onSave,
|
||||
onClose,
|
||||
}: Props) {
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
<Modal
|
||||
title="Claude Instructions"
|
||||
description="Written to ~/.claude/CLAUDE.md inside containers."
|
||||
onClose={onClose}
|
||||
widthClassName="w-[40rem]"
|
||||
footer={<Button onClick={onClose}>Close</Button>}
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[40rem] shadow-xl max-h-[80vh] flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-1">Claude Instructions</h2>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-4">
|
||||
Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)
|
||||
</p>
|
||||
|
||||
{disabled && (
|
||||
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
||||
Container must be stopped to change Claude instructions.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
placeholder="Enter instructions for Claude Code in this project's container..."
|
||||
disabled={disabled}
|
||||
rows={14}
|
||||
className="w-full flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 resize-y font-mono"
|
||||
/>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ClaudeInstructionsEditor
|
||||
instructions={instructions}
|
||||
disabled={disabled}
|
||||
disabledReason="Container must be stopped to change Claude instructions."
|
||||
rows={14}
|
||||
autoFocus
|
||||
onSave={async (value) => {
|
||||
try {
|
||||
await onSave(value);
|
||||
} catch (err) {
|
||||
console.error("Failed to update Claude instructions:", err);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
interface Props {
|
||||
projectName: string;
|
||||
@@ -7,49 +8,31 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onCancel]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current) onCancel();
|
||||
},
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[24rem] shadow-xl">
|
||||
<h2 className="text-lg font-semibold mb-3">Remove Project</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-5">
|
||||
Are you sure you want to remove <strong className="text-[var(--text-primary)]">{projectName}</strong>? This will delete the container, config volume, and stored credentials.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
<Modal
|
||||
title="Remove Project"
|
||||
onClose={onCancel}
|
||||
widthClassName="w-[26rem]"
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 text-sm text-white bg-[var(--error)] hover:opacity-80 rounded transition-colors"
|
||||
className="bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
Are you sure you want to remove{" "}
|
||||
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This will
|
||||
delete the container, config volume, and stored credentials.
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface Props {
|
||||
projectName: string;
|
||||
operation: "starting" | "stopping" | "resetting";
|
||||
progressMsg: string | null;
|
||||
error: string | null;
|
||||
completed: boolean;
|
||||
onForceStop: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const operationLabels: Record<string, string> = {
|
||||
starting: "Starting",
|
||||
stopping: "Stopping",
|
||||
resetting: "Resetting",
|
||||
};
|
||||
|
||||
export default function ContainerProgressModal({
|
||||
projectName,
|
||||
operation,
|
||||
progressMsg,
|
||||
error,
|
||||
completed,
|
||||
onForceStop,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-close on success after 800ms
|
||||
useEffect(() => {
|
||||
if (completed && !error) {
|
||||
const timer = setTimeout(onClose, 800);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [completed, error, onClose]);
|
||||
|
||||
// Escape to close (only when completed or error)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && (completed || error)) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [completed, error, onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current && (completed || error)) onClose();
|
||||
},
|
||||
[completed, error, onClose],
|
||||
);
|
||||
|
||||
const inProgress = !completed && !error;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-80 shadow-xl text-center">
|
||||
<h3 className="text-sm font-semibold mb-4">
|
||||
{operationLabels[operation]} “{projectName}”
|
||||
</h3>
|
||||
|
||||
{/* Spinner / checkmark / error icon */}
|
||||
<div className="flex justify-center mb-3">
|
||||
{error ? (
|
||||
<span className="text-3xl text-[var(--error)]">✕</span>
|
||||
) : completed ? (
|
||||
<span className="text-3xl text-[var(--success)]">✓</span>
|
||||
) : (
|
||||
<div className="w-8 h-8 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress message */}
|
||||
<p className="text-xs text-[var(--text-secondary)] min-h-[1.25rem] mb-4">
|
||||
{error
|
||||
? <span className="text-[var(--error)]">{error}</span>
|
||||
: completed
|
||||
? "Done!"
|
||||
: progressMsg ?? `${operationLabels[operation]}...`}
|
||||
</p>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-center gap-2">
|
||||
{inProgress && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onForceStop(); }}
|
||||
className="px-3 py-1.5 text-xs text-[var(--error)] border border-[var(--error)]/30 rounded hover:bg-[var(--error)]/10 transition-colors"
|
||||
>
|
||||
Force Stop
|
||||
</button>
|
||||
)}
|
||||
{(completed || error) && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { EnvVar } from "../../lib/types";
|
||||
import Button from "../ui/Button";
|
||||
import { monoInputClass } from "../ui/Field";
|
||||
|
||||
interface Props {
|
||||
envVars: EnvVar[];
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
onSave: (vars: EnvVar[]) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/** Env-var table. Used inline in Project Home → Config and in global Settings. */
|
||||
export default function EnvVarsEditor({
|
||||
envVars: initial,
|
||||
disabled,
|
||||
disabledReason,
|
||||
onSave,
|
||||
}: Props) {
|
||||
const [vars, setVars] = useState<EnvVar[]>(initial);
|
||||
|
||||
useEffect(() => {
|
||||
setVars(initial);
|
||||
}, [initial]);
|
||||
|
||||
const updateVar = (index: number, field: keyof EnvVar, value: string) => {
|
||||
const updated = [...vars];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
setVars(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{disabled && disabledReason && (
|
||||
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
|
||||
{disabledReason}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{vars.length === 0 && (
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
No environment variables configured.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{vars.map((ev, i) => (
|
||||
<div key={i} className="flex gap-2 items-center">
|
||||
<input
|
||||
value={ev.key}
|
||||
onChange={(e) => updateVar(i, "key", e.target.value)}
|
||||
onBlur={() => onSave(vars)}
|
||||
placeholder="KEY"
|
||||
aria-label={`Environment variable ${i + 1} name`}
|
||||
disabled={disabled}
|
||||
className={`w-2/5 ${monoInputClass}`}
|
||||
/>
|
||||
<input
|
||||
value={ev.value}
|
||||
onChange={(e) => updateVar(i, "value", e.target.value)}
|
||||
onBlur={() => onSave(vars)}
|
||||
placeholder="value"
|
||||
aria-label={`Environment variable ${i + 1} value`}
|
||||
disabled={disabled}
|
||||
className={`flex-1 ${monoInputClass}`}
|
||||
/>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={disabled}
|
||||
aria-label={`Remove environment variable ${ev.key || i + 1}`}
|
||||
onClick={() => {
|
||||
const updated = vars.filter((_, j) => j !== i);
|
||||
setVars(updated);
|
||||
onSave(updated);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const updated = [...vars, { key: "", value: "" }];
|
||||
setVars(updated);
|
||||
onSave(updated);
|
||||
}}
|
||||
>
|
||||
+ Add variable
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { EnvVar } from "../../lib/types";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import EnvVarsEditor from "./EnvVarsEditor";
|
||||
|
||||
interface Props {
|
||||
envVars: EnvVar[];
|
||||
@@ -8,117 +10,27 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function EnvVarsModal({ envVars: initial, disabled, onSave, onClose }: Props) {
|
||||
const [vars, setVars] = useState<EnvVar[]>(initial);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const updateVar = (index: number, field: keyof EnvVar, value: string) => {
|
||||
const updated = [...vars];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
setVars(updated);
|
||||
};
|
||||
|
||||
const removeVar = async (index: number) => {
|
||||
const updated = vars.filter((_, i) => i !== index);
|
||||
setVars(updated);
|
||||
try { await onSave(updated); } catch (err) {
|
||||
console.error("Failed to remove environment variable:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const addVar = async () => {
|
||||
const updated = [...vars, { key: "", value: "" }];
|
||||
setVars(updated);
|
||||
try { await onSave(updated); } catch (err) {
|
||||
console.error("Failed to add environment variable:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = async () => {
|
||||
try { await onSave(vars); } catch (err) {
|
||||
console.error("Failed to update environment variables:", err);
|
||||
}
|
||||
};
|
||||
|
||||
/** Global env vars (Settings). Per-project vars live inline in Config → Access. */
|
||||
export default function EnvVarsModal({ envVars, disabled, onSave, onClose }: Props) {
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
<Modal
|
||||
title="Environment Variables"
|
||||
onClose={onClose}
|
||||
widthClassName="w-[36rem]"
|
||||
footer={<Button onClick={onClose}>Close</Button>}
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[36rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
||||
<h2 className="text-lg font-semibold mb-4">Environment Variables</h2>
|
||||
|
||||
{disabled && (
|
||||
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
||||
Container must be stopped to change environment variables.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
{vars.length === 0 && (
|
||||
<p className="text-xs text-[var(--text-secondary)]">No environment variables configured.</p>
|
||||
)}
|
||||
{vars.map((ev, i) => (
|
||||
<div key={i} className="flex gap-2 items-center">
|
||||
<input
|
||||
value={ev.key}
|
||||
onChange={(e) => updateVar(i, "key", e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
placeholder="KEY"
|
||||
disabled={disabled}
|
||||
className="w-2/5 px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
||||
/>
|
||||
<input
|
||||
value={ev.value}
|
||||
onChange={(e) => updateVar(i, "value", e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
placeholder="value"
|
||||
disabled={disabled}
|
||||
className="flex-1 px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeVar(i)}
|
||||
disabled={disabled}
|
||||
className="px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
onClick={addVar}
|
||||
disabled={disabled}
|
||||
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
+ Add variable
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EnvVarsEditor
|
||||
envVars={envVars}
|
||||
disabled={disabled}
|
||||
disabledReason="Container must be stopped to change environment variables."
|
||||
onSave={async (vars) => {
|
||||
try {
|
||||
await onSave(vars);
|
||||
} catch (err) {
|
||||
console.error("Failed to update environment variables:", err);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useFileManager } from "../../hooks/useFileManager";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
export default function FileManagerModal({ projectId, projectName, onClose }: Props) {
|
||||
const {
|
||||
currentPath,
|
||||
entries,
|
||||
loading,
|
||||
error,
|
||||
navigate,
|
||||
goUp,
|
||||
refresh,
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
} = useFileManager(projectId);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load initial directory
|
||||
useEffect(() => {
|
||||
navigate("/workspace");
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
// Build breadcrumbs from current path
|
||||
const breadcrumbs = currentPath === "/"
|
||||
? [{ label: "/", path: "/" }]
|
||||
: currentPath.split("/").reduce<{ label: string; path: string }[]>((acc, part, i) => {
|
||||
if (i === 0) {
|
||||
acc.push({ label: "/", path: "/" });
|
||||
} else if (part) {
|
||||
const parentPath = acc[acc.length - 1].path;
|
||||
const fullPath = parentPath === "/" ? `/${part}` : `${parentPath}/${part}`;
|
||||
acc.push({ label: part, path: fullPath });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg shadow-xl w-[36rem] max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-color)] flex-shrink-0">
|
||||
<h2 className="text-sm font-semibold">Files — {projectName}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Path bar */}
|
||||
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
<span key={crumb.path} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
|
||||
<button
|
||||
onClick={() => navigate(crumb.path)}
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap"
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50 px-1"
|
||||
title="Refresh"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{error && (
|
||||
<div className="px-4 py-2 text-xs text-[var(--error)]">{error}</div>
|
||||
)}
|
||||
|
||||
{loading && entries.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-xs text-[var(--text-secondary)]">
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{/* Go up entry */}
|
||||
{currentPath !== "/" && (
|
||||
<tr
|
||||
onClick={() => goUp()}
|
||||
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-1.5 text-[var(--text-primary)]">..</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
{entries.map((entry) => (
|
||||
<tr
|
||||
key={entry.name}
|
||||
onClick={() => entry.is_directory && navigate(entry.path)}
|
||||
className={`${
|
||||
entry.is_directory ? "cursor-pointer" : ""
|
||||
} hover:bg-[var(--bg-tertiary)] transition-colors`}
|
||||
>
|
||||
<td className="px-4 py-1.5">
|
||||
<span className={entry.is_directory ? "text-[var(--accent)]" : "text-[var(--text-primary)]"}>
|
||||
{entry.is_directory ? "📁 " : ""}{entry.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap">
|
||||
{!entry.is_directory && formatSize(entry.size)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
|
||||
{entry.modified}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{!entry.is_directory && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadFile(entry);
|
||||
}}
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors px-1"
|
||||
title="Download"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{entries.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-[var(--text-secondary)]">
|
||||
Empty directory
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-color)] flex-shrink-0">
|
||||
<button
|
||||
onClick={uploadFile}
|
||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
Upload file
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import PermissionModeControl, {
|
||||
effectivePermissionMode,
|
||||
permissionModePatch,
|
||||
} from "./PermissionModeControl";
|
||||
import type { Project } from "../../lib/types";
|
||||
|
||||
const baseProject: Project = {
|
||||
id: "p1",
|
||||
name: "api-server",
|
||||
paths: [{ host_path: "/src/api", mount_name: "api" }],
|
||||
container_id: null,
|
||||
status: "running",
|
||||
backend: "anthropic",
|
||||
bedrock_config: null,
|
||||
ollama_config: null,
|
||||
openai_compatible_config: null,
|
||||
allow_docker_access: false,
|
||||
sandbox_mode_enabled: true,
|
||||
mission_control_enabled: false,
|
||||
full_permissions: false,
|
||||
permission_mode: null,
|
||||
ssh_key_path: null,
|
||||
git_token: null,
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
custom_env_vars: [],
|
||||
port_mappings: [],
|
||||
claude_instructions: null,
|
||||
claude_code_settings: null,
|
||||
renamed_session_names: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("effectivePermissionMode", () => {
|
||||
it("falls back to the legacy boolean when permission_mode is null", () => {
|
||||
expect(effectivePermissionMode(baseProject)).toBe("default");
|
||||
expect(
|
||||
effectivePermissionMode({ ...baseProject, full_permissions: true }),
|
||||
).toBe("bypass");
|
||||
});
|
||||
|
||||
it("prefers permission_mode when it is set", () => {
|
||||
expect(
|
||||
effectivePermissionMode({
|
||||
...baseProject,
|
||||
permission_mode: "plan",
|
||||
full_permissions: true,
|
||||
}),
|
||||
).toBe("plan");
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissionModePatch", () => {
|
||||
it("keeps the legacy full_permissions flag in sync", () => {
|
||||
expect(permissionModePatch("bypass")).toEqual({
|
||||
permission_mode: "bypass",
|
||||
full_permissions: true,
|
||||
});
|
||||
expect(permissionModePatch("acceptEdits")).toEqual({
|
||||
permission_mode: "acceptEdits",
|
||||
full_permissions: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PermissionModeControl", () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders all four modes as a radio group with the effective one checked", () => {
|
||||
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
|
||||
const group = screen.getByRole("radiogroup", { name: "Permission mode" });
|
||||
expect(group).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("radio")).toHaveLength(4);
|
||||
expect(screen.getByRole("radio", { name: "Default" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the picked mode", () => {
|
||||
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Accept Edits" }));
|
||||
expect(onChange).toHaveBeenCalledWith("acceptEdits");
|
||||
});
|
||||
|
||||
it("moves selection with the arrow keys", () => {
|
||||
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
|
||||
fireEvent.keyDown(screen.getByRole("radiogroup", { name: "Permission mode" }), {
|
||||
key: "ArrowRight",
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith("acceptEdits");
|
||||
});
|
||||
|
||||
it("shows sandbox state beside the control", () => {
|
||||
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
|
||||
expect(screen.getByTestId("sandbox-state")).toHaveTextContent(
|
||||
/Sandbox\s*ON/,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not paint Bypass as dangerous while the sandbox contains it", () => {
|
||||
render(
|
||||
<PermissionModeControl
|
||||
project={{ ...baseProject, permission_mode: "bypass" }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const bypass = screen.getByRole("radio", { name: "Bypass" });
|
||||
expect(bypass.className).toContain("--accent-emphasis");
|
||||
expect(bypass.className).not.toContain("--warning-emphasis");
|
||||
expect(screen.getByTestId("permission-mode-hint")).toHaveTextContent(
|
||||
/contained by the sandbox/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses caution colour only when Bypass runs with the sandbox off", () => {
|
||||
render(
|
||||
<PermissionModeControl
|
||||
project={{
|
||||
...baseProject,
|
||||
permission_mode: "bypass",
|
||||
sandbox_mode_enabled: false,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const bypass = screen.getByRole("radio", { name: "Bypass" });
|
||||
expect(bypass.className).toContain("--warning-emphasis");
|
||||
expect(screen.getByTestId("permission-mode-hint")).toHaveTextContent(
|
||||
/Caution/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { PermissionMode, Project } from "../../lib/types";
|
||||
import SegmentedControl, { type Segment } from "../ui/SegmentedControl";
|
||||
|
||||
export const PERMISSION_MODES: Segment<PermissionMode>[] = [
|
||||
{ value: "plan", label: "Plan", hint: "Claude proposes a plan and makes no changes." },
|
||||
{ value: "default", label: "Default", hint: "Claude asks before each tool call." },
|
||||
{
|
||||
value: "acceptEdits",
|
||||
label: "Accept Edits",
|
||||
hint: "File edits are auto-approved; other tools still prompt.",
|
||||
},
|
||||
{
|
||||
value: "bypass",
|
||||
label: "Bypass",
|
||||
hint: "Every tool call is auto-approved (--dangerously-skip-permissions).",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* `permission_mode` is nullable for projects saved before it existed; fall back
|
||||
* to the legacy boolean.
|
||||
*/
|
||||
export function effectivePermissionMode(project: Project): PermissionMode {
|
||||
return project.permission_mode ?? (project.full_permissions ? "bypass" : "default");
|
||||
}
|
||||
|
||||
/**
|
||||
* The patch to apply when the user picks a mode. `full_permissions` is kept in
|
||||
* sync so anything still reading the legacy field cannot drift.
|
||||
*/
|
||||
export function permissionModePatch(mode: PermissionMode): Partial<Project> {
|
||||
return { permission_mode: mode, full_permissions: mode === "bypass" };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
onChange: (mode: PermissionMode) => void;
|
||||
disabled?: boolean;
|
||||
/** Explanation of why the control is disabled, shown beneath it. */
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hero control. Per §B3.3: Bypass is only painted as caution when the
|
||||
* sandbox is OFF — with the sandbox ON, bypassing prompts is contained.
|
||||
*/
|
||||
export default function PermissionModeControl({
|
||||
project,
|
||||
onChange,
|
||||
disabled = false,
|
||||
disabledReason,
|
||||
}: Props) {
|
||||
const mode = effectivePermissionMode(project);
|
||||
const sandboxOn = project.sandbox_mode_enabled;
|
||||
const uncontainedBypass = mode === "bypass" && !sandboxOn;
|
||||
|
||||
const segments = PERMISSION_MODES.map((segment) =>
|
||||
segment.value === "bypass" ? { ...segment, caution: !sandboxOn } : segment,
|
||||
);
|
||||
|
||||
const active = PERMISSION_MODES.find((s) => s.value === mode);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Permission mode
|
||||
</span>
|
||||
<SegmentedControl
|
||||
label="Permission mode"
|
||||
segments={segments}
|
||||
value={mode}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span
|
||||
className="text-xs text-[var(--text-secondary)]"
|
||||
data-testid="sandbox-state"
|
||||
>
|
||||
Sandbox{" "}
|
||||
<span
|
||||
className={
|
||||
sandboxOn ? "text-[var(--success)] font-semibold" : "text-[var(--warning)] font-semibold"
|
||||
}
|
||||
>
|
||||
{sandboxOn ? "ON" : "OFF"}
|
||||
</span>
|
||||
{sandboxOn ? " — bubblewrap isolation" : " — no filesystem/network isolation"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={`text-xs leading-snug ${
|
||||
uncontainedBypass ? "text-[var(--warning)]" : "text-[var(--text-secondary)]"
|
||||
}`}
|
||||
data-testid="permission-mode-hint"
|
||||
>
|
||||
{uncontainedBypass
|
||||
? "Caution: every tool call is auto-approved and the sandbox is off, so nothing contains what Claude runs."
|
||||
: mode === "bypass"
|
||||
? "Every tool call is auto-approved — contained by the sandbox."
|
||||
: (active?.hint ?? "")}
|
||||
</p>
|
||||
|
||||
{project.status === "running" && (
|
||||
<p className="text-xs text-[var(--text-disabled)]">
|
||||
Applies to terminals opened from now on.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{disabled && disabledReason && (
|
||||
<p className="text-xs text-[var(--text-disabled)]">{disabledReason}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { PortMapping } from "../../lib/types";
|
||||
import Button from "../ui/Button";
|
||||
import { monoInputClass, selectClass } from "../ui/Field";
|
||||
|
||||
interface Props {
|
||||
portMappings: PortMapping[];
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
onSave: (mappings: PortMapping[]) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export default function PortMappingsEditor({
|
||||
portMappings: initial,
|
||||
disabled,
|
||||
disabledReason,
|
||||
onSave,
|
||||
}: Props) {
|
||||
const [mappings, setMappings] = useState<PortMapping[]>(initial);
|
||||
|
||||
useEffect(() => {
|
||||
setMappings(initial);
|
||||
}, [initial]);
|
||||
|
||||
const updatePort = (
|
||||
index: number,
|
||||
field: "host_port" | "container_port",
|
||||
value: string,
|
||||
) => {
|
||||
const updated = [...mappings];
|
||||
const num = parseInt(value, 10);
|
||||
updated[index] = { ...updated[index], [field]: isNaN(num) ? 0 : num };
|
||||
setMappings(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{disabled && disabledReason && (
|
||||
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
|
||||
{disabledReason}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{mappings.length === 0 && (
|
||||
<p className="text-xs text-[var(--text-secondary)]">No port mappings configured.</p>
|
||||
)}
|
||||
|
||||
{mappings.length > 0 && (
|
||||
<div className="flex gap-2 items-center text-xs text-[var(--text-secondary)] px-0.5">
|
||||
<span className="w-[28%]">Host port</span>
|
||||
<span className="w-[28%]">Container port</span>
|
||||
<span className="w-[22%]">Protocol</span>
|
||||
<span className="flex-1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mappings.map((pm, i) => (
|
||||
<div key={i} className="flex gap-2 items-center">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={pm.host_port || ""}
|
||||
onChange={(e) => updatePort(i, "host_port", e.target.value)}
|
||||
onBlur={() => onSave(mappings)}
|
||||
placeholder="8080"
|
||||
aria-label={`Host port ${i + 1}`}
|
||||
disabled={disabled}
|
||||
className={`w-[28%] ${monoInputClass}`}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={pm.container_port || ""}
|
||||
onChange={(e) => updatePort(i, "container_port", e.target.value)}
|
||||
onBlur={() => onSave(mappings)}
|
||||
placeholder="8080"
|
||||
aria-label={`Container port ${i + 1}`}
|
||||
disabled={disabled}
|
||||
className={`w-[28%] ${monoInputClass}`}
|
||||
/>
|
||||
<select
|
||||
value={pm.protocol}
|
||||
aria-label={`Protocol ${i + 1}`}
|
||||
onChange={(e) => {
|
||||
const updated = [...mappings];
|
||||
updated[i] = { ...updated[i], protocol: e.target.value };
|
||||
setMappings(updated);
|
||||
onSave(updated);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={`w-[22%] ${selectClass}`}
|
||||
>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={disabled}
|
||||
aria-label={`Remove port mapping ${i + 1}`}
|
||||
onClick={() => {
|
||||
const updated = mappings.filter((_, j) => j !== i);
|
||||
setMappings(updated);
|
||||
onSave(updated);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const updated = [
|
||||
...mappings,
|
||||
{ host_port: 0, container_port: 0, protocol: "tcp" },
|
||||
];
|
||||
setMappings(updated);
|
||||
onSave(updated);
|
||||
}}
|
||||
>
|
||||
+ Add port mapping
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { PortMapping } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
portMappings: PortMapping[];
|
||||
disabled: boolean;
|
||||
onSave: (mappings: PortMapping[]) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PortMappingsModal({ portMappings: initial, disabled, onSave, onClose }: Props) {
|
||||
const [mappings, setMappings] = useState<PortMapping[]>(initial);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const updatePort = (index: number, field: "host_port" | "container_port", value: string) => {
|
||||
const updated = [...mappings];
|
||||
const num = parseInt(value, 10);
|
||||
updated[index] = { ...updated[index], [field]: isNaN(num) ? 0 : num };
|
||||
setMappings(updated);
|
||||
};
|
||||
|
||||
const updateProtocol = (index: number, value: string) => {
|
||||
const updated = [...mappings];
|
||||
updated[index] = { ...updated[index], protocol: value };
|
||||
setMappings(updated);
|
||||
};
|
||||
|
||||
const removeMapping = async (index: number) => {
|
||||
const updated = mappings.filter((_, i) => i !== index);
|
||||
setMappings(updated);
|
||||
try { await onSave(updated); } catch (err) {
|
||||
console.error("Failed to remove port mapping:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const addMapping = async () => {
|
||||
const updated = [...mappings, { host_port: 0, container_port: 0, protocol: "tcp" }];
|
||||
setMappings(updated);
|
||||
try { await onSave(updated); } catch (err) {
|
||||
console.error("Failed to add port mapping:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = async () => {
|
||||
try { await onSave(mappings); } catch (err) {
|
||||
console.error("Failed to update port mappings:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[36rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
||||
<h2 className="text-lg font-semibold mb-2">Port Mappings</h2>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-4">
|
||||
Map host ports to container ports. Services can be started after the container is running.
|
||||
</p>
|
||||
|
||||
{disabled && (
|
||||
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
||||
Container must be stopped to change port mappings.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
{mappings.length === 0 && (
|
||||
<p className="text-xs text-[var(--text-secondary)]">No port mappings configured.</p>
|
||||
)}
|
||||
{mappings.length > 0 && (
|
||||
<div className="flex gap-2 items-center text-xs text-[var(--text-secondary)] px-0.5">
|
||||
<span className="w-[30%]">Host Port</span>
|
||||
<span className="w-[30%]">Container Port</span>
|
||||
<span className="w-[25%]">Protocol</span>
|
||||
<span className="w-[15%]" />
|
||||
</div>
|
||||
)}
|
||||
{mappings.map((pm, i) => (
|
||||
<div key={i} className="flex gap-2 items-center">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={pm.host_port || ""}
|
||||
onChange={(e) => updatePort(i, "host_port", e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
placeholder="8080"
|
||||
disabled={disabled}
|
||||
className="w-[30%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={pm.container_port || ""}
|
||||
onChange={(e) => updatePort(i, "container_port", e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
placeholder="8080"
|
||||
disabled={disabled}
|
||||
className="w-[30%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
||||
/>
|
||||
<select
|
||||
value={pm.protocol}
|
||||
onChange={(e) => { updateProtocol(i, e.target.value); handleBlur(); }}
|
||||
disabled={disabled}
|
||||
className="w-[25%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
||||
>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => removeMapping(i)}
|
||||
disabled={disabled}
|
||||
className="w-[15%] px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors text-center"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
onClick={addMapping}
|
||||
disabled={disabled}
|
||||
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
+ Add port mapping
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import ProjectCard from "./ProjectCard";
|
||||
import type { Project } from "../../lib/types";
|
||||
|
||||
// Mock Tauri dialog plugin
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock hooks
|
||||
const mockUpdate = vi.fn();
|
||||
const mockStart = vi.fn();
|
||||
const mockStop = vi.fn();
|
||||
const mockRebuild = vi.fn();
|
||||
const mockRemove = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useProjects", () => ({
|
||||
useProjects: () => ({
|
||||
start: mockStart,
|
||||
stop: mockStop,
|
||||
rebuild: mockRebuild,
|
||||
remove: mockRemove,
|
||||
update: mockUpdate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useTerminal", () => ({
|
||||
useTerminal: () => ({
|
||||
open: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
let mockSelectedProjectId: string | null = null;
|
||||
vi.mock("../../store/appState", () => ({
|
||||
useAppState: vi.fn((selector) =>
|
||||
selector({
|
||||
selectedProjectId: mockSelectedProjectId,
|
||||
setSelectedProject: vi.fn(),
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
const mockProject: Project = {
|
||||
id: "test-1",
|
||||
name: "Test Project",
|
||||
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
|
||||
container_id: null,
|
||||
status: "stopped",
|
||||
backend: "anthropic",
|
||||
bedrock_config: null,
|
||||
allow_docker_access: false,
|
||||
ssh_key_path: null,
|
||||
git_token: null,
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
custom_env_vars: [],
|
||||
port_mappings: [],
|
||||
claude_instructions: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("ProjectCard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSelectedProjectId = null;
|
||||
});
|
||||
|
||||
it("renders project name and path", () => {
|
||||
render(<ProjectCard project={mockProject} />);
|
||||
expect(screen.getByText("Test Project")).toBeInTheDocument();
|
||||
expect(screen.getByText("/workspace/project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("card root has min-w-0 and overflow-hidden to contain content", () => {
|
||||
const { container } = render(<ProjectCard project={mockProject} />);
|
||||
const card = container.firstElementChild;
|
||||
expect(card).not.toBeNull();
|
||||
expect(card!.className).toContain("min-w-0");
|
||||
expect(card!.className).toContain("overflow-hidden");
|
||||
});
|
||||
|
||||
describe("when selected and showing config", () => {
|
||||
beforeEach(() => {
|
||||
mockSelectedProjectId = "test-1";
|
||||
});
|
||||
|
||||
it("expanded area has min-w-0 and overflow-hidden", () => {
|
||||
const { container } = render(<ProjectCard project={mockProject} />);
|
||||
// The expanded section (mt-2 ml-4) contains the auth/action/config controls
|
||||
const expandedSection = container.querySelector(".ml-4.mt-2");
|
||||
expect(expandedSection).not.toBeNull();
|
||||
expect(expandedSection!.className).toContain("min-w-0");
|
||||
expect(expandedSection!.className).toContain("overflow-hidden");
|
||||
});
|
||||
|
||||
it("folder path inputs use min-w-0 to allow shrinking", async () => {
|
||||
const { container } = render(<ProjectCard project={mockProject} />);
|
||||
|
||||
// Click Config button to show config panel
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Config"));
|
||||
});
|
||||
|
||||
// After config is shown, check the folder host_path input has min-w-0
|
||||
const hostPathInputs = container.querySelectorAll('input[placeholder="/path/to/folder"]');
|
||||
expect(hostPathInputs.length).toBeGreaterThan(0);
|
||||
expect(hostPathInputs[0].className).toContain("min-w-0");
|
||||
});
|
||||
|
||||
it("config panel container has overflow-hidden", async () => {
|
||||
const { container } = render(<ProjectCard project={mockProject} />);
|
||||
|
||||
// Click Config button
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Config"));
|
||||
});
|
||||
|
||||
// The config panel has border-t and overflow containment classes
|
||||
const allDivs = container.querySelectorAll("div");
|
||||
const configPanel = Array.from(allDivs).find(
|
||||
(div) => div.className.includes("border-t") && div.className.includes("min-w-0")
|
||||
);
|
||||
expect(configPanel).toBeDefined();
|
||||
expect(configPanel!.className).toContain("overflow-hidden");
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,32 @@
|
||||
import { useState } from "react";
|
||||
import { useProjects } from "../../hooks/useProjects";
|
||||
import ProjectCard from "./ProjectCard";
|
||||
import ProjectRow from "./ProjectRow";
|
||||
import AddProjectDialog from "./AddProjectDialog";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
export default function ProjectList() {
|
||||
const { projects } = useProjects();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between px-2 py-1 mb-2">
|
||||
<span className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
||||
<div className="p-2">
|
||||
<div className="flex items-center justify-between px-1 py-1 mb-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Projects
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="text-lg leading-none text-[var(--text-secondary)] hover:text-[var(--accent)] transition-colors"
|
||||
title="Add project"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<Button onClick={() => setShowAdd(true)} aria-label="Add project">
|
||||
+ Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<p className="px-2 text-sm text-[var(--text-secondary)]">
|
||||
No projects yet. Click + to add one.
|
||||
<p className="px-1 text-xs text-[var(--text-secondary)]">
|
||||
No projects yet — use “+ Add” to create one.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
<ProjectRow key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import ProjectRow from "./ProjectRow";
|
||||
import type { Project } from "../../lib/types";
|
||||
|
||||
const mockStart = vi.fn();
|
||||
const mockStop = vi.fn();
|
||||
const mockOpenClaudeTerminal = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useProjectActions", () => ({
|
||||
useProjectActions: () => ({
|
||||
busy: false,
|
||||
backingUp: false,
|
||||
handleStart: mockStart,
|
||||
handleStop: mockStop,
|
||||
handleReset: vi.fn(),
|
||||
handleBackup: vi.fn(),
|
||||
openClaudeTerminal: mockOpenClaudeTerminal,
|
||||
openShell: vi.fn(),
|
||||
openTerminalWithCommand: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockOpenProjectHome = vi.fn();
|
||||
let storeState: Record<string, unknown> = {};
|
||||
|
||||
vi.mock("../../store/appState", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../store/appState")>(
|
||||
"../../store/appState",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useAppState: vi.fn((selector: (s: unknown) => unknown) => selector(storeState)),
|
||||
};
|
||||
});
|
||||
|
||||
const baseProject: Project = {
|
||||
id: "test-1",
|
||||
name: "Test Project",
|
||||
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
|
||||
container_id: null,
|
||||
status: "stopped",
|
||||
backend: "anthropic",
|
||||
bedrock_config: null,
|
||||
ollama_config: null,
|
||||
openai_compatible_config: null,
|
||||
allow_docker_access: false,
|
||||
sandbox_mode_enabled: true,
|
||||
mission_control_enabled: false,
|
||||
full_permissions: false,
|
||||
permission_mode: null,
|
||||
ssh_key_path: null,
|
||||
git_token: null,
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
custom_env_vars: [],
|
||||
port_mappings: [],
|
||||
claude_instructions: null,
|
||||
claude_code_settings: null,
|
||||
renamed_session_names: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
function setStore(overrides: Record<string, unknown> = {}) {
|
||||
storeState = {
|
||||
activeTabKey: null,
|
||||
selectedProjectId: null,
|
||||
openProjectHome: mockOpenProjectHome,
|
||||
containerProgress: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ProjectRow", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setStore();
|
||||
});
|
||||
|
||||
it("renders project name and mount path", () => {
|
||||
render(<ProjectRow project={baseProject} />);
|
||||
expect(screen.getByText("Test Project")).toBeInTheDocument();
|
||||
expect(screen.getByText("/workspace/project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("row root has min-w-0 and overflow-hidden to contain content", () => {
|
||||
const { container } = render(<ProjectRow project={baseProject} />);
|
||||
const row = container.firstElementChild;
|
||||
expect(row).not.toBeNull();
|
||||
expect(row!.className).toContain("min-w-0");
|
||||
expect(row!.className).toContain("overflow-hidden");
|
||||
});
|
||||
|
||||
it("communicates status with a word, not colour alone", () => {
|
||||
render(<ProjectRow project={baseProject} />);
|
||||
expect(screen.getAllByText("Stopped").length).toBeGreaterThan(0);
|
||||
|
||||
render(<ProjectRow project={{ ...baseProject, status: "error" }} />);
|
||||
expect(screen.getAllByText("Error").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("selecting the row opens that project's home tab instead of expanding in place", () => {
|
||||
render(<ProjectRow project={baseProject} />);
|
||||
fireEvent.click(screen.getByText("Test Project"));
|
||||
expect(mockOpenProjectHome).toHaveBeenCalledWith("test-1");
|
||||
// No config form is rendered in the sidebar any more.
|
||||
expect(screen.queryByPlaceholderText("/path/to/folder")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers start when stopped and stop when running", () => {
|
||||
const { unmount } = render(<ProjectRow project={baseProject} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Start Test Project" }));
|
||||
expect(mockStart).toHaveBeenCalled();
|
||||
unmount();
|
||||
|
||||
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stop Test Project" }));
|
||||
expect(mockStop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only allows opening a terminal while the container runs", () => {
|
||||
const { unmount } = render(<ProjectRow project={baseProject} />);
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Open a Claude terminal for Test Project",
|
||||
}),
|
||||
).toBeDisabled();
|
||||
unmount();
|
||||
|
||||
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Open a Claude terminal for Test Project",
|
||||
}),
|
||||
);
|
||||
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows container progress inline rather than in a blocking modal", () => {
|
||||
setStore({ containerProgress: { "test-1": "Pulling image…" } });
|
||||
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
|
||||
expect(screen.getByText("Pulling image…")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import type { Project } from "../../lib/types";
|
||||
import { useAppState, homeTabKey } from "../../store/appState";
|
||||
import { useProjectActions } from "../../hooks/useProjectActions";
|
||||
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar rows are select-only: name, paths, status, and hover controls.
|
||||
* Clicking a row opens (or focuses) that project's Project Home tab — the
|
||||
* settings form no longer lives in a 280px accordion.
|
||||
*/
|
||||
export default function ProjectRow({ project }: Props) {
|
||||
const { activeTabKey, selectedProjectId, openProjectHome, progress } = useAppState(
|
||||
useShallow((s) => ({
|
||||
activeTabKey: s.activeTabKey,
|
||||
selectedProjectId: s.selectedProjectId,
|
||||
openProjectHome: s.openProjectHome,
|
||||
progress: s.containerProgress[project.id],
|
||||
})),
|
||||
);
|
||||
const { busy, handleStart, handleStop, openClaudeTerminal } =
|
||||
useProjectActions(project);
|
||||
|
||||
const isSelected =
|
||||
activeTabKey === homeTabKey(project.id) || selectedProjectId === project.id;
|
||||
const isRunning = project.status === "running";
|
||||
const isTransitioning =
|
||||
project.status === "starting" || project.status === "stopping";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
|
||||
isSelected
|
||||
? "bg-[var(--bg-tertiary)]"
|
||||
: "hover:bg-[var(--bg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openProjectHome(project.id)}
|
||||
aria-current={isSelected ? "true" : undefined}
|
||||
className="w-full text-left min-w-0"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ProjectStatusIndicator status={project.status} iconOnly />
|
||||
<span className="text-[13px] font-medium truncate flex-1 text-[var(--text-primary)]">
|
||||
{project.name}
|
||||
</span>
|
||||
{/* Space reserved for the hover controls so the name never jumps. */}
|
||||
<span className="w-[3.75rem] flex-shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mt-0.5 ml-4 space-y-0.5 min-w-0">
|
||||
{project.paths.map((pp, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-xs text-[var(--text-secondary)] truncate font-mono"
|
||||
>
|
||||
/workspace/{pp.mount_name}
|
||||
</div>
|
||||
))}
|
||||
<div className="text-xs">
|
||||
{isTransitioning ? (
|
||||
<span className="text-[var(--warning)] truncate block">
|
||||
{progress ?? `${project.status}…`}
|
||||
</span>
|
||||
) : (
|
||||
<ProjectStatusIndicator
|
||||
status={project.status}
|
||||
className="text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Hover / focus-within controls */}
|
||||
<div className="absolute top-1.5 right-2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
// While a container is mid-transition this stays live so it can act
|
||||
// as the force-stop that the old progress modal used to offer.
|
||||
onClick={() => (isRunning || isTransitioning ? handleStop() : handleStart())}
|
||||
title={
|
||||
isTransitioning
|
||||
? `Force stop ${project.name}`
|
||||
: isRunning
|
||||
? `Stop ${project.name}`
|
||||
: `Start ${project.name}`
|
||||
}
|
||||
aria-label={
|
||||
isTransitioning
|
||||
? `Force stop ${project.name}`
|
||||
: isRunning
|
||||
? `Stop ${project.name}`
|
||||
: `Start ${project.name}`
|
||||
}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||
>
|
||||
{isRunning || isTransitioning ? (
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<rect x="6" y="6" width="12" height="12" rx="1.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 5.5v13l11-6.5z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isRunning}
|
||||
onClick={() => openClaudeTerminal()}
|
||||
title={`Open a Claude terminal for ${project.name}`}
|
||||
aria-label={`Open a Claude terminal for ${project.name}`}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<polyline points="7 9 10 12 7 15" />
|
||||
<line x1="13" y1="15" x2="17" y2="15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { Project, ScheduledTask, SchedulerNotification } from "../../../lib/types";
|
||||
import {
|
||||
clearSchedulerNotifications,
|
||||
getScheduledTaskLog,
|
||||
getSchedulerNotifications,
|
||||
listScheduledTasks,
|
||||
removeScheduledTask,
|
||||
runScheduledTaskNow,
|
||||
setScheduledTaskEnabled,
|
||||
} from "../../../lib/tauri-commands";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import Button from "../../ui/Button";
|
||||
import Toggle from "../../ui/Toggle";
|
||||
import Modal from "../../ui/Modal";
|
||||
import StatusIndicator from "../../ui/StatusIndicator";
|
||||
import { formatAge } from "./format";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI for `triple-c-scheduler`, which ships in every container and until now
|
||||
* had no interface beyond a CLAUDE.md paragraph.
|
||||
*/
|
||||
export default function AutomationTab({ project }: Props) {
|
||||
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
||||
const [notifications, setNotifications] = useState<SchedulerNotification[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
|
||||
const [log, setLog] = useState<{ task: ScheduledTask; text: string } | null>(null);
|
||||
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null);
|
||||
const pushToast = useAppState((s) => s.pushToast);
|
||||
const running = project.status === "running";
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!running) {
|
||||
setTasks([]);
|
||||
setNotifications([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
listScheduledTasks(project.id).catch(() => [] as ScheduledTask[]),
|
||||
getSchedulerNotifications(project.id).catch(
|
||||
() => [] as SchedulerNotification[],
|
||||
),
|
||||
])
|
||||
.then(([t, n]) => {
|
||||
setTasks(t);
|
||||
setNotifications(n);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [project.id, running]);
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
const withTask = async (taskId: string, label: string, fn: () => Promise<unknown>) => {
|
||||
setBusyTaskId(taskId);
|
||||
try {
|
||||
await fn();
|
||||
load();
|
||||
} catch (e) {
|
||||
pushToast({ kind: "error", message: `${label} failed`, detail: String(e) });
|
||||
} finally {
|
||||
setBusyTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openLog = async (task: ScheduledTask) => {
|
||||
setBusyTaskId(task.id);
|
||||
try {
|
||||
const text = await getScheduledTaskLog(project.id, task.id, 200);
|
||||
setLog({ task, text });
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: `Could not read the log for “${task.name}”`,
|
||||
detail: String(e),
|
||||
});
|
||||
} finally {
|
||||
setBusyTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const removing = tasks.find((t) => t.id === confirmRemoveId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6 max-w-4xl">
|
||||
{/* Notifications */}
|
||||
{notifications.length > 0 && (
|
||||
<section className="border border-[var(--accent)]/40 bg-[var(--accent-muted)] rounded-[var(--radius-panel)]">
|
||||
<header className="flex items-center justify-between px-3 py-2 border-b border-[var(--border-color)]">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--accent)]">
|
||||
{notifications.length} notification
|
||||
{notifications.length === 1 ? "" : "s"}
|
||||
</h2>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await clearSchedulerNotifications(project.id);
|
||||
setNotifications([]);
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not clear notifications",
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</header>
|
||||
<ul className="divide-y divide-[var(--border-color)]">
|
||||
{notifications.map((n, i) => (
|
||||
<li key={`${n.task_id}-${i}`} className="px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
{n.task_name ?? n.task_id}
|
||||
</span>
|
||||
{n.status && (
|
||||
<StatusIndicator
|
||||
tone={n.status.toLowerCase() === "success" ? "ok" : "error"}
|
||||
label={n.status}
|
||||
/>
|
||||
)}
|
||||
<span className="text-[var(--text-secondary)] ml-auto">
|
||||
{formatAge(n.created_at) ?? n.time ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-[var(--text-secondary)] whitespace-pre-wrap break-words">
|
||||
{n.summary ?? n.body}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Recurring Claude Code runs managed by{" "}
|
||||
<code className="font-mono text-[var(--text-primary)]">
|
||||
triple-c-scheduler
|
||||
</code>{" "}
|
||||
inside the container.
|
||||
</p>
|
||||
<Button onClick={load} disabled={!running || loading}>
|
||||
{loading ? "Refreshing…" : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!running ? (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
Start the container to list its scheduled tasks.
|
||||
</p>
|
||||
) : tasks.length === 0 && !loading ? (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
No scheduled tasks. Ask Claude to add one with{" "}
|
||||
<code className="font-mono">triple-c-scheduler add</code>.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{tasks.map((task) => (
|
||||
<li
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-[var(--text-primary)] truncate">
|
||||
{task.name}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">
|
||||
{task.task_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] font-mono truncate">
|
||||
{task.at ?? task.schedule}
|
||||
{task.last_run ? ` · last run ${formatAge(task.last_run) ?? task.last_run}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Toggle
|
||||
label={`${task.name} enabled`}
|
||||
checked={task.enabled}
|
||||
disabled={busyTaskId === task.id}
|
||||
onChange={(v) =>
|
||||
withTask(task.id, "Toggle task", () =>
|
||||
setScheduledTaskEnabled(project.id, task.id, v),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
disabled={busyTaskId === task.id}
|
||||
onClick={() =>
|
||||
withTask(task.id, "Run now", () =>
|
||||
runScheduledTaskNow(project.id, task.id),
|
||||
)
|
||||
}
|
||||
>
|
||||
Run now
|
||||
</Button>
|
||||
<Button disabled={busyTaskId === task.id} onClick={() => openLog(task)}>
|
||||
Log
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={busyTaskId === task.id}
|
||||
onClick={() => setConfirmRemoveId(task.id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{log && (
|
||||
<Modal
|
||||
title={`Log — ${log.task.name}`}
|
||||
onClose={() => setLog(null)}
|
||||
widthClassName="w-[46rem]"
|
||||
footer={<Button onClick={() => setLog(null)}>Close</Button>}
|
||||
>
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-[var(--text-secondary)]">
|
||||
{log.text.trim() || "(empty log)"}
|
||||
</pre>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{removing && (
|
||||
<Modal
|
||||
title="Remove scheduled task"
|
||||
onClose={() => setConfirmRemoveId(null)}
|
||||
widthClassName="w-[26rem]"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setConfirmRemoveId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
|
||||
onClick={() => {
|
||||
setConfirmRemoveId(null);
|
||||
withTask(removing.id, "Remove task", () =>
|
||||
removeScheduledTask(project.id, removing.id),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
Remove <strong className="text-[var(--text-primary)]">{removing.name}</strong>{" "}
|
||||
from this container’s scheduler?
|
||||
</p>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type {
|
||||
CapabilityGroup,
|
||||
ContainerCapabilities,
|
||||
Project,
|
||||
} from "../../../lib/types";
|
||||
import { listContainerCapabilities } from "../../../lib/tauri-commands";
|
||||
import Modal from "../../ui/Modal";
|
||||
import Button from "../../ui/Button";
|
||||
|
||||
/**
|
||||
* Read-only inventory of what Claude Code can do inside this container.
|
||||
* Triple-C surfaces counts and launches the real editors in the terminal —
|
||||
* it does not rebuild `/agents`, `/hooks`, or `/plugins` as forms.
|
||||
*/
|
||||
const GROUPS: { key: keyof ContainerCapabilities; label: string }[] = [
|
||||
{ key: "skills", label: "Skills" },
|
||||
{ key: "agents", label: "Agents" },
|
||||
{ key: "commands", label: "Commands" },
|
||||
{ key: "hooks", label: "Hooks" },
|
||||
{ key: "plugins", label: "Plugins" },
|
||||
{ key: "mcp_servers", label: "MCP servers" },
|
||||
];
|
||||
|
||||
const SLASH_HINT: Partial<Record<keyof ContainerCapabilities, string>> = {
|
||||
agents: "/agents",
|
||||
hooks: "/hooks",
|
||||
plugins: "/plugins",
|
||||
mcp_servers: "/mcp",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
onManageInTerminal: (command: string) => void;
|
||||
}
|
||||
|
||||
export default function CapabilityTiles({ project, onManageInTerminal }: Props) {
|
||||
const [capabilities, setCapabilities] = useState<ContainerCapabilities | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState<keyof ContainerCapabilities | null>(null);
|
||||
|
||||
const running = project.status === "running";
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) {
|
||||
setCapabilities(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
listContainerCapabilities(project.id)
|
||||
.then((c) => {
|
||||
if (!cancelled) setCapabilities(c);
|
||||
})
|
||||
// Introspection degrades to "nothing found" when the container is
|
||||
// unreachable — that is an empty state, not an error banner.
|
||||
.catch(() => {
|
||||
if (!cancelled) setCapabilities(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [project.id, running, project.container_id]);
|
||||
|
||||
const openGroup: CapabilityGroup | null =
|
||||
open && capabilities ? capabilities[open] : null;
|
||||
const openLabel = GROUPS.find((g) => g.key === open)?.label ?? "";
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)] mb-2">
|
||||
Capabilities
|
||||
</h2>
|
||||
|
||||
{!running ? (
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Start the container to read its skills, agents, commands, hooks and plugins.
|
||||
</p>
|
||||
) : loading && !capabilities ? (
|
||||
<p className="text-xs text-[var(--text-secondary)]">Reading container volume…</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{GROUPS.map(({ key, label }) => {
|
||||
const count = capabilities?.[key].count ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
disabled={count === 0}
|
||||
onClick={() => setOpen(key)}
|
||||
className="flex items-baseline gap-2 px-3 py-2 min-w-[7.5rem] text-left bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] hover:border-[var(--accent)] disabled:hover:border-[var(--border-color)] disabled:cursor-default transition-colors"
|
||||
>
|
||||
<span
|
||||
className={`text-lg font-semibold tabular-nums ${
|
||||
count === 0 ? "text-[var(--text-disabled)]" : "text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-secondary)]">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && openGroup && (
|
||||
<Modal
|
||||
title={`${openLabel} — ${project.name}`}
|
||||
description={
|
||||
SLASH_HINT[open]
|
||||
? `Claude Code manages these with ${SLASH_HINT[open]}.`
|
||||
: undefined
|
||||
}
|
||||
onClose={() => setOpen(null)}
|
||||
widthClassName="w-[34rem]"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setOpen(null);
|
||||
// Claude Code owns the editors; we just deep-link into them.
|
||||
onManageInTerminal("claude");
|
||||
}}
|
||||
>
|
||||
Manage in terminal
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(null)}>Close</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{openGroup.items.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-secondary)]">Nothing configured.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{openGroup.items.map((item, i) => (
|
||||
<li
|
||||
key={`${item.name}-${i}`}
|
||||
className="pb-2 border-b border-[var(--border-color)] last:border-b-0"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-[var(--text-primary)] font-mono">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded-[var(--radius-control)] bg-[var(--accent-muted)] text-[var(--accent)]">
|
||||
{item.scope}
|
||||
</span>
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="mt-0.5 text-xs text-[var(--text-secondary)]">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Project } from "../../../lib/types";
|
||||
import type { SaveState } from "../../../hooks/useSaveState";
|
||||
import SaveIndicator from "../../ui/SaveIndicator";
|
||||
import WorkspaceSection from "./config/WorkspaceSection";
|
||||
import ModelSection from "./config/ModelSection";
|
||||
import AccessSection from "./config/AccessSection";
|
||||
import RuntimeSection from "./config/RuntimeSection";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||
saveState: SaveState;
|
||||
}
|
||||
|
||||
const STOPPED_ONLY =
|
||||
"Container must be stopped to change this setting.";
|
||||
|
||||
/**
|
||||
* Everything the seven config modals used to hold, full-width and grouped.
|
||||
* Saves happen on blur; the indicator in the header reports the outcome.
|
||||
*/
|
||||
export default function ConfigTab({ project, save, saveState }: Props) {
|
||||
const isStopped = project.status === "stopped" || project.status === "error";
|
||||
const disabled = !isStopped;
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4 max-w-4xl">
|
||||
<div className="flex items-center justify-between gap-4 min-h-[1.5rem]">
|
||||
{disabled ? (
|
||||
<p className="px-2 py-1 text-xs text-[var(--warning)] bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)]">
|
||||
Container is {project.status} — stop it to change these settings.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Changes save when a field loses focus.
|
||||
</p>
|
||||
)}
|
||||
<SaveIndicator state={saveState} />
|
||||
</div>
|
||||
|
||||
<WorkspaceSection project={project} save={save} disabled={disabled} />
|
||||
<ModelSection project={project} save={save} disabled={disabled} />
|
||||
<AccessSection
|
||||
project={project}
|
||||
save={save}
|
||||
disabled={disabled}
|
||||
disabledReason={STOPPED_ONLY}
|
||||
/>
|
||||
<RuntimeSection
|
||||
project={project}
|
||||
save={save}
|
||||
disabled={disabled}
|
||||
disabledReason={STOPPED_ONLY}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect } from "react";
|
||||
import type { Project } from "../../../lib/types";
|
||||
import { useFileManager } from "../../../hooks/useFileManager";
|
||||
import Button from "../../ui/Button";
|
||||
import { formatBytes } from "./format";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
/** The old 42rem FileManager popup, now a main-area section. */
|
||||
export default function FilesTab({ project }: Props) {
|
||||
const {
|
||||
currentPath,
|
||||
entries,
|
||||
loading,
|
||||
error,
|
||||
navigate,
|
||||
goUp,
|
||||
refresh,
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
} = useFileManager(project.id);
|
||||
|
||||
const running = project.status === "running";
|
||||
|
||||
useEffect(() => {
|
||||
if (running) navigate("/workspace");
|
||||
// Re-list when the container comes up.
|
||||
}, [navigate, running]);
|
||||
|
||||
const breadcrumbs =
|
||||
currentPath === "/"
|
||||
? [{ label: "/", path: "/" }]
|
||||
: currentPath
|
||||
.split("/")
|
||||
.reduce<{ label: string; path: string }[]>((acc, part, i) => {
|
||||
if (i === 0) {
|
||||
acc.push({ label: "/", path: "/" });
|
||||
} else if (part) {
|
||||
const parentPath = acc[acc.length - 1].path;
|
||||
const fullPath = parentPath === "/" ? `/${part}` : `${parentPath}/${part}`;
|
||||
acc.push({ label: part, path: fullPath });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if (!running) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
Start the container to browse its files.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
|
||||
<nav aria-label="Path" className="flex items-center gap-1">
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
<span key={crumb.path} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(crumb.path)}
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap font-mono"
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex-1" />
|
||||
<Button onClick={uploadFile}>Upload file</Button>
|
||||
<Button onClick={refresh} disabled={loading} className="ml-1">
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{error && (
|
||||
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && entries.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-xs text-[var(--text-secondary)]">
|
||||
Loading…
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{currentPath !== "/" && (
|
||||
<tr
|
||||
onClick={goUp}
|
||||
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-1.5 text-[var(--text-primary)] font-mono">..</td>
|
||||
<td colSpan={3} />
|
||||
</tr>
|
||||
)}
|
||||
{entries.map((entry) => (
|
||||
<tr
|
||||
key={entry.name}
|
||||
onClick={() => entry.is_directory && navigate(entry.path)}
|
||||
className={`${
|
||||
entry.is_directory ? "cursor-pointer" : ""
|
||||
} hover:bg-[var(--bg-tertiary)] transition-colors`}
|
||||
>
|
||||
<td className="px-4 py-1.5">
|
||||
<span
|
||||
className={`font-mono ${
|
||||
entry.is_directory
|
||||
? "text-[var(--accent)]"
|
||||
: "text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{entry.is_directory ? "📁 " : ""}
|
||||
{entry.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap tabular-nums">
|
||||
{!entry.is_directory && formatBytes(entry.size)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
|
||||
{entry.modified}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{!entry.is_directory && (
|
||||
<Button
|
||||
aria-label={`Download ${entry.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadFile(entry);
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{entries.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
className="px-4 py-8 text-center text-[var(--text-secondary)]"
|
||||
>
|
||||
Empty directory
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ClaudeSession, Project, ScheduledTask } from "../../../lib/types";
|
||||
import {
|
||||
listClaudeSessions,
|
||||
listScheduledTasks,
|
||||
getSchedulerNotifications,
|
||||
resumeSessionCommand,
|
||||
} from "../../../lib/tauri-commands";
|
||||
import type { useProjectActions } from "../../../hooks/useProjectActions";
|
||||
import type { SaveState } from "../../../hooks/useSaveState";
|
||||
import PermissionModeControl, {
|
||||
permissionModePatch,
|
||||
} from "../PermissionModeControl";
|
||||
import CapabilityTiles from "./CapabilityTiles";
|
||||
import SaveIndicator from "../../ui/SaveIndicator";
|
||||
import Button from "../../ui/Button";
|
||||
import { formatAge } from "./format";
|
||||
import type { ProjectHomeTabId } from "./ProjectHome";
|
||||
|
||||
const BACKEND_LABEL: Record<Project["backend"], string> = {
|
||||
anthropic: "Anthropic",
|
||||
bedrock: "AWS Bedrock",
|
||||
ollama: "Ollama",
|
||||
open_ai_compatible: "OpenAI Compatible",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||
saveState: SaveState;
|
||||
actions: ReturnType<typeof useProjectActions>;
|
||||
onOpenTab: (tab: ProjectHomeTabId) => void;
|
||||
}
|
||||
|
||||
export default function OverviewTab({
|
||||
project,
|
||||
save,
|
||||
saveState,
|
||||
actions,
|
||||
onOpenTab,
|
||||
}: Props) {
|
||||
const [sessions, setSessions] = useState<ClaudeSession[]>([]);
|
||||
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
||||
const [notificationCount, setNotificationCount] = useState(0);
|
||||
const running = project.status === "running";
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) {
|
||||
setSessions([]);
|
||||
setTasks([]);
|
||||
setNotificationCount(0);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
// All three degrade to empty when the container is unreachable.
|
||||
listClaudeSessions(project.id)
|
||||
.then((s) => !cancelled && setSessions(s.slice(0, 4)))
|
||||
.catch(() => !cancelled && setSessions([]));
|
||||
listScheduledTasks(project.id)
|
||||
.then((t) => !cancelled && setTasks(t))
|
||||
.catch(() => !cancelled && setTasks([]));
|
||||
getSchedulerNotifications(project.id)
|
||||
.then((n) => !cancelled && setNotificationCount(n.length))
|
||||
.catch(() => !cancelled && setNotificationCount(0));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [project.id, running, project.container_id]);
|
||||
|
||||
const handleResume = async (session: ClaudeSession) => {
|
||||
try {
|
||||
const command = await resumeSessionCommand(project.id, session.id);
|
||||
await actions.openTerminalWithCommand(command, session.name ?? "resume");
|
||||
} catch (e) {
|
||||
console.error("Failed to build the resume command:", e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6 max-w-4xl">
|
||||
{/* Permission mode — the hero control */}
|
||||
<section className="p-3 border border-[var(--border-color)] rounded-[var(--radius-panel)] bg-[var(--bg-secondary)]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<PermissionModeControl
|
||||
project={project}
|
||||
disabled={!running && project.status !== "stopped" && project.status !== "error"}
|
||||
onChange={(mode) => save(permissionModePatch(mode))}
|
||||
/>
|
||||
</div>
|
||||
<SaveIndicator state={saveState} />
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-[var(--border-color)] flex flex-wrap gap-x-6 gap-y-1 text-xs">
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
Backend{" "}
|
||||
<span className="text-[var(--text-primary)] font-medium">
|
||||
{BACKEND_LABEL[project.backend]}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
Docker access{" "}
|
||||
<span className="text-[var(--text-primary)] font-medium">
|
||||
{project.allow_docker_access ? "ON" : "OFF"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
Mission Control{" "}
|
||||
<span className="text-[var(--text-primary)] font-medium">
|
||||
{project.mission_control_enabled ? "ON" : "OFF"}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenTab("config")}
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
Edit configuration →
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CapabilityTiles
|
||||
project={project}
|
||||
onManageInTerminal={(command) => actions.openTerminalWithCommand(command)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Recent sessions */}
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Recent sessions
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenTab("sessions")}
|
||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
All sessions →
|
||||
</button>
|
||||
</div>
|
||||
{!running ? (
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Start the container to list saved conversations.
|
||||
</p>
|
||||
) : sessions.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-secondary)]">No sessions yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{sessions.map((session) => (
|
||||
<li
|
||||
key={session.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
|
||||
>
|
||||
<span className="flex-1 min-w-0 text-xs text-[var(--text-primary)] truncate">
|
||||
{session.name ?? session.summary ?? session.id}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">
|
||||
{formatAge(session.last_modified) ?? ""}
|
||||
</span>
|
||||
<Button onClick={() => handleResume(session)}>Resume</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Scheduled tasks */}
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Scheduled tasks
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenTab("automation")}
|
||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
Automation →
|
||||
</button>
|
||||
</div>
|
||||
{!running ? (
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Start the container to list scheduled tasks.
|
||||
</p>
|
||||
) : tasks.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-secondary)]">No scheduled tasks.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{tasks.slice(0, 4).map((task) => (
|
||||
<li
|
||||
key={task.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
|
||||
>
|
||||
<span className="flex-1 min-w-0 text-xs text-[var(--text-primary)] truncate">
|
||||
{task.name}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-secondary)] font-mono flex-shrink-0">
|
||||
{task.at ?? task.schedule}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{notificationCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenTab("automation")}
|
||||
className="mt-2 inline-flex items-center gap-1.5 px-2 py-1 text-xs rounded-[var(--radius-control)] bg-[var(--accent-muted)] text-[var(--accent)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
{notificationCount} notification{notificationCount === 1 ? "" : "s"}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { ClaudeSession, Project } from "../../../lib/types";
|
||||
import { listClaudeSessions, resumeSessionCommand } from "../../../lib/tauri-commands";
|
||||
import type { useProjectActions } from "../../../hooks/useProjectActions";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import Button from "../../ui/Button";
|
||||
import { formatAge, formatBytes } from "./format";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
actions: ReturnType<typeof useProjectActions>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stop/start container model buries "which conversation was I in?" in the
|
||||
* config volume. This lists it and makes [Resume] one click.
|
||||
*/
|
||||
export default function SessionsTab({ project, actions }: Props) {
|
||||
const [sessions, setSessions] = useState<ClaudeSession[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const pushToast = useAppState((s) => s.pushToast);
|
||||
const running = project.status === "running";
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!running) {
|
||||
setSessions([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
listClaudeSessions(project.id)
|
||||
.then(setSessions)
|
||||
.catch(() => setSessions([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [project.id, running]);
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
const resume = async (session: ClaudeSession) => {
|
||||
try {
|
||||
const command = await resumeSessionCommand(project.id, session.id);
|
||||
await actions.openTerminalWithCommand(command, session.name ?? "resume");
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not resume that session",
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-4xl">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Conversations stored on this project’s config volume. Resume opens a
|
||||
terminal running the resume command.
|
||||
</p>
|
||||
<Button onClick={load} disabled={!running || loading}>
|
||||
{loading ? "Refreshing…" : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!running ? (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
Start the container to read its saved sessions.
|
||||
</p>
|
||||
) : sessions.length === 0 && !loading ? (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
No sessions recorded yet. Open a Claude terminal to start one.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{sessions.map((session) => (
|
||||
<li
|
||||
key={session.id}
|
||||
className="flex items-center gap-3 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] text-[var(--text-primary)] truncate">
|
||||
{session.name ?? session.summary ?? "(untitled session)"}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] truncate font-mono">
|
||||
{session.id}
|
||||
{session.cwd ? ` · ${session.cwd}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right text-xs text-[var(--text-secondary)] tabular-nums">
|
||||
<div>{formatAge(session.last_modified) ?? "—"}</div>
|
||||
<div>
|
||||
{formatBytes(session.size_bytes)} · {session.message_count} msg
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => resume(session)}>
|
||||
Resume
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { Project } from "../../../../lib/types";
|
||||
import Button from "../../../ui/Button";
|
||||
import Field, { ConfigGroup, inputClass } from "../../../ui/Field";
|
||||
import EnvVarsEditor from "../../EnvVarsEditor";
|
||||
import PortMappingsEditor from "../../PortMappingsEditor";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
export default function AccessSection({
|
||||
project,
|
||||
save,
|
||||
disabled,
|
||||
disabledReason,
|
||||
}: Props) {
|
||||
const [sshKeyPath, setSshKeyPath] = useState(project.ssh_key_path ?? "");
|
||||
const [gitName, setGitName] = useState(project.git_user_name ?? "");
|
||||
const [gitEmail, setGitEmail] = useState(project.git_user_email ?? "");
|
||||
const [gitToken, setGitToken] = useState(project.git_token ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
setSshKeyPath(project.ssh_key_path ?? "");
|
||||
setGitName(project.git_user_name ?? "");
|
||||
setGitEmail(project.git_user_email ?? "");
|
||||
setGitToken(project.git_token ?? "");
|
||||
}, [project]);
|
||||
|
||||
return (
|
||||
<ConfigGroup
|
||||
title="Access"
|
||||
description="Credentials, environment, and networking the container is given."
|
||||
>
|
||||
<Field
|
||||
label="SSH key directory"
|
||||
hint="Mounted into the container so Claude can authenticate with Git remotes over SSH."
|
||||
>
|
||||
{(id) => (
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
id={id}
|
||||
value={sshKeyPath}
|
||||
onChange={(e) => setSshKeyPath(e.target.value)}
|
||||
onBlur={() => save({ ssh_key_path: sshKeyPath || null })}
|
||||
placeholder="~/.ssh"
|
||||
disabled={disabled}
|
||||
className={inputClass}
|
||||
/>
|
||||
<Button
|
||||
size="md"
|
||||
disabled={disabled}
|
||||
onClick={async () => {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (typeof selected === "string") {
|
||||
setSshKeyPath(selected);
|
||||
save({ ssh_key_path: selected });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Git name" hint="Sets git user.name inside the container for commit authorship.">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={gitName}
|
||||
onChange={(e) => setGitName(e.target.value)}
|
||||
onBlur={() => save({ git_user_name: gitName || null })}
|
||||
placeholder="Your Name"
|
||||
disabled={disabled}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Git email" hint="Sets git user.email inside the container for commit authorship.">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={gitEmail}
|
||||
onChange={(e) => setGitEmail(e.target.value)}
|
||||
onBlur={() => save({ git_user_email: gitEmail || null })}
|
||||
placeholder="you@example.com"
|
||||
disabled={disabled}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Git HTTPS token"
|
||||
hint="A personal access token (e.g. a GitHub PAT) for HTTPS git operations inside the container."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={gitToken}
|
||||
onChange={(e) => setGitToken(e.target.value)}
|
||||
onBlur={() => save({ git_token: gitToken || null })}
|
||||
placeholder="ghp_…"
|
||||
disabled={disabled}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div className="pt-2 border-t border-[var(--border-color)]">
|
||||
<span className="block text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Environment variables
|
||||
</span>
|
||||
<p className="mt-0.5 mb-2 text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Injected into this project’s container. These override global variables
|
||||
with the same key.
|
||||
</p>
|
||||
<EnvVarsEditor
|
||||
envVars={project.custom_env_vars ?? []}
|
||||
disabled={disabled}
|
||||
disabledReason={disabledReason}
|
||||
onSave={(vars) => save({ custom_env_vars: vars })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-[var(--border-color)]">
|
||||
<span className="block text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Port mappings
|
||||
</span>
|
||||
<p className="mt-0.5 mb-2 text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Expose container ports on the host so you can reach dev servers running inside
|
||||
the sandbox.
|
||||
</p>
|
||||
<PortMappingsEditor
|
||||
portMappings={project.port_mappings ?? []}
|
||||
disabled={disabled}
|
||||
disabledReason={disabledReason}
|
||||
onSave={(mappings) => save({ port_mappings: mappings })}
|
||||
/>
|
||||
</div>
|
||||
</ConfigGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type {
|
||||
Backend,
|
||||
BedrockAuthMethod,
|
||||
BedrockConfig,
|
||||
OllamaConfig,
|
||||
OpenAiCompatibleConfig,
|
||||
Project,
|
||||
} from "../../../../lib/types";
|
||||
import Field, { ConfigGroup, monoInputClass, selectClass } from "../../../ui/Field";
|
||||
|
||||
export const DEFAULT_BEDROCK_CONFIG: BedrockConfig = {
|
||||
auth_method: "static_credentials",
|
||||
aws_region: "us-east-1",
|
||||
aws_access_key_id: null,
|
||||
aws_secret_access_key: null,
|
||||
aws_session_token: null,
|
||||
aws_profile: null,
|
||||
aws_bearer_token: null,
|
||||
model_id: null,
|
||||
disable_prompt_caching: false,
|
||||
service_tier: null,
|
||||
};
|
||||
|
||||
export const DEFAULT_OLLAMA_CONFIG: OllamaConfig = {
|
||||
base_url: "http://host.docker.internal:11434",
|
||||
model_id: null,
|
||||
};
|
||||
|
||||
export const DEFAULT_OPENAI_COMPATIBLE_CONFIG: OpenAiCompatibleConfig = {
|
||||
base_url: "http://host.docker.internal:4000",
|
||||
api_key: null,
|
||||
model_id: null,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export default function ModelSection({ project, save, disabled }: Props) {
|
||||
const bedrock = project.bedrock_config ?? DEFAULT_BEDROCK_CONFIG;
|
||||
|
||||
// Local text state — saved on blur, not on every keystroke.
|
||||
const [bedrockRegion, setBedrockRegion] = useState(bedrock.aws_region);
|
||||
const [accessKeyId, setAccessKeyId] = useState(bedrock.aws_access_key_id ?? "");
|
||||
const [secretKey, setSecretKey] = useState(bedrock.aws_secret_access_key ?? "");
|
||||
const [sessionToken, setSessionToken] = useState(bedrock.aws_session_token ?? "");
|
||||
const [profile, setProfile] = useState(bedrock.aws_profile ?? "");
|
||||
const [bearerToken, setBearerToken] = useState(bedrock.aws_bearer_token ?? "");
|
||||
const [bedrockModelId, setBedrockModelId] = useState(bedrock.model_id ?? "");
|
||||
const [serviceTier, setServiceTier] = useState(bedrock.service_tier ?? "");
|
||||
|
||||
const [ollamaBaseUrl, setOllamaBaseUrl] = useState(
|
||||
project.ollama_config?.base_url ?? DEFAULT_OLLAMA_CONFIG.base_url,
|
||||
);
|
||||
const [ollamaModelId, setOllamaModelId] = useState(
|
||||
project.ollama_config?.model_id ?? "",
|
||||
);
|
||||
|
||||
const [oaiBaseUrl, setOaiBaseUrl] = useState(
|
||||
project.openai_compatible_config?.base_url ??
|
||||
DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url,
|
||||
);
|
||||
const [oaiApiKey, setOaiApiKey] = useState(
|
||||
project.openai_compatible_config?.api_key ?? "",
|
||||
);
|
||||
const [oaiModelId, setOaiModelId] = useState(
|
||||
project.openai_compatible_config?.model_id ?? "",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const bc = project.bedrock_config ?? DEFAULT_BEDROCK_CONFIG;
|
||||
setBedrockRegion(bc.aws_region);
|
||||
setAccessKeyId(bc.aws_access_key_id ?? "");
|
||||
setSecretKey(bc.aws_secret_access_key ?? "");
|
||||
setSessionToken(bc.aws_session_token ?? "");
|
||||
setProfile(bc.aws_profile ?? "");
|
||||
setBearerToken(bc.aws_bearer_token ?? "");
|
||||
setBedrockModelId(bc.model_id ?? "");
|
||||
setServiceTier(bc.service_tier ?? "");
|
||||
setOllamaBaseUrl(project.ollama_config?.base_url ?? DEFAULT_OLLAMA_CONFIG.base_url);
|
||||
setOllamaModelId(project.ollama_config?.model_id ?? "");
|
||||
setOaiBaseUrl(
|
||||
project.openai_compatible_config?.base_url ??
|
||||
DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url,
|
||||
);
|
||||
setOaiApiKey(project.openai_compatible_config?.api_key ?? "");
|
||||
setOaiModelId(project.openai_compatible_config?.model_id ?? "");
|
||||
}, [project]);
|
||||
|
||||
const saveBedrock = (patch: Partial<BedrockConfig>) =>
|
||||
save({ bedrock_config: { ...bedrock, ...patch } });
|
||||
|
||||
const saveOllama = (patch: Partial<OllamaConfig>) =>
|
||||
save({
|
||||
ollama_config: { ...(project.ollama_config ?? DEFAULT_OLLAMA_CONFIG), ...patch },
|
||||
});
|
||||
|
||||
const saveOpenAi = (patch: Partial<OpenAiCompatibleConfig>) =>
|
||||
save({
|
||||
openai_compatible_config: {
|
||||
...(project.openai_compatible_config ?? DEFAULT_OPENAI_COMPATIBLE_CONFIG),
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
|
||||
const handleBackendChange = (mode: Backend) => {
|
||||
const patch: Partial<Project> = { backend: mode };
|
||||
if (mode === "bedrock" && !project.bedrock_config)
|
||||
patch.bedrock_config = DEFAULT_BEDROCK_CONFIG;
|
||||
if (mode === "ollama" && !project.ollama_config)
|
||||
patch.ollama_config = DEFAULT_OLLAMA_CONFIG;
|
||||
if (mode === "open_ai_compatible" && !project.openai_compatible_config)
|
||||
patch.openai_compatible_config = DEFAULT_OPENAI_COMPATIBLE_CONFIG;
|
||||
save(patch);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigGroup title="Model" description="Which provider serves this project's Claude.">
|
||||
<Field
|
||||
label="Backend"
|
||||
hint="Anthropic connects directly via OAuth (run `claude login` in a terminal). Bedrock routes through AWS. Ollama and OpenAI Compatible point at any compatible endpoint."
|
||||
>
|
||||
{(id) => (
|
||||
<select
|
||||
id={id}
|
||||
value={project.backend}
|
||||
onChange={(e) => handleBackendChange(e.target.value as Backend)}
|
||||
disabled={disabled}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="bedrock">Bedrock</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="open_ai_compatible">OpenAI Compatible</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{project.backend === "bedrock" && (
|
||||
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
|
||||
<Field label="Authentication method" hint="How the container proves its identity to Bedrock.">
|
||||
{(id) => (
|
||||
<select
|
||||
id={id}
|
||||
value={bedrock.auth_method}
|
||||
onChange={(e) =>
|
||||
saveBedrock({ auth_method: e.target.value as BedrockAuthMethod })
|
||||
}
|
||||
disabled={disabled}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="static_credentials">Static keys</option>
|
||||
<option value="profile">Named profile</option>
|
||||
<option value="bearer_token">Bearer token</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="AWS region" hint="Region where your Bedrock endpoint is available.">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={bedrockRegion}
|
||||
onChange={(e) => setBedrockRegion(e.target.value)}
|
||||
onBlur={() => saveBedrock({ aws_region: bedrockRegion })}
|
||||
placeholder="us-east-1"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{bedrock.auth_method === "static_credentials" && (
|
||||
<>
|
||||
<Field label="Access key ID" hint="IAM access key used for Bedrock API calls.">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={accessKeyId}
|
||||
onChange={(e) => setAccessKeyId(e.target.value)}
|
||||
onBlur={() => saveBedrock({ aws_access_key_id: accessKeyId || null })}
|
||||
placeholder="AKIA…"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Secret access key"
|
||||
hint="Stored locally and injected as an env var into the container."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
onBlur={() =>
|
||||
saveBedrock({ aws_secret_access_key: secretKey || null })
|
||||
}
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Session token"
|
||||
hint="Optional — for assumed-role or MFA-based credentials."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={sessionToken}
|
||||
onChange={(e) => setSessionToken(e.target.value)}
|
||||
onBlur={() =>
|
||||
saveBedrock({ aws_session_token: sessionToken || null })
|
||||
}
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{bedrock.auth_method === "profile" && (
|
||||
<Field
|
||||
label="AWS profile"
|
||||
hint="Named profile from your AWS config/credentials files."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={profile}
|
||||
onChange={(e) => setProfile(e.target.value)}
|
||||
onBlur={() => saveBedrock({ aws_profile: profile || null })}
|
||||
placeholder="default"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{bedrock.auth_method === "bearer_token" && (
|
||||
<Field
|
||||
label="Bearer token"
|
||||
hint="SSO or identity-center token for Bedrock authentication."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={bearerToken}
|
||||
onChange={(e) => setBearerToken(e.target.value)}
|
||||
onBlur={() => saveBedrock({ aws_bearer_token: bearerToken || null })}
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Model ID" hint="Optional override. Leave blank for Claude's default.">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={bedrockModelId}
|
||||
onChange={(e) => setBedrockModelId(e.target.value)}
|
||||
onBlur={() => saveBedrock({ model_id: bedrockModelId || null })}
|
||||
placeholder="anthropic.claude-sonnet-4-20250514-v1:0"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Service tier"
|
||||
hint="Optional — sets ANTHROPIC_BEDROCK_SERVICE_TIER (e.g. “priority”)."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={serviceTier}
|
||||
onChange={(e) => setServiceTier(e.target.value)}
|
||||
onBlur={() => saveBedrock({ service_tier: serviceTier.trim() || null })}
|
||||
placeholder="(account default)"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.backend === "ollama" && (
|
||||
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
|
||||
<Field
|
||||
label="Base URL"
|
||||
hint="Use host.docker.internal to reach the host machine, or an IP/hostname for a remote server."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={ollamaBaseUrl}
|
||||
onChange={(e) => setOllamaBaseUrl(e.target.value)}
|
||||
onBlur={() => saveOllama({ base_url: ollamaBaseUrl })}
|
||||
placeholder="http://host.docker.internal:11434"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Model"
|
||||
hint="Required. The model must already be pulled in Ollama before the container starts."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={ollamaModelId}
|
||||
onChange={(e) => setOllamaModelId(e.target.value)}
|
||||
onBlur={() => saveOllama({ model_id: ollamaModelId || null })}
|
||||
placeholder="qwen3.5:27b"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.backend === "open_ai_compatible" && (
|
||||
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
|
||||
<Field
|
||||
label="Base URL"
|
||||
hint="Any OpenAI API-compatible endpoint — LiteLLM, OpenRouter, vLLM, and so on."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={oaiBaseUrl}
|
||||
onChange={(e) => setOaiBaseUrl(e.target.value)}
|
||||
onBlur={() => saveOpenAi({ base_url: oaiBaseUrl })}
|
||||
placeholder="http://host.docker.internal:4000"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="API key" hint="Authentication key for the endpoint, if it requires one.">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={oaiApiKey}
|
||||
onChange={(e) => setOaiApiKey(e.target.value)}
|
||||
onBlur={() => saveOpenAi({ api_key: oaiApiKey || null })}
|
||||
placeholder="sk-…"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Model" hint="Optional — model identifier as configured by your provider.">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={oaiModelId}
|
||||
onChange={(e) => setOaiModelId(e.target.value)}
|
||||
onBlur={() => saveOpenAi({ model_id: oaiModelId || null })}
|
||||
placeholder="gpt-4o / gemini-pro / …"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</ConfigGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Project } from "../../../../lib/types";
|
||||
import Toggle from "../../../ui/Toggle";
|
||||
import { ConfigGroup, SwitchRow } from "../../../ui/Field";
|
||||
import PermissionModeControl, { permissionModePatch } from "../../PermissionModeControl";
|
||||
import ClaudeInstructionsEditor from "../../ClaudeInstructionsEditor";
|
||||
import ClaudeCodeSettingsEditor from "../../ClaudeCodeSettingsEditor";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
export default function RuntimeSection({
|
||||
project,
|
||||
save,
|
||||
disabled,
|
||||
disabledReason,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
<ConfigGroup
|
||||
title="Runtime"
|
||||
description="How much the sandbox lets Claude do, and what contains it."
|
||||
>
|
||||
<div className="pb-2 border-b border-[var(--border-color)]">
|
||||
<PermissionModeControl
|
||||
project={project}
|
||||
onChange={(mode) => save(permissionModePatch(mode))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SwitchRow
|
||||
label="Sandbox mode"
|
||||
hint="Claude Code's bash sandbox (bubblewrap filesystem and network isolation). Triple-C is the source of truth: toggling this overrides any manual /sandbox configuration in the container's settings.json on next start."
|
||||
control={
|
||||
<Toggle
|
||||
label="Sandbox mode"
|
||||
checked={project.sandbox_mode_enabled}
|
||||
disabled={disabled}
|
||||
onChange={(v) => save({ sandbox_mode_enabled: v })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SwitchRow
|
||||
label="Allow container spawning"
|
||||
hint="Mounts the Docker socket so Claude can build and run Docker containers from inside the sandbox."
|
||||
control={
|
||||
<Toggle
|
||||
label="Allow container spawning"
|
||||
checked={project.allow_docker_access}
|
||||
disabled={disabled}
|
||||
onChange={(v) => save({ allow_docker_access: v })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SwitchRow
|
||||
label="Mission Control"
|
||||
hint="A web dashboard for monitoring and managing Claude sessions remotely."
|
||||
control={
|
||||
<Toggle
|
||||
label="Mission Control"
|
||||
checked={project.mission_control_enabled}
|
||||
disabled={disabled}
|
||||
onChange={(v) => save({ mission_control_enabled: v })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{disabled && disabledReason && (
|
||||
<p className="text-xs text-[var(--text-disabled)]">{disabledReason}</p>
|
||||
)}
|
||||
</ConfigGroup>
|
||||
|
||||
<ConfigGroup
|
||||
title="Claude instructions"
|
||||
description="Written to ~/.claude/CLAUDE.md inside this project's container."
|
||||
>
|
||||
<ClaudeInstructionsEditor
|
||||
instructions={project.claude_instructions ?? ""}
|
||||
disabled={disabled}
|
||||
disabledReason={disabledReason}
|
||||
onSave={(value) => save({ claude_instructions: value || null })}
|
||||
/>
|
||||
</ConfigGroup>
|
||||
|
||||
<ConfigGroup
|
||||
title="Claude Code settings"
|
||||
description="Per-project CLI behaviour. These override the global defaults in Settings."
|
||||
>
|
||||
<ClaudeCodeSettingsEditor
|
||||
settings={project.claude_code_settings}
|
||||
disabled={disabled}
|
||||
disabledReason={disabledReason}
|
||||
onSave={(settings) => save({ claude_code_settings: settings })}
|
||||
/>
|
||||
</ConfigGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { Project, ProjectPath } from "../../../../lib/types";
|
||||
import Button from "../../../ui/Button";
|
||||
import Field, { ConfigGroup, inputClass, monoInputClass } from "../../../ui/Field";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
const [name, setName] = useState(project.name);
|
||||
const [paths, setPaths] = useState<ProjectPath[]>(project.paths ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
setName(project.name);
|
||||
setPaths(project.paths ?? []);
|
||||
}, [project]);
|
||||
|
||||
return (
|
||||
<ConfigGroup
|
||||
title="Workspace"
|
||||
description="What this sandbox is called and which host folders it can see."
|
||||
>
|
||||
<Field
|
||||
label="Project name"
|
||||
hint="Shown in the sidebar and on terminal tabs."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={() => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
setName(project.name);
|
||||
return;
|
||||
}
|
||||
if (trimmed !== project.name) save({ name: trimmed });
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
if (e.key === "Escape") setName(project.name);
|
||||
}}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<span className="block text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Folders
|
||||
</span>
|
||||
<p className="mt-0.5 mb-2 text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Each host folder is mounted at <span className="font-mono">/workspace/<name></span>{" "}
|
||||
inside the container.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{paths.map((pp, i) => (
|
||||
<div key={i} className="flex flex-col gap-1.5 sm:flex-row sm:items-center">
|
||||
<input
|
||||
value={pp.host_path}
|
||||
aria-label={`Folder ${i + 1} host path`}
|
||||
onChange={(e) => {
|
||||
const updated = [...paths];
|
||||
updated[i] = { ...updated[i], host_path: e.target.value };
|
||||
setPaths(updated);
|
||||
}}
|
||||
onBlur={() => save({ paths })}
|
||||
placeholder="/path/to/folder"
|
||||
disabled={disabled}
|
||||
className={`flex-1 min-w-0 ${inputClass}`}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
size="md"
|
||||
disabled={disabled}
|
||||
onClick={async () => {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (typeof selected === "string") {
|
||||
const updated = [...paths];
|
||||
const basename =
|
||||
selected.replace(/[/\\]$/, "").split(/[/\\]/).pop() || "";
|
||||
updated[i] = {
|
||||
host_path: selected,
|
||||
mount_name: updated[i].mount_name || basename,
|
||||
};
|
||||
setPaths(updated);
|
||||
save({ paths: updated });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
<span className="text-xs text-[var(--text-secondary)] font-mono flex-shrink-0">
|
||||
/workspace/
|
||||
</span>
|
||||
<input
|
||||
value={pp.mount_name}
|
||||
aria-label={`Folder ${i + 1} mount name`}
|
||||
onChange={(e) => {
|
||||
const updated = [...paths];
|
||||
updated[i] = { ...updated[i], mount_name: e.target.value };
|
||||
setPaths(updated);
|
||||
}}
|
||||
onBlur={() => save({ paths })}
|
||||
placeholder="name"
|
||||
disabled={disabled}
|
||||
className={`w-40 ${monoInputClass}`}
|
||||
/>
|
||||
{paths.length > 1 && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="danger"
|
||||
disabled={disabled}
|
||||
aria-label={`Remove folder ${i + 1}`}
|
||||
onClick={() => {
|
||||
const updated = paths.filter((_, j) => j !== i);
|
||||
setPaths(updated);
|
||||
save({ paths: updated });
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="mt-2"
|
||||
disabled={disabled}
|
||||
onClick={() => setPaths([...paths, { host_path: "", mount_name: "" }])}
|
||||
>
|
||||
+ Add folder
|
||||
</Button>
|
||||
</div>
|
||||
</ConfigGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Shared formatting helpers for the Project Home views. */
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
/** "2h ago" / "3d ago". Returns null for unparseable timestamps. */
|
||||
export function formatAge(iso: string | null | undefined): string | null {
|
||||
if (!iso) return null;
|
||||
const then = Date.parse(iso);
|
||||
if (Number.isNaN(then)) return null;
|
||||
return formatElapsed(Date.now() - then);
|
||||
}
|
||||
|
||||
export function formatElapsed(ms: number): string {
|
||||
const seconds = Math.max(0, Math.floor(ms / 1000));
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ${minutes % 60}m ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
/** Uptime phrasing for a known start timestamp. */
|
||||
export function formatUptime(startedAtMs: number | undefined): string | null {
|
||||
if (startedAtMs === undefined) return null;
|
||||
const seconds = Math.floor((Date.now() - startedAtMs) / 1000);
|
||||
if (seconds < 60) return "just started";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `up ${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `up ${hours}h ${minutes % 60}m`;
|
||||
return `up ${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export default function AwsSettings() {
|
||||
value={globalAws.aws_config_path ?? ""}
|
||||
onChange={(e) => handleChange("aws_config_path", e.target.value)}
|
||||
placeholder="~/.aws"
|
||||
className="flex-1 px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
className="flex-1 px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleDetect}
|
||||
@@ -86,7 +86,7 @@ export default function AwsSettings() {
|
||||
<select
|
||||
value={globalAws.aws_profile ?? ""}
|
||||
onChange={(e) => handleChange("aws_profile", e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="">None (use default)</option>
|
||||
{profiles.map((p) => (
|
||||
@@ -103,7 +103,7 @@ export default function AwsSettings() {
|
||||
value={globalAws.aws_region ?? ""}
|
||||
onChange={(e) => handleChange("aws_region", e.target.value)}
|
||||
placeholder="e.g., us-east-1"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function AwsSettings() {
|
||||
value={globalAws.default_model_id ?? ""}
|
||||
onChange={(e) => handleChange("default_model_id", e.target.value)}
|
||||
placeholder="anthropic.claude-sonnet-4-20250514-v1:0"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function DockerSettings() {
|
||||
onClick={() => handleSourceChange(opt.value)}
|
||||
className={`flex-1 px-2 py-1.5 text-xs rounded border transition-colors ${
|
||||
imageSource === opt.value
|
||||
? "bg-[var(--accent)] text-white border-[var(--accent)]"
|
||||
? "bg-[var(--accent-emphasis)] text-white border-[var(--accent)]"
|
||||
: "bg-[var(--bg-tertiary)] border-[var(--border-color)] hover:bg-[var(--border-color)]"
|
||||
}`}
|
||||
title={opt.description}
|
||||
@@ -116,7 +116,7 @@ export default function DockerSettings() {
|
||||
value={customInput}
|
||||
onChange={(e) => handleCustomChange(e.target.value)}
|
||||
placeholder="e.g., myregistry.com/image:tag"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -149,7 +149,7 @@ export default function DockerSettings() {
|
||||
<button
|
||||
onClick={handleBuild}
|
||||
disabled={working || !dockerAvailable}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||
className="px-3 py-1.5 text-xs bg-[var(--accent-emphasis)] text-white rounded hover:bg-[var(--accent-emphasis-hover)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||
>
|
||||
{working ? "Building..." : imageExists ? "Rebuild Image" : "Build Image"}
|
||||
</button>
|
||||
@@ -157,7 +157,7 @@ export default function DockerSettings() {
|
||||
<button
|
||||
onClick={handlePull}
|
||||
disabled={working || !dockerAvailable}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||
className="px-3 py-1.5 text-xs bg-[var(--accent-emphasis)] text-white rounded hover:bg-[var(--accent-emphasis-hover)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||
>
|
||||
{working ? "Pulling..." : imageExists ? "Re-pull Image" : "Pull Image"}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import type { ImageUpdateInfo } from "../../lib/types";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
interface Props {
|
||||
imageUpdateInfo: ImageUpdateInfo;
|
||||
@@ -12,23 +13,6 @@ export default function ImageUpdateDialog({
|
||||
onDismiss,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const shortDigest = (digest: string) => {
|
||||
// Show first 16 chars of the hash part (after "sha256:")
|
||||
const hash = digest.startsWith("sha256:") ? digest.slice(7) : digest;
|
||||
@@ -36,56 +20,45 @@ export default function ImageUpdateDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
<Modal
|
||||
title="Container Image Update"
|
||||
onClose={onClose}
|
||||
widthClassName="w-[30rem]"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onDismiss}>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[28rem] max-h-[80vh] overflow-y-auto shadow-xl">
|
||||
<h2 className="text-lg font-semibold mb-3">Container Image Update</h2>
|
||||
<p className="text-[13px] text-[var(--text-secondary)] mb-4">
|
||||
A newer version of the container image is available in the registry. Re-pull the
|
||||
image in Docker settings to get the latest tools and fixes.
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
||||
A newer version of the container image is available in the registry.
|
||||
Re-pull the image in Docker settings to get the latest tools and fixes.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 mb-4 text-xs bg-[var(--bg-primary)] rounded p-3 border border-[var(--border-color)]">
|
||||
{imageUpdateInfo.local_digest && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[var(--text-secondary)]">Local digest</span>
|
||||
<span className="font-mono text-[var(--text-primary)]">
|
||||
{shortDigest(imageUpdateInfo.local_digest)}...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2 mb-4 text-xs bg-[var(--bg-primary)] rounded-[var(--radius-control)] p-3 border border-[var(--border-color)]">
|
||||
{imageUpdateInfo.local_digest && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[var(--text-secondary)]">Remote digest</span>
|
||||
<span className="font-mono text-[var(--accent)]">
|
||||
{shortDigest(imageUpdateInfo.remote_digest)}...
|
||||
<span className="text-[var(--text-secondary)]">Local digest</span>
|
||||
<span className="font-mono text-[var(--text-primary)]">
|
||||
{shortDigest(imageUpdateInfo.local_digest)}…
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-4">
|
||||
Go to Settings > Docker and click "Re-pull Image" to update.
|
||||
Running containers will not be affected until restarted.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[var(--text-secondary)]">Remote digest</span>
|
||||
<span className="font-mono text-[var(--accent)]">
|
||||
{shortDigest(imageUpdateInfo.remote_digest)}…
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Go to Settings > Container and click "Re-pull Image" to update.
|
||||
Running containers will not be affected until restarted.
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function MicrophoneSettings() {
|
||||
value={selected}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
disabled={loading}
|
||||
className="flex-1 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
className="flex-1 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="">System Default</option>
|
||||
{devices.map((d) => (
|
||||
@@ -90,7 +90,7 @@ export default function MicrophoneSettings() {
|
||||
onClick={enumerateDevices}
|
||||
disabled={loading}
|
||||
title="Refresh microphone list"
|
||||
className="text-xs px-2 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors disabled:opacity-50"
|
||||
className="text-xs px-2 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors disabled:text-[var(--text-disabled)]"
|
||||
>
|
||||
{loading ? "..." : "Refresh"}
|
||||
</button>
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function OllamaSettings() {
|
||||
value={globalOllama.base_url ?? ""}
|
||||
onChange={(e) => handleChange("base_url", e.target.value)}
|
||||
placeholder="http://host.docker.internal:11434"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function OllamaSettings() {
|
||||
value={globalOllama.default_model_id ?? ""}
|
||||
onChange={(e) => handleChange("default_model_id", e.target.value)}
|
||||
placeholder="qwen3.5:27b"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function OpenAiCompatibleSettings() {
|
||||
value={globalOai.base_url ?? ""}
|
||||
onChange={(e) => handleChange("base_url", e.target.value)}
|
||||
placeholder="http://host.docker.internal:4000"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function OpenAiCompatibleSettings() {
|
||||
value={globalOai.default_model_id ?? ""}
|
||||
onChange={(e) => handleChange("default_model_id", e.target.value)}
|
||||
placeholder="gpt-4o / gemini-pro / etc."
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { detectHostTimezone } from "../../lib/tauri-commands";
|
||||
import type { EnvVar } from "../../lib/types";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
import AccordionSection from "../ui/AccordionSection";
|
||||
import Toggle from "../ui/Toggle";
|
||||
import WebTerminalSettings from "./WebTerminalSettings";
|
||||
import SttSettings from "./SttSettings";
|
||||
|
||||
@@ -86,7 +87,7 @@ export default function SettingsPanel() {
|
||||
}
|
||||
}}
|
||||
placeholder="UTC"
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -177,7 +178,7 @@ export default function SettingsPanel() {
|
||||
}
|
||||
}}
|
||||
placeholder="~/.ssh"
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -194,7 +195,7 @@ export default function SettingsPanel() {
|
||||
}
|
||||
}}
|
||||
placeholder="Your Name"
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -211,7 +212,7 @@ export default function SettingsPanel() {
|
||||
}
|
||||
}}
|
||||
placeholder="you@example.com"
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
@@ -230,27 +231,22 @@ export default function SettingsPanel() {
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-[var(--text-secondary)]">Auto-check for updates</label>
|
||||
<button
|
||||
onClick={handleAutoCheckToggle}
|
||||
className={`px-2 py-0.5 text-xs rounded transition-colors ${
|
||||
appSettings?.auto_check_updates !== false
|
||||
? "bg-[var(--success)] text-white"
|
||||
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{appSettings?.auto_check_updates !== false ? "ON" : "OFF"}
|
||||
</button>
|
||||
<Toggle
|
||||
label="Auto-check for updates"
|
||||
checked={appSettings?.auto_check_updates !== false}
|
||||
onChange={handleAutoCheckToggle}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCheckNow}
|
||||
disabled={checkingUpdates}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
|
||||
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||
>
|
||||
{checkingUpdates ? "Checking..." : "Check now"}
|
||||
</button>
|
||||
{imageUpdateInfo && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs bg-[var(--bg-primary)] border border-[var(--warning,#f59e0b)] rounded">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--warning,#f59e0b)]" />
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs bg-[var(--bg-primary)] border border-[var(--warning)] rounded">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--warning)]" />
|
||||
<span>A newer container image is available. Re-pull the image in Container settings above to update.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getSttStatus, startStt, stopStt, pullSttImage, buildSttImage } from "..
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { SttStatus } from "../../lib/types";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
import Toggle from "../ui/Toggle";
|
||||
|
||||
export default function SttSettings() {
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
@@ -130,16 +131,11 @@ export default function SttSettings() {
|
||||
<div className="space-y-2">
|
||||
{/* Enable toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleToggleEnabled}
|
||||
className={`px-2 py-0.5 text-xs rounded transition-colors ${
|
||||
appSettings?.stt?.enabled
|
||||
? "bg-[var(--success)] text-white"
|
||||
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{appSettings?.stt?.enabled ? "ON" : "OFF"}
|
||||
</button>
|
||||
<Toggle
|
||||
label="Speech to text"
|
||||
checked={!!appSettings?.stt?.enabled}
|
||||
onChange={handleToggleEnabled}
|
||||
/>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{appSettings?.stt?.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
@@ -154,7 +150,7 @@ export default function SttSettings() {
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
onBlur={handleSaveModel}
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="tiny">Tiny (fastest, ~75MB)</option>
|
||||
<option value="small">Small (balanced, ~500MB)</option>
|
||||
@@ -172,7 +168,7 @@ export default function SttSettings() {
|
||||
onBlur={handleSavePort}
|
||||
min={1}
|
||||
max={65535}
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -185,7 +181,7 @@ export default function SttSettings() {
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
onBlur={handleSaveLanguage}
|
||||
placeholder="Auto-detect"
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -222,14 +218,14 @@ export default function SttSettings() {
|
||||
<button
|
||||
onClick={handlePull}
|
||||
disabled={pulling || building}
|
||||
className="px-3 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
|
||||
className="px-3 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||
>
|
||||
{pulling ? "Pulling..." : "Pull Image"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBuild}
|
||||
disabled={pulling || building}
|
||||
className="px-3 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
|
||||
className="px-3 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||
>
|
||||
{building ? "Building..." : "Build Locally"}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import type { UpdateInfo } from "../../lib/types";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
interface Props {
|
||||
updateInfo: UpdateInfo;
|
||||
@@ -15,23 +16,6 @@ export default function UpdateDialog({
|
||||
onDismiss,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handleDownload = async (url: string) => {
|
||||
try {
|
||||
await openUrl(url);
|
||||
@@ -46,76 +30,65 @@ export default function UpdateDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[28rem] max-h-[80vh] overflow-y-auto shadow-xl">
|
||||
<h2 className="text-lg font-semibold mb-3">Update Available</h2>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4 text-sm">
|
||||
<span className="text-[var(--text-secondary)]">{currentVersion}</span>
|
||||
<span className="text-[var(--text-secondary)]">→</span>
|
||||
<span className="text-[var(--accent)] font-semibold">
|
||||
{updateInfo.version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{updateInfo.body && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xs font-semibold uppercase text-[var(--text-secondary)] mb-1">
|
||||
Release Notes
|
||||
</h3>
|
||||
<div className="text-xs text-[var(--text-primary)] whitespace-pre-wrap bg-[var(--bg-primary)] rounded p-3 max-h-48 overflow-y-auto border border-[var(--border-color)]">
|
||||
{updateInfo.body}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateInfo.assets.length > 0 && (
|
||||
<div className="mb-4 space-y-1">
|
||||
<h3 className="text-xs font-semibold uppercase text-[var(--text-secondary)] mb-1">
|
||||
Downloads
|
||||
</h3>
|
||||
{updateInfo.assets.map((asset) => (
|
||||
<button
|
||||
key={asset.name}
|
||||
onClick={() => handleDownload(asset.browser_download_url)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:border-[var(--accent)] transition-colors"
|
||||
>
|
||||
<span className="truncate">{asset.name}</span>
|
||||
<span className="text-[var(--text-secondary)] ml-2 flex-shrink-0">
|
||||
{formatSize(asset.size)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
<Modal
|
||||
title="Update Available"
|
||||
onClose={onClose}
|
||||
widthClassName="w-[30rem]"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mr-auto text-[var(--accent)] hover:text-[var(--accent-hover)]"
|
||||
onClick={() => handleDownload(updateInfo.release_url)}
|
||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
View on Gitea
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onDismiss}>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-4 text-[13px]">
|
||||
<span className="text-[var(--text-secondary)] font-mono">{currentVersion}</span>
|
||||
<span className="text-[var(--text-secondary)]">→</span>
|
||||
<span className="text-[var(--accent)] font-semibold font-mono">
|
||||
{updateInfo.version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{updateInfo.body && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)] mb-1">
|
||||
Release notes
|
||||
</h3>
|
||||
<div className="text-xs text-[var(--text-primary)] whitespace-pre-wrap bg-[var(--bg-primary)] rounded-[var(--radius-control)] p-3 max-h-48 overflow-y-auto border border-[var(--border-color)]">
|
||||
{updateInfo.body}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateInfo.assets.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)] mb-1">
|
||||
Downloads
|
||||
</h3>
|
||||
{updateInfo.assets.map((asset) => (
|
||||
<button
|
||||
key={asset.name}
|
||||
type="button"
|
||||
onClick={() => handleDownload(asset.browser_download_url)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] hover:border-[var(--accent)] transition-colors"
|
||||
>
|
||||
<span className="truncate font-mono">{asset.name}</span>
|
||||
<span className="text-[var(--text-secondary)] ml-2 flex-shrink-0">
|
||||
{formatSize(asset.size)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
||||
import { startWebTerminal, stopWebTerminal, getWebTerminalStatus, regenerateWebTerminalToken } from "../../lib/tauri-commands";
|
||||
import type { WebTerminalInfo } from "../../lib/types";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
import Toggle from "../ui/Toggle";
|
||||
|
||||
export default function WebTerminalSettings() {
|
||||
const [info, setInfo] = useState<WebTerminalInfo | null>(null);
|
||||
@@ -68,17 +69,12 @@ export default function WebTerminalSettings() {
|
||||
<div className="space-y-2">
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
<Toggle
|
||||
label="Web terminal"
|
||||
checked={!!info?.running}
|
||||
disabled={loading}
|
||||
className={`px-2 py-0.5 text-xs rounded transition-colors ${
|
||||
info?.running
|
||||
? "bg-[var(--success)] text-white"
|
||||
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{loading ? "..." : info?.running ? "ON" : "OFF"}
|
||||
</button>
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{info?.running
|
||||
? `Running on port ${info.port}`
|
||||
@@ -116,7 +112,7 @@ export default function WebTerminalSettings() {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRegenerate}
|
||||
className="text-xs px-2 py-0.5 text-[var(--warning,#f59e0b)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||
className="text-xs px-2 py-0.5 text-[var(--warning)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useProjects } from "../../hooks/useProjects";
|
||||
|
||||
interface ContextMenuState {
|
||||
sessionId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export default function TerminalTabs() {
|
||||
const { sessions, activeSessionId, setActiveSession, close } = useTerminal();
|
||||
const { projects, update } = useProjects();
|
||||
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const dismiss = () => setMenu(null);
|
||||
window.addEventListener("click", dismiss);
|
||||
window.addEventListener("scroll", dismiss, true);
|
||||
return () => {
|
||||
window.removeEventListener("click", dismiss);
|
||||
window.removeEventListener("scroll", dismiss, true);
|
||||
};
|
||||
}, [menu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (renamingId) {
|
||||
renameInputRef.current?.focus();
|
||||
renameInputRef.current?.select();
|
||||
}
|
||||
}, [renamingId]);
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="px-3 text-xs text-[var(--text-secondary)] leading-10">
|
||||
No active terminals
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getCustomName = (projectId: string, sessionId: string): string | null => {
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
return project?.renamed_session_names?.[sessionId] ?? null;
|
||||
};
|
||||
|
||||
const startRename = (sessionId: string) => {
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (!session) return;
|
||||
const current = getCustomName(session.projectId, sessionId) ?? session.sessionName ?? session.projectName;
|
||||
setRenameDraft(current);
|
||||
setRenamingId(sessionId);
|
||||
setMenu(null);
|
||||
};
|
||||
|
||||
const commitRename = async (sessionId: string) => {
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (!session) {
|
||||
setRenamingId(null);
|
||||
return;
|
||||
}
|
||||
const project = projects.find((p) => p.id === session.projectId);
|
||||
if (!project) {
|
||||
setRenamingId(null);
|
||||
return;
|
||||
}
|
||||
const trimmed = renameDraft.trim();
|
||||
const map = { ...(project.renamed_session_names ?? {}) };
|
||||
if (trimmed) {
|
||||
map[sessionId] = trimmed;
|
||||
} else {
|
||||
delete map[sessionId];
|
||||
}
|
||||
try {
|
||||
await update({ ...project, renamed_session_names: map });
|
||||
} catch (err) {
|
||||
console.error("Failed to rename terminal tab:", err);
|
||||
} finally {
|
||||
setRenamingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCustomName = async (sessionId: string) => {
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (!session) return;
|
||||
const project = projects.find((p) => p.id === session.projectId);
|
||||
if (!project) return;
|
||||
const map = { ...(project.renamed_session_names ?? {}) };
|
||||
if (!(sessionId in map)) {
|
||||
setMenu(null);
|
||||
return;
|
||||
}
|
||||
delete map[sessionId];
|
||||
try {
|
||||
await update({ ...project, renamed_session_names: map });
|
||||
} catch (err) {
|
||||
console.error("Failed to reset terminal tab name:", err);
|
||||
} finally {
|
||||
setMenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-full">
|
||||
{sessions.map((session) => {
|
||||
const customName = getCustomName(session.projectId, session.id);
|
||||
const baseLabel =
|
||||
(session.sessionName ?? session.projectName) +
|
||||
(session.sessionType === "bash" ? " (bash)" : "");
|
||||
const displayLabel = customName
|
||||
? `${session.projectName}: ${customName}`
|
||||
: baseLabel;
|
||||
const isRenaming = renamingId === session.id;
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => setActiveSession(session.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
onDoubleClick={() => startRename(session.id)}
|
||||
className={`flex items-center gap-2 px-3 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${
|
||||
activeSessionId === session.id
|
||||
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameDraft}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onBlur={() => commitRename(session.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
if (e.key === "Escape") setRenamingId(null);
|
||||
}}
|
||||
className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded text-xs text-[var(--text-primary)] focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate max-w-[200px]" title={displayLabel}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
close(session.id);
|
||||
}}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--error)] transition-colors"
|
||||
title="Close terminal"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{menu && (() => {
|
||||
const session = sessions.find((s) => s.id === menu.sessionId);
|
||||
const hasCustom = session ? !!getCustomName(session.projectId, menu.sessionId) : false;
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 min-w-[160px] py-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded shadow-lg text-xs"
|
||||
style={{ top: menu.y, left: menu.x }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-primary)] transition-colors"
|
||||
onClick={() => startRename(menu.sessionId)}
|
||||
>
|
||||
Rename tab
|
||||
</button>
|
||||
{hasCustom && (
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--bg-primary)] transition-colors"
|
||||
onClick={() => clearCustomName(menu.sessionId)}
|
||||
>
|
||||
Reset name
|
||||
</button>
|
||||
)}
|
||||
<div className="border-t border-[var(--border-color)] my-1" />
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 text-[var(--error)] hover:bg-[var(--bg-primary)] transition-colors"
|
||||
onClick={() => {
|
||||
close(menu.sessionId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
Close tab
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||
export type ButtonSize = "sm" | "md";
|
||||
|
||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real buttons with visible bounds and a ≥24px hit target.
|
||||
* Filled variants use the *-emphasis tokens so white text clears WCAG AA;
|
||||
* `--accent` stays reserved for foreground/link use.
|
||||
*/
|
||||
const VARIANTS: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)]",
|
||||
secondary:
|
||||
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)]",
|
||||
danger:
|
||||
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent",
|
||||
ghost:
|
||||
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent",
|
||||
};
|
||||
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
sm: "h-6 px-2 text-xs gap-1",
|
||||
md: "h-8 px-3 text-[13px] gap-1.5",
|
||||
};
|
||||
|
||||
export default function Button({
|
||||
variant = "secondary",
|
||||
size = "sm",
|
||||
className = "",
|
||||
type = "button",
|
||||
children,
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
{...rest}
|
||||
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useId, type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Shared control styling. Full-width forms mean the helper text that used to
|
||||
* hide inside 27 hover-only tooltips can just be visible.
|
||||
*/
|
||||
export const inputClass =
|
||||
"w-full px-2.5 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] disabled:text-[var(--text-disabled)] disabled:bg-[var(--bg-secondary)] transition-colors";
|
||||
|
||||
export const monoInputClass = `${inputClass} font-mono`;
|
||||
|
||||
export const selectClass =
|
||||
"px-2.5 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] disabled:text-[var(--text-disabled)] disabled:bg-[var(--bg-secondary)] transition-colors";
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
/** Visible helper text — the replacement for hover-only tooltips. */
|
||||
hint?: ReactNode;
|
||||
children: (id: string) => ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Field({ label, hint, children, className = "" }: FieldProps) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className={className}>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="block text-[13px] font-medium text-[var(--text-primary)]"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{hint && (
|
||||
<p className="mt-0.5 mb-1 text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
<div className={hint ? "" : "mt-1"}>{children(id)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Label + helper text on the left, a control (usually a Toggle) on the right. */
|
||||
export function SwitchRow({
|
||||
label,
|
||||
hint,
|
||||
control,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: ReactNode;
|
||||
control: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-medium text-[var(--text-primary)]">{label}</div>
|
||||
{hint && (
|
||||
<p className="mt-0.5 text-xs text-[var(--text-secondary)] leading-snug">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 pt-0.5">{control}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Grouping card used by the Config tab (Workspace / Model / Access / Runtime). */
|
||||
export function ConfigGroup({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="border border-[var(--border-color)] rounded-[var(--radius-panel)] bg-[var(--bg-secondary)]">
|
||||
<header className="px-4 py-2.5 border-b border-[var(--border-color)]">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="mt-0.5 text-xs text-[var(--text-secondary)]">{description}</p>
|
||||
)}
|
||||
</header>
|
||||
<div className="px-4 py-4 space-y-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import Modal from "./Modal";
|
||||
|
||||
/**
|
||||
* Modal focuses asynchronously via rAF so the panel is laid out first; jsdom
|
||||
* needs that flushed manually.
|
||||
*/
|
||||
async function flushFocus() {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(20);
|
||||
});
|
||||
}
|
||||
|
||||
describe("Modal", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["requestAnimationFrame", "setTimeout"] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("exposes dialog semantics and an accessible name", async () => {
|
||||
render(
|
||||
<Modal title="Remove Project" onClose={vi.fn()}>
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
const dialog = screen.getByRole("dialog", { name: "Remove Project" });
|
||||
expect(dialog).toHaveAttribute("aria-modal", "true");
|
||||
});
|
||||
|
||||
it("moves focus into the dialog on open", async () => {
|
||||
render(
|
||||
<Modal title="Dialog" onClose={vi.fn()}>
|
||||
<button>First</button>
|
||||
<button>Second</button>
|
||||
</Modal>,
|
||||
);
|
||||
await flushFocus();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||
});
|
||||
|
||||
it("traps Tab inside the dialog, wrapping at both ends", async () => {
|
||||
render(
|
||||
<Modal title="Dialog" onClose={vi.fn()} hideCloseButton>
|
||||
<button>First</button>
|
||||
<button>Last</button>
|
||||
</Modal>,
|
||||
);
|
||||
await flushFocus();
|
||||
|
||||
const first = screen.getByRole("button", { name: "First" });
|
||||
const last = screen.getByRole("button", { name: "Last" });
|
||||
|
||||
last.focus();
|
||||
fireEvent.keyDown(document, { key: "Tab" });
|
||||
expect(document.activeElement).toBe(first);
|
||||
|
||||
first.focus();
|
||||
fireEvent.keyDown(document, { key: "Tab", shiftKey: true });
|
||||
expect(document.activeElement).toBe(last);
|
||||
});
|
||||
|
||||
it("restores focus to the trigger on unmount", async () => {
|
||||
const trigger = document.createElement("button");
|
||||
document.body.appendChild(trigger);
|
||||
trigger.focus();
|
||||
|
||||
const { unmount } = render(
|
||||
<Modal title="Dialog" onClose={vi.fn()}>
|
||||
<button>Inside</button>
|
||||
</Modal>,
|
||||
);
|
||||
await flushFocus();
|
||||
expect(document.activeElement).not.toBe(trigger);
|
||||
|
||||
unmount();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
trigger.remove();
|
||||
});
|
||||
|
||||
it("closes on Escape and on an overlay click", async () => {
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(
|
||||
<Modal title="Dialog" onClose={onClose}>
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
await flushFocus();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The overlay is the portal root's only child.
|
||||
const overlay = document.querySelector(".fixed.inset-0");
|
||||
expect(overlay).not.toBeNull();
|
||||
fireEvent.click(overlay!);
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ignores Escape and overlay clicks when not dismissible", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<Modal title="Installing" onClose={onClose} dismissible={false}>
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
await flushFocus();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
fireEvent.click(document.querySelector(".fixed.inset-0")!);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useCallback, useEffect, useId, useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
"a[href]",
|
||||
"area[href]",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
"button:not([disabled])",
|
||||
"iframe",
|
||||
"object",
|
||||
"embed",
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[contenteditable="true"]',
|
||||
].join(",");
|
||||
|
||||
function focusableWithin(root: HTMLElement): HTMLElement[] {
|
||||
// Deliberately no `offsetParent` check: everything a dialog renders is
|
||||
// visible, and `offsetParent` is unreliable inside fixed-position overlays.
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||
(el) => !el.closest("[hidden]") && el.getAttribute("aria-hidden") !== "true",
|
||||
);
|
||||
}
|
||||
|
||||
export interface ModalProps {
|
||||
/** Accessible name for the dialog. Rendered as the header unless `hideTitle`. */
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
/** Optional sticky footer row (buttons live here). */
|
||||
footer?: ReactNode;
|
||||
/** Optional sub-header description, wired to `aria-describedby`. */
|
||||
description?: ReactNode;
|
||||
/** Tailwind width class for the dialog panel. */
|
||||
widthClassName?: string;
|
||||
/** When false, Escape / overlay click / the ✕ button do not close. */
|
||||
dismissible?: boolean;
|
||||
/** Hide the ✕ in the header (the footer usually carries a Close button). */
|
||||
hideCloseButton?: boolean;
|
||||
/** Focused on mount; falls back to the first focusable child. */
|
||||
initialFocusRef?: React.RefObject<HTMLElement | null>;
|
||||
/** Applied to the scrollable body wrapper. */
|
||||
bodyClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one modal primitive. Every dialog in the app renders through this so
|
||||
* `role="dialog"`, `aria-modal`, a focus trap, focus restore, Escape and
|
||||
* click-outside are implemented once instead of twelve times.
|
||||
*/
|
||||
export default function Modal({
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
description,
|
||||
widthClassName = "w-[32rem]",
|
||||
dismissible = true,
|
||||
hideCloseButton = false,
|
||||
initialFocusRef,
|
||||
bodyClassName = "",
|
||||
}: ModalProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||
const titleId = useId();
|
||||
const descId = useId();
|
||||
|
||||
// Remember what had focus, move focus inside, restore on unmount.
|
||||
useEffect(() => {
|
||||
restoreFocusRef.current = document.activeElement as HTMLElement | null;
|
||||
const panel = panelRef.current;
|
||||
if (panel) {
|
||||
const target =
|
||||
initialFocusRef?.current ?? focusableWithin(panel)[0] ?? panel;
|
||||
// Defer so the panel is laid out (offsetParent) before we query it.
|
||||
requestAnimationFrame(() => target.focus?.());
|
||||
}
|
||||
return () => {
|
||||
restoreFocusRef.current?.focus?.();
|
||||
};
|
||||
// Mount/unmount only — re-running would steal focus mid-interaction.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Escape closes; Tab is trapped inside the panel.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && dismissible) {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
const items = focusableWithin(panel);
|
||||
if (items.length === 0) {
|
||||
e.preventDefault();
|
||||
panel.focus();
|
||||
return;
|
||||
}
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (!active || !panel.contains(active)) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey && active === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
return () => document.removeEventListener("keydown", onKeyDown, true);
|
||||
}, [dismissible, onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (dismissible && e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[dismissible, onClose],
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={description ? descId : undefined}
|
||||
tabIndex={-1}
|
||||
className={`flex flex-col max-h-[85vh] ${widthClassName} max-w-full bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)]`}
|
||||
style={{ boxShadow: "var(--shadow-overlay)" }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 px-5 py-3 border-b border-[var(--border-color)] flex-shrink-0">
|
||||
<div className="min-w-0">
|
||||
<h2 id={titleId} className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p id={descId} className="mt-0.5 text-xs text-[var(--text-secondary)]">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!hideCloseButton && dismissible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close dialog"
|
||||
className="flex-shrink-0 w-6 h-6 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>
|
||||
|
||||
<div className={`flex-1 min-h-0 overflow-y-auto px-5 py-4 ${bodyClassName}`}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--border-color)] flex-shrink-0">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface OverflowItem {
|
||||
label: string;
|
||||
onSelect: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: OverflowItem[];
|
||||
label?: string;
|
||||
align?: "left" | "right";
|
||||
}
|
||||
|
||||
/** The `⋯` menu that keeps destructive actions out of the main button row. */
|
||||
export default function OverflowMenu({
|
||||
items,
|
||||
label = "More actions",
|
||||
align = "right",
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDocClick);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative inline-block">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={label}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="inline-flex items-center justify-center h-6 w-7 rounded-[var(--radius-control)] border border-[var(--border-color)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
<span aria-hidden="true" className="leading-none">⋯</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className={`absolute z-40 mt-1 min-w-[11rem] py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)] ${
|
||||
align === "right" ? "right-0" : "left-0"
|
||||
}`}
|
||||
style={{ boxShadow: "var(--shadow-overlay)" }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
item.onSelect();
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs transition-colors disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent hover:bg-[var(--bg-tertiary)] ${
|
||||
item.danger ? "text-[var(--error)]" : "text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { SaveState } from "../../hooks/useSaveState";
|
||||
|
||||
/**
|
||||
* Visible outcome for save-on-blur. Config writes used to fail silently into
|
||||
* `console.error`, which is silent data loss.
|
||||
*/
|
||||
export default function SaveIndicator({ state }: { state: SaveState }) {
|
||||
if (state.status === "idle") return null;
|
||||
|
||||
const map = {
|
||||
saving: { text: "Saving…", color: "var(--text-secondary)" },
|
||||
saved: { text: "Saved ✓", color: "var(--success)" },
|
||||
failed: { text: "Save failed ✕", color: "var(--error)" },
|
||||
} as const;
|
||||
const tone = map[state.status];
|
||||
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="text-xs font-medium"
|
||||
style={{ color: tone.color }}
|
||||
title={state.error ?? undefined}
|
||||
>
|
||||
{tone.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
export interface Segment<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
/** Visible helper text under the control when this segment is selected. */
|
||||
hint?: string;
|
||||
/** Paint this segment with the caution treatment when selected. */
|
||||
caution?: boolean;
|
||||
}
|
||||
|
||||
interface Props<T extends string> {
|
||||
/** Accessible group name. */
|
||||
label: string;
|
||||
segments: Segment<T>[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roving-tabindex radio group rendered as a segmented control.
|
||||
* Arrow keys move between segments; only the selected one is tabbable.
|
||||
*/
|
||||
export default function SegmentedControl<T extends string>({
|
||||
label,
|
||||
segments,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
className = "",
|
||||
}: Props<T>) {
|
||||
const groupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const move = (delta: number) => {
|
||||
const index = segments.findIndex((s) => s.value === value);
|
||||
const next = segments[(index + delta + segments.length) % segments.length];
|
||||
if (!next) return;
|
||||
onChange(next.value);
|
||||
requestAnimationFrame(() => {
|
||||
groupRef.current
|
||||
?.querySelector<HTMLElement>(`[data-segment="${next.value}"]`)
|
||||
?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={groupRef}
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
className={`inline-flex p-0.5 gap-0.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] ${className}`}
|
||||
onKeyDown={(e) => {
|
||||
if (disabled) return;
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{segments.map((segment) => {
|
||||
const selected = segment.value === value;
|
||||
let cls =
|
||||
"text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]";
|
||||
if (selected) {
|
||||
cls = segment.caution
|
||||
? "bg-[var(--warning-emphasis)] text-white"
|
||||
: "bg-[var(--accent-emphasis)] text-white";
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={segment.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
data-segment={segment.value}
|
||||
aria-checked={selected}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(segment.value)}
|
||||
className={`h-6 px-2.5 text-xs font-medium rounded-[4px] transition-colors disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent ${cls}`}
|
||||
>
|
||||
{segment.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { ProjectStatus } from "../../lib/types";
|
||||
|
||||
/**
|
||||
* Status is never encoded by hue alone: every tone carries a distinct glyph
|
||||
* shape, and (unless `iconOnly`) a word.
|
||||
*/
|
||||
export type StatusTone =
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "busy"
|
||||
| "error"
|
||||
| "unknown"
|
||||
| "ok"
|
||||
| "off";
|
||||
|
||||
interface ToneStyle {
|
||||
glyph: string;
|
||||
color: string;
|
||||
pulse?: boolean;
|
||||
}
|
||||
|
||||
const TONES: Record<StatusTone, ToneStyle> = {
|
||||
running: { glyph: "●", color: "var(--success)" },
|
||||
ok: { glyph: "●", color: "var(--success)" },
|
||||
stopped: { glyph: "○", color: "var(--text-secondary)" },
|
||||
off: { glyph: "○", color: "var(--text-secondary)" },
|
||||
busy: { glyph: "◐", color: "var(--warning)", pulse: true },
|
||||
error: { glyph: "▲", color: "var(--error)" },
|
||||
// Still being checked — distinct from "unavailable", and it pulses.
|
||||
unknown: { glyph: "◌", color: "var(--text-disabled)", pulse: true },
|
||||
};
|
||||
|
||||
export const PROJECT_STATUS_TONE: Record<ProjectStatus, StatusTone> = {
|
||||
running: "running",
|
||||
stopped: "stopped",
|
||||
starting: "busy",
|
||||
stopping: "busy",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
export const PROJECT_STATUS_LABEL: Record<ProjectStatus, string> = {
|
||||
running: "Running",
|
||||
stopped: "Stopped",
|
||||
starting: "Starting",
|
||||
stopping: "Stopping",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
tone: StatusTone;
|
||||
label: string;
|
||||
/** Render the glyph only; `label` still ships as accessible text. */
|
||||
iconOnly?: boolean;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export default function StatusIndicator({
|
||||
tone,
|
||||
label,
|
||||
iconOnly = false,
|
||||
className = "",
|
||||
title,
|
||||
}: Props) {
|
||||
const style = TONES[tone];
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 whitespace-nowrap ${className}`}
|
||||
title={title ?? label}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`leading-none text-[10px] ${style.pulse ? "animate-status-pulse" : ""}`}
|
||||
style={{ color: style.color }}
|
||||
>
|
||||
{style.glyph}
|
||||
</span>
|
||||
{iconOnly ? (
|
||||
<span className="sr-only">{label}</span>
|
||||
) : (
|
||||
<span style={{ color: style.color }}>{label}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Status pill for a project, derived from `ProjectStatus`. */
|
||||
export function ProjectStatusIndicator({
|
||||
status,
|
||||
iconOnly,
|
||||
className,
|
||||
}: {
|
||||
status: ProjectStatus;
|
||||
iconOnly?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<StatusIndicator
|
||||
tone={PROJECT_STATUS_TONE[status]}
|
||||
label={PROJECT_STATUS_LABEL[status]}
|
||||
iconOnly={iconOnly}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
interface Props {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
/** Accessible name — required, since the visual label lives outside. */
|
||||
label: string;
|
||||
/** Paint the "on" state as caution rather than success. */
|
||||
tone?: "success" | "caution";
|
||||
}
|
||||
|
||||
/**
|
||||
* ON/OFF switch. The old version put white text on `--success` (~2.1:1, the
|
||||
* worst contrast in the app); the on-state now uses a tinted background with
|
||||
* the token colour as *foreground*.
|
||||
*/
|
||||
export default function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
label,
|
||||
tone = "success",
|
||||
}: Props) {
|
||||
const onStyle =
|
||||
tone === "caution"
|
||||
? "bg-[var(--warning-muted)] border-[var(--warning)] text-[var(--warning)]"
|
||||
: "bg-[var(--success-muted)] border-[var(--success)] text-[var(--success)]";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`inline-flex items-center justify-center h-6 min-w-[3rem] px-2 text-xs font-semibold rounded-[var(--radius-control)] border transition-colors disabled:cursor-not-allowed disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:bg-[var(--bg-primary)] ${
|
||||
checked
|
||||
? onStyle
|
||||
: "bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{checked ? "ON" : "OFF"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -51,8 +51,11 @@ export default function Tooltip({ text, children }: TooltipProps) {
|
||||
>
|
||||
{children ?? (
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-[var(--text-secondary)] text-[var(--text-secondary)] text-[9px] leading-none cursor-help select-none hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
|
||||
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-[var(--text-secondary)] text-[var(--text-secondary)] text-[10px] leading-none cursor-help select-none hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
|
||||
aria-label="Help"
|
||||
tabIndex={0}
|
||||
onFocus={() => setVisible(true)}
|
||||
onBlur={() => setVisible(false)}
|
||||
>
|
||||
?
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user