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