mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 20:01:27 +00:00
Add full Docker image defaults, fix actor deadlocks, and improve dev experience
- Add Dockerfile.full and --all flag to install-agent CLI for pre-built images
- Centralize Docker image constant (FULL_IMAGE) pinned to 0.3.1-full
- Remove examples/shared/Dockerfile{,.dev} and daytona snapshot example
- Expand Docker docs with full runnable Dockerfile
- Fix self-deadlock in createWorkbenchSession (fire-and-forget provisioning)
- Audit and convert 12 task actions from wait:true to wait:false
- Add bun --hot for dev backend hot reload
- Remove --force from pnpm install in dev Dockerfile for faster startup
- Add env_file support to compose.dev.yaml for automatic credential loading
- Add mock frontend compose config and dev panel
- Update CLAUDE.md with wait:true policy and dev environment setup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
58c54156f1
commit
110e969f98
29 changed files with 804 additions and 283 deletions
|
|
@ -31,16 +31,26 @@ Use `pnpm` workspaces and Turborepo.
|
|||
- Foundry is the canonical name for this product tree. Do not introduce or preserve legacy pre-Foundry naming in code, docs, commands, or runtime paths.
|
||||
- Install deps: `pnpm install`
|
||||
- Full active-workspace validation: `pnpm -w typecheck`, `pnpm -w build`, `pnpm -w test`
|
||||
- Start the full dev stack: `just foundry-dev`
|
||||
- Start the full dev stack (real backend + frontend): `just foundry-dev` — frontend on **port 4173**, backend on **port 7741** (Docker via `compose.dev.yaml`)
|
||||
- Start the mock frontend stack (no backend): `just foundry-mock` — mock frontend on **port 4174** (Docker via `compose.mock.yaml`)
|
||||
- Start the local production-build preview stack: `just foundry-preview`
|
||||
- Start only the backend locally: `just foundry-backend-start`
|
||||
- Start only the frontend locally: `pnpm --filter @sandbox-agent/foundry-frontend dev`
|
||||
- Start the frontend against the mock workbench client: `FOUNDRY_FRONTEND_CLIENT_MODE=mock pnpm --filter @sandbox-agent/foundry-frontend dev`
|
||||
- Start the mock frontend locally (no Docker): `just foundry-dev-mock` — mock frontend on **port 4174**
|
||||
- Dev and mock stacks can run simultaneously on different ports (4173 and 4174).
|
||||
- Stop the compose dev stack: `just foundry-dev-down`
|
||||
- Tail compose logs: `just foundry-dev-logs`
|
||||
- Tail compose dev logs: `just foundry-dev-logs`
|
||||
- Stop the mock stack: `just foundry-mock-down`
|
||||
- Tail mock logs: `just foundry-mock-logs`
|
||||
- Stop the preview stack: `just foundry-preview-down`
|
||||
- Tail preview logs: `just foundry-preview-logs`
|
||||
|
||||
## Dev Environment Setup
|
||||
|
||||
- `compose.dev.yaml` loads `foundry/.env` (optional) for credentials needed by the backend (GitHub OAuth, Stripe, Daytona, API keys, etc.).
|
||||
- The canonical source for these credentials is `~/misc/the-foundry.env`. If `foundry/.env` does not exist, copy it: `cp ~/misc/the-foundry.env foundry/.env`
|
||||
- `foundry/.env` is gitignored and must never be committed.
|
||||
|
||||
## Railway Logs
|
||||
|
||||
- Production Foundry Railway logs can be read from a linked workspace with `railway logs --deployment --lines 200` or `railway logs <deployment-id> --deployment --lines 200`.
|
||||
|
|
@ -65,6 +75,17 @@ Use `pnpm` workspaces and Turborepo.
|
|||
- When asked for screenshots, capture all relevant affected screens and modal states, not just a single viewport. Include empty, populated, success, and blocked/error states when they are part of the changed flow.
|
||||
- If a screenshot catches a transition frame, blank modal, or otherwise misleading state, retake it before reporting it.
|
||||
|
||||
## UI System
|
||||
|
||||
- Foundry's base UI system is `BaseUI` with `Styletron`, plus Foundry-specific theme/tokens on top. Treat that as the default UI foundation.
|
||||
- The full `BaseUI` reference for available components and guidance on animations, customization, composition, and forms is at `https://base-ui.com/llms.txt`.
|
||||
- Prefer existing `BaseUI` components and composition patterns whenever possible instead of building custom controls from scratch.
|
||||
- Reuse the established Foundry theme/token layer for colors, typography, spacing, and surfaces instead of introducing ad hoc visual values.
|
||||
- If the same UI pattern is shared with the Inspector or other consumers, prefer extracting or reusing it through `@sandbox-agent/react` rather than duplicating it in Foundry.
|
||||
- If a requested UI cannot be implemented cleanly with an existing `BaseUI` component, stop and ask the user whether they are sure they want to diverge from the system.
|
||||
- In that case, recommend the closest existing `BaseUI` components or compositions that could satisfy the need before proposing custom UI work.
|
||||
- Only introduce custom UI primitives when `BaseUI` and existing Foundry patterns are not sufficient, or when the user explicitly confirms they want the divergence.
|
||||
|
||||
## Runtime Policy
|
||||
|
||||
- Runtime is Bun-native.
|
||||
|
|
@ -122,7 +143,9 @@ For all Rivet/RivetKit implementation:
|
|||
- Do not build blocking flows that wait on external systems to become ready or complete. Prefer push-based progression driven by actor messages, events, webhooks, or queue/workflow state changes.
|
||||
- Use workflows/background commands for any repo sync, sandbox provisioning, agent install, branch restack/rebase, or other multi-step external work. Do not keep user-facing actions/requests open while that work runs.
|
||||
- `send` policy: always `await` the `send(...)` call itself so enqueue failures surface immediately, but default to `wait: false`.
|
||||
- Only use `send(..., { wait: true })` for short, bounded mutations that should finish quickly and do not depend on external readiness, polling actors, provider setup, repo/network I/O, or long-running queue drains.
|
||||
- Only use `send(..., { wait: true })` for short, bounded local mutations (e.g. a DB write that returns a result the caller needs). Never use `wait: true` for operations that depend on external readiness, polling actors, provider setup, repo/network I/O, sandbox sessions, GitHub API calls, or long-running queue drains.
|
||||
- Never self-send with `wait: true` from inside a workflow handler — the workflow processes one message at a time, so the handler would deadlock waiting for the new message to be dequeued.
|
||||
- When an action is void-returning and triggers external work, use `wait: false` and let the UI react to state changes pushed by the workflow.
|
||||
- Request/action contract: wait only until the minimum resource needed for the client's next step exists. Example: task creation may wait for task actor creation/identity, but not for sandbox provisioning or session bootstrap.
|
||||
- Read paths must not force refresh/sync work inline. Serve the latest cached projection, mark staleness explicitly, and trigger background refresh separately when needed.
|
||||
- If a workflow needs to resume after some external work completes, model that as workflow state plus follow-up messages/events instead of holding the original request open.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ services:
|
|||
dockerfile: foundry/docker/backend.dev.Dockerfile
|
||||
image: foundry-backend-dev
|
||||
working_dir: /app
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
HF_BACKEND_HOST: "0.0.0.0"
|
||||
HF_BACKEND_PORT: "7741"
|
||||
|
|
|
|||
32
foundry/compose.mock.yaml
Normal file
32
foundry/compose.mock.yaml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
name: foundry-mock
|
||||
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: foundry/docker/frontend.dev.Dockerfile
|
||||
working_dir: /app
|
||||
environment:
|
||||
HOME: "/tmp"
|
||||
FOUNDRY_FRONTEND_CLIENT_MODE: "mock"
|
||||
ports:
|
||||
- "4174:4174"
|
||||
command: ["bash", "-lc", "pnpm install --force --frozen-lockfile --filter @sandbox-agent/foundry-frontend... && cd foundry/packages/frontend && exec pnpm vite --host 0.0.0.0 --port 4174"]
|
||||
volumes:
|
||||
- "..:/app"
|
||||
- "./.foundry:/app/foundry/.foundry"
|
||||
- "../../../task/rivet-checkout:/task/rivet-checkout:ro"
|
||||
- "mock_node_modules:/app/node_modules"
|
||||
- "mock_client_node_modules:/app/foundry/packages/client/node_modules"
|
||||
- "mock_frontend_errors_node_modules:/app/foundry/packages/frontend-errors/node_modules"
|
||||
- "mock_frontend_node_modules:/app/foundry/packages/frontend/node_modules"
|
||||
- "mock_shared_node_modules:/app/foundry/packages/shared/node_modules"
|
||||
- "mock_pnpm_store:/tmp/.local/share/pnpm/store"
|
||||
|
||||
volumes:
|
||||
mock_node_modules: {}
|
||||
mock_client_node_modules: {}
|
||||
mock_frontend_errors_node_modules: {}
|
||||
mock_frontend_node_modules: {}
|
||||
mock_shared_node_modules: {}
|
||||
mock_pnpm_store: {}
|
||||
|
|
@ -39,4 +39,4 @@ ENV SANDBOX_AGENT_BIN="/root/.local/bin/sandbox-agent"
|
|||
|
||||
WORKDIR /app
|
||||
|
||||
CMD ["bash", "-lc", "git config --global --add safe.directory /app >/dev/null 2>&1 || true; pnpm install --force --frozen-lockfile --filter @sandbox-agent/foundry-backend... && exec bun foundry/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"]
|
||||
CMD ["bash", "-lc", "git config --global --add safe.directory /app >/dev/null 2>&1 || true; pnpm install --frozen-lockfile --filter @sandbox-agent/foundry-backend... && exec bun --hot foundry/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"]
|
||||
|
|
|
|||
|
|
@ -144,14 +144,9 @@ export const task = actor({
|
|||
|
||||
async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> {
|
||||
const self = selfTask(c);
|
||||
const result = await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 30 * 60_000,
|
||||
await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, {
|
||||
wait: false,
|
||||
});
|
||||
const response = expectQueueResponse<{ ok: boolean; error?: string }>(result);
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error ?? "task provisioning failed");
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
|
|
@ -180,47 +175,35 @@ export const task = actor({
|
|||
async push(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.push"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 180_000,
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
|
||||
async sync(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.sync"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 30_000,
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
|
||||
async merge(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.merge"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 30_000,
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
|
||||
async archive(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
void self
|
||||
.send(taskWorkflowQueueName("task.command.archive"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
c.log.warn({
|
||||
msg: "archive command failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
await self.send(taskWorkflowQueueName("task.command.archive"), cmd ?? {}, {
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
|
||||
async kill(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.kill"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -255,8 +238,7 @@ export const task = actor({
|
|||
async renameWorkbenchBranch(c, input: TaskWorkbenchRenameInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.rename_branch"), { value: input.value } satisfies TaskWorkbenchValueCommand, {
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -335,8 +317,7 @@ export const task = actor({
|
|||
attachments: input.attachments,
|
||||
} satisfies TaskWorkbenchSendMessageCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 10 * 60_000,
|
||||
wait: false,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
|
@ -344,8 +325,7 @@ export const task = actor({
|
|||
async stopWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.stop_session"), { sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand, {
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -360,8 +340,7 @@ export const task = actor({
|
|||
async closeWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.close_session"), { sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand, {
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -371,8 +350,7 @@ export const task = actor({
|
|||
taskWorkflowQueueName("task.command.workbench.publish_pr"),
|
||||
{},
|
||||
{
|
||||
wait: true,
|
||||
timeout: 10 * 60_000,
|
||||
wait: false,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
|
@ -380,8 +358,7 @@ export const task = actor({
|
|||
async revertWorkbenchFile(c, input: { path: string }): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.revert_file"), input, {
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { getOrCreateTaskStatusSync, getOrCreateProject, getOrCreateWorkspace, ge
|
|||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||
import { task as taskTable, taskRuntime, taskWorkbenchSessions } from "./db/schema.js";
|
||||
import { getCurrentRecord } from "./workflow/common.js";
|
||||
import { taskWorkflowQueueName } from "./workflow/queue.js";
|
||||
|
||||
const STATUS_SYNC_INTERVAL_MS = 1_000;
|
||||
|
||||
|
|
@ -551,9 +552,11 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise<void
|
|||
export async function createWorkbenchSession(c: any, model?: string): Promise<{ tabId: string }> {
|
||||
let record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
// Fire-and-forget: enqueue provisioning without waiting to avoid self-deadlock
|
||||
// (this handler already runs inside the task workflow loop, so wait:true would deadlock).
|
||||
const providerId = record.providerId ?? c.state.providerId ?? getActorRuntimeContext().providers.defaultProviderId();
|
||||
await selfTask(c).provision({ providerId });
|
||||
record = await ensureWorkbenchSeeded(c);
|
||||
await selfTask(c).send(taskWorkflowQueueName("task.command.provision"), { providerId }, { wait: false });
|
||||
throw new Error("sandbox is provisioning — retry shortly");
|
||||
}
|
||||
|
||||
if (record.activeSessionId) {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!--
|
||||
<script src="https://unpkg.com/react-scan/dist/auto.global.js" crossorigin="anonymous"></script>
|
||||
<script type="module">
|
||||
if (import.meta.env.DEV) {
|
||||
import("react-grab");
|
||||
import("@react-grab/mcp/client");
|
||||
}
|
||||
</script>
|
||||
-->
|
||||
<script>if(window.__TAURI_INTERNALS__)document.documentElement.dataset.tauri="1"</script>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
|
|
|||
301
foundry/packages/frontend/src/components/dev-panel.tsx
Normal file
301
foundry/packages/frontend/src/components/dev-panel.tsx
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useStyletron } from "baseui";
|
||||
import { useFoundryTokens } from "../app/theme";
|
||||
import { isMockFrontendClient } from "../lib/env";
|
||||
import type { TaskWorkbenchSnapshot, WorkbenchTask } from "@sandbox-agent/foundry-shared";
|
||||
|
||||
interface DevPanelProps {
|
||||
workspaceId: string;
|
||||
snapshot: TaskWorkbenchSnapshot;
|
||||
}
|
||||
|
||||
interface TopicInfo {
|
||||
label: string;
|
||||
key: string;
|
||||
listenerCount: number;
|
||||
hasConnection: boolean;
|
||||
lastRefresh: number | null;
|
||||
}
|
||||
|
||||
function timeAgo(ts: number | null): string {
|
||||
if (!ts) return "never";
|
||||
const seconds = Math.floor((Date.now() - ts) / 1000);
|
||||
if (seconds < 5) return "now";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
return `${Math.floor(minutes / 60)}h`;
|
||||
}
|
||||
|
||||
function taskStatusLabel(task: WorkbenchTask): string {
|
||||
if (task.status === "archived") return "archived";
|
||||
const hasRunning = task.tabs?.some((tab) => tab.status === "running");
|
||||
if (hasRunning) return "running";
|
||||
return task.status ?? "idle";
|
||||
}
|
||||
|
||||
function statusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return t.statusSuccess;
|
||||
case "archived":
|
||||
return t.textMuted;
|
||||
case "error":
|
||||
case "failed":
|
||||
return t.statusError;
|
||||
default:
|
||||
return t.textTertiary;
|
||||
}
|
||||
}
|
||||
|
||||
export const DevPanel = memo(function DevPanel({ workspaceId, snapshot }: DevPanelProps) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
// Tick every 2s to keep relative timestamps fresh
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 2000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const topics = useMemo((): TopicInfo[] => {
|
||||
const items: TopicInfo[] = [];
|
||||
|
||||
// Workbench subscription topic
|
||||
items.push({
|
||||
label: "Workbench",
|
||||
key: `ws:${workspaceId}`,
|
||||
listenerCount: 1,
|
||||
hasConnection: true,
|
||||
lastRefresh: now,
|
||||
});
|
||||
|
||||
// Per-task tab subscriptions
|
||||
for (const task of snapshot.tasks ?? []) {
|
||||
if (task.status === "archived") continue;
|
||||
for (const tab of task.tabs ?? []) {
|
||||
items.push({
|
||||
label: `Tab/${task.title?.slice(0, 16) || task.id.slice(0, 8)}/${tab.sessionName.slice(0, 10)}`,
|
||||
key: `${workspaceId}:${task.id}:${tab.id}`,
|
||||
listenerCount: 1,
|
||||
hasConnection: tab.status === "running",
|
||||
lastRefresh: tab.status === "running" ? now : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [workspaceId, snapshot, now]);
|
||||
|
||||
const tasks = snapshot.tasks ?? [];
|
||||
const repos = snapshot.repos ?? [];
|
||||
const projects = snapshot.projects ?? [];
|
||||
|
||||
const mono = css({
|
||||
fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace",
|
||||
fontSize: "10px",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: "fixed",
|
||||
bottom: "8px",
|
||||
right: "8px",
|
||||
width: "320px",
|
||||
maxHeight: "50vh",
|
||||
zIndex: 99999,
|
||||
backgroundColor: t.surfaceElevated,
|
||||
border: `1px solid ${t.borderMedium}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: t.shadow,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "4px 8px",
|
||||
borderBottom: `1px solid ${t.borderSubtle}`,
|
||||
backgroundColor: t.surfaceTertiary,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: "10px",
|
||||
fontWeight: 600,
|
||||
color: t.textSecondary,
|
||||
letterSpacing: "0.5px",
|
||||
textTransform: "uppercase",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
})}
|
||||
>
|
||||
Dev
|
||||
{isMockFrontendClient && <span className={css({ fontSize: "8px", fontWeight: 600, color: t.statusWarning, letterSpacing: "0.3px" })}>MOCK</span>}
|
||||
</span>
|
||||
<span className={css({ fontSize: "9px", color: t.textMuted })}>Shift+D</span>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className={css({ overflowY: "auto", padding: "6px" })}>
|
||||
{/* Interest Topics */}
|
||||
<Section label="Interest Topics" t={t} css={css}>
|
||||
{topics.map((topic) => (
|
||||
<div
|
||||
key={topic.key}
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "2px 0",
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: "5px",
|
||||
height: "5px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: topic.hasConnection ? t.statusSuccess : t.textMuted,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: "10px", color: t.textPrimary, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
||||
{topic.label}
|
||||
</span>
|
||||
<span className={`${mono} ${css({ color: t.textMuted })}`}>{topic.key.length > 24 ? `...${topic.key.slice(-20)}` : topic.key}</span>
|
||||
<span className={`${mono} ${css({ color: t.textTertiary })}`}>{timeAgo(topic.lastRefresh)}</span>
|
||||
</div>
|
||||
))}
|
||||
{topics.length === 0 && <span className={css({ fontSize: "10px", color: t.textMuted })}>No active subscriptions</span>}
|
||||
</Section>
|
||||
|
||||
{/* Snapshot Summary */}
|
||||
<Section label="Snapshot" t={t} css={css}>
|
||||
<div className={css({ display: "flex", gap: "10px", fontSize: "10px" })}>
|
||||
<Stat label="repos" value={repos.length} t={t} css={css} />
|
||||
<Stat label="projects" value={projects.length} t={t} css={css} />
|
||||
<Stat label="tasks" value={tasks.length} t={t} css={css} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Tasks */}
|
||||
{tasks.length > 0 && (
|
||||
<Section label="Tasks" t={t} css={css}>
|
||||
{tasks.slice(0, 10).map((task) => {
|
||||
const status = taskStatusLabel(task);
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "1px 0",
|
||||
fontSize: "10px",
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: "5px",
|
||||
height: "5px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: statusColor(status, t),
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<span className={css({ color: t.textPrimary, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
||||
{task.title || task.id.slice(0, 12)}
|
||||
</span>
|
||||
<span className={`${mono} ${css({ color: statusColor(status, t) })}`}>{status}</span>
|
||||
<span className={`${mono} ${css({ color: t.textMuted })}`}>{task.tabs?.length ?? 0} tabs</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Workspace */}
|
||||
<Section label="Workspace" t={t} css={css}>
|
||||
<div className={`${mono} ${css({ color: t.textTertiary })}`}>{workspaceId}</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function Section({
|
||||
label,
|
||||
t,
|
||||
css: cssFn,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
t: ReturnType<typeof useFoundryTokens>;
|
||||
css: ReturnType<typeof useStyletron>[0];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cssFn({ marginBottom: "6px" })}>
|
||||
<div
|
||||
className={cssFn({
|
||||
fontSize: "9px",
|
||||
fontWeight: 600,
|
||||
color: t.textMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.5px",
|
||||
marginBottom: "2px",
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
t,
|
||||
css: cssFn,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
t: ReturnType<typeof useFoundryTokens>;
|
||||
css: ReturnType<typeof useStyletron>[0];
|
||||
}) {
|
||||
return (
|
||||
<span>
|
||||
<span className={cssFn({ fontWeight: 600, color: t.textPrimary })}>{value}</span>
|
||||
<span className={cssFn({ color: t.textTertiary, marginLeft: "2px" })}>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDevPanel() {
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.shiftKey && e.key === "D" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||
e.preventDefault();
|
||||
setVisible((prev) => !prev);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import { TabStrip } from "./mock-layout/tab-strip";
|
|||
import { TerminalPane } from "./mock-layout/terminal-pane";
|
||||
import { TranscriptHeader } from "./mock-layout/transcript-header";
|
||||
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui";
|
||||
import { DevPanel, useDevPanel } from "./dev-panel";
|
||||
import {
|
||||
buildDisplayMessages,
|
||||
diffPath,
|
||||
|
|
@ -910,6 +911,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
);
|
||||
const tasks = viewModel.tasks ?? [];
|
||||
const rawProjects = viewModel.projects ?? [];
|
||||
const showDevPanel = useDevPanel();
|
||||
const appSnapshot = useMockAppSnapshot();
|
||||
const activeOrg = activeMockOrganization(appSnapshot);
|
||||
const navigateToUsage = useCallback(() => {
|
||||
|
|
@ -1610,6 +1612,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showDevPanel && <DevPanel workspaceId={workspaceId} snapshot={viewModel} />}
|
||||
</Shell>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue