From c7bb9e215457f1e875b2d5b1642807d92709f3bf Mon Sep 17 00:00:00 2001 From: George Pickett Date: Wed, 28 Jan 2026 19:44:41 -0800 Subject: [PATCH 001/547] more reliable --- .agent/done/consolidate-project-fs-helpers.md | 124 +++ ARCHITECTURE.md | 148 ++++ .../tiles/[tileId]/heartbeat/route.ts | 230 +++++ .../tiles/[tileId]/workspace-files/route.ts | 16 +- src/app/globals.css | 27 +- src/app/page.tsx | 24 +- src/features/canvas/components/AgentTile.tsx | 837 ++++++++++++++---- .../canvas/components/AgentTileNode.tsx | 18 +- src/features/canvas/components/CanvasFlow.tsx | 47 +- .../canvas/components/ConnectionPanel.tsx | 2 +- src/features/canvas/components/HeaderBar.tsx | 2 +- src/features/canvas/state/store.tsx | 23 +- src/lib/canvasTileDefaults.ts | 2 + src/lib/projects/client.ts | 27 + src/lib/projects/types.ts | 23 + src/lib/projects/workspaceFiles.server.ts | 61 ++ tests/unit/workspaceFiles.test.ts | 51 ++ 17 files changed, 1435 insertions(+), 227 deletions(-) create mode 100644 .agent/done/consolidate-project-fs-helpers.md create mode 100644 ARCHITECTURE.md create mode 100644 src/app/api/projects/[projectId]/tiles/[tileId]/heartbeat/route.ts create mode 100644 src/lib/canvasTileDefaults.ts create mode 100644 src/lib/projects/workspaceFiles.server.ts create mode 100644 tests/unit/workspaceFiles.test.ts diff --git a/.agent/done/consolidate-project-fs-helpers.md b/.agent/done/consolidate-project-fs-helpers.md new file mode 100644 index 00000000..581be123 --- /dev/null +++ b/.agent/done/consolidate-project-fs-helpers.md @@ -0,0 +1,124 @@ +# Consolidate project filesystem helpers for API routes + +This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. + +This repository includes `.agent/PLANS.md`, and this ExecPlan must be maintained in accordance with its requirements. + +## Purpose / Big Picture + +Users should see the exact same behavior when creating, opening, and deleting workspaces or tiles, but the implementation should be safer to maintain. Today several API routes reimplement the same home-path expansion and agent artifact deletion logic, which increases the risk of future drift and inconsistent behavior. After this change, those routes share a single server-only helper module for path resolution and cleanup, so edits to filesystem behavior are made once and apply everywhere. + +## Progress + +- [x] (2026-01-29 03:42Z) Add a shared server-only filesystem helper module plus focused unit tests for path resolution and deletion helpers. +- [x] (2026-01-29 03:43Z) Replace per-route helper copies with shared imports and confirm all tests pass. + +## Surprises & Discoveries + +None yet. + +## Decision Log + +- Decision: Consolidate home-path resolution and agent artifact deletion into a new server-only helper in `src/lib/projects/fs.server.ts`, used by API routes that currently inline those helpers. + Rationale: This removes duplicated, potentially dangerous filesystem logic across multiple routes while keeping the blast radius small and validation straightforward. + Date/Author: 2026-01-29 / Codex + +## Outcomes & Retrospective + +Consolidated duplicated filesystem helpers into `src/lib/projects/fs.server.ts`, updated the four API routes to use shared helpers, and added unit tests for path resolution and deletion behavior. All unit tests passed with `npm test`. + +## Context and Orientation + +The API routes under `src/app/api/projects` implement workspace and tile actions. Several of these routes duplicate low-level filesystem helpers: + +- `src/app/api/projects/open/route.ts` expands `~` in user-provided workspace paths. +- `src/app/api/projects/[projectId]/route.ts` and `src/app/api/projects/[projectId]/tiles/[tileId]/route.ts` both implement `resolveHomePath`, `deleteDirIfExists`, and `deleteAgentArtifacts` to remove agent workspace/state. +- `src/app/api/projects/[projectId]/tiles/route.ts` also re-implements `resolveHomePath` for copying auth profiles. + +The shared workspace path logic already lives in `src/lib/projects/agentWorkspace.ts` via `resolveAgentWorkspaceDir`. We will introduce a new server-only helper module that centralizes home-path expansion and deletion helpers, then update the routes to use it. + +## Plan of Work + +First, create `src/lib/projects/fs.server.ts` to host shared filesystem utilities. This module should export `resolveHomePath`, `resolveClawdbotStateDir`, `resolveAgentStateDir`, `deleteDirIfExists`, and `deleteAgentArtifacts`. `deleteAgentArtifacts` should use `resolveAgentWorkspaceDir` and `resolveAgentStateDir` so the path logic is centralized. The module must be server-only and use Node built-ins directly, similar to `workspaceFiles.server.ts`. + +Second, write unit tests in `tests/unit/projectFs.test.ts` that validate the pure path-resolution helpers and the deletion helper on a temporary directory. The tests should avoid touching real user agent directories. Use a temporary directory and pass it directly to `deleteDirIfExists`. For `resolveHomePath`, validate `~`, `~/subdir`, and absolute paths without `~`. For `resolveClawdbotStateDir`, set and restore `process.env.CLAWDBOT_STATE_DIR` around the test and assert the resolved path expands `~`. + +Third, update these API routes to import from the new module and remove their local helper implementations: + +- `src/app/api/projects/open/route.ts` should import `resolveHomePath`. +- `src/app/api/projects/[projectId]/route.ts` should import `deleteAgentArtifacts` (and no longer define `resolveHomePath`, `deleteDirIfExists`, or `deleteAgentArtifacts`). +- `src/app/api/projects/[projectId]/tiles/[tileId]/route.ts` should import `deleteAgentArtifacts` and remove the duplicate helpers. +- `src/app/api/projects/[projectId]/tiles/route.ts` should import `resolveClawdbotStateDir` (or `resolveHomePath`) for auth profile copying, and remove its local `resolveHomePath` implementation. + +Keep behavior identical: error messages, warnings, and deletion semantics must not change. Only the helper placement and imports should move. + +## Concrete Steps + +From the repository root (`/Users/georgepickett/clawdbot-agent-ui`): + +1) Inspect current helper duplication for reference. + + rg -n "resolveHomePath|deleteAgentArtifacts|deleteDirIfExists" src/app/api/projects -S + +2) Create the helper module and tests. + + - Add `src/lib/projects/fs.server.ts` with the shared helpers. + - Add `tests/unit/projectFs.test.ts` with vitest coverage for path resolution and deletion of a temp directory. + +3) Replace per-route helpers with imports and remove the duplicated definitions. + +4) Run unit tests. + + npm test -- tests/unit/projectFs.test.ts + +If there are any lint or type issues, run `npm run lint` and `npm run typecheck` and fix them before proceeding. + +## Validation and Acceptance + +Behavioral acceptance: + +- Opening, creating, and deleting workspaces or tiles continues to return the same HTTP responses and warnings as before. +- All routes that previously inlined `resolveHomePath` or `deleteAgentArtifacts` now import them from `src/lib/projects/fs.server.ts`. + +Test acceptance, per milestone: + +Milestone 1 (helper + tests): + +1. Tests to write: In `tests/unit/projectFs.test.ts`, write tests named `resolvesHomePathVariants` and `resolvesClawdbotStateDirFromEnv`, plus `deleteDirIfExistsRemovesDirectory`. The home path test should assert that `resolveHomePath("~")` equals `os.homedir()`, `resolveHomePath("~/foo")` equals `path.join(os.homedir(), "foo")`, and `resolveHomePath("/tmp/x")` returns `/tmp/x` unchanged. The state dir test should set `process.env.CLAWDBOT_STATE_DIR` to `~/state-test` and assert the resolved path is `path.join(os.homedir(), "state-test")`, then restore the env var. The deletion test should create a temp directory via `fs.mkdtempSync`, call `deleteDirIfExists` on it, and assert it no longer exists. +2. Implementation: Add `src/lib/projects/fs.server.ts` with the exported helpers. +3. Verification: Run `npm test -- tests/unit/projectFs.test.ts` and confirm all tests pass. +4. Commit: After tests pass, commit with message `Milestone 1: Add shared project fs helpers and tests`. + +Milestone 2 (route updates): + +1. Tests to write: No new tests beyond Milestone 1; ensure existing tests still pass. +2. Implementation: Update the four API route files to import and use the shared helpers, removing local duplicates. +3. Verification: Run `npm test` and confirm all tests pass. +4. Commit: After tests pass, commit with message `Milestone 2: Use shared fs helpers in project routes`. + +## Idempotence and Recovery + +The changes are safe to apply multiple times because they only move logic and adjust imports. If a step fails, revert the file being edited to the last committed state and reapply the change. If any route behavior changes unexpectedly, restore the previous helper implementation from git history and reassess before proceeding. + +## Artifacts and Notes + +Expected test command output (example): + + $ npm test -- tests/unit/projectFs.test.ts + ✓ tests/unit/projectFs.test.ts (3) + +## Interfaces and Dependencies + +Define these exports in `src/lib/projects/fs.server.ts`: + +- `resolveHomePath(inputPath: string): string` which expands `~` and `~/` to the OS home directory. +- `resolveClawdbotStateDir(): string` which resolves `process.env.CLAWDBOT_STATE_DIR ?? "~/.clawdbot"` via `resolveHomePath`. +- `resolveAgentStateDir(agentId: string): string` which returns `path.join(resolveClawdbotStateDir(), "agents", agentId)`. +- `deleteDirIfExists(targetPath: string, label: string, warnings: string[]): void` which deletes an existing directory, appending warnings when it does not exist. +- `deleteAgentArtifacts(projectId: string, agentId: string, warnings: string[]): void` which deletes both the agent workspace directory (via `resolveAgentWorkspaceDir`) and the agent state directory (via `resolveAgentStateDir`). + +These helpers should use Node built-ins (`fs`, `path`, `os`) and stay server-only like `workspaceFiles.server.ts`. + +--- + +Plan change note: Initial version created from refactor recommendation to consolidate duplicated filesystem helpers in project API routes. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..63b244d8 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,148 @@ +# Architecture + +## High-level overview & goals +This repo is a local-first, single-user Next.js App Router UI for managing Clawdbot/Moltbot agents on a canvas. It provides: +- A canvas-based workspace UI for multiple agent tiles. +- Local persistence for projects/tiles via a JSON store on disk. +- Integration with the Clawdbot runtime via a WebSocket gateway. +- Optional Discord channel provisioning for agents. + +Primary goals: +- **Local-first**: zero external DB, fast startup, filesystem-backed state. +- **Clear boundaries**: client UI vs server routes vs external gateway/config. +- **Predictable state**: single source of truth for project/tile state. +- **Maintainability**: feature-focused modules, minimal abstractions. + +Non-goals: +- Multi-tenant or multi-user concurrency. +- Server-side rendering of data from external services. + +## Architectural style +**Layered + vertical slice (feature-first)** within Next.js App Router: +- UI components + feature state in `src/features`. +- Shared utilities and adapters in `src/lib`. +- Server-side route handlers under `src/app/api`. + +This keeps feature cohesion high while preserving a clear client/server boundary. + +## Main modules / bounded contexts +- **Canvas UI** (`src/features/canvas`): React Flow canvas, tiles, editor UI, local in-memory state + actions. +- **Projects** (`src/lib/projects`, `src/app/api/projects`): project/tile models, store persistence, workspace files, heartbeat settings, server-side filesystem helpers (`src/lib/projects/fs.server.ts`) for path resolution and agent cleanup. +- **Gateway** (`src/lib/gateway`): WebSocket client for agent runtime (frames, connect, request/response). +- **Clawdbot config** (`src/lib/clawdbot`): read/write moltbot.json, agent list and heartbeat defaults. +- **Discord integration** (`src/lib/discord`, API route): channel provisioning and config binding. +- **Shared utilities** (`src/lib/*`): env, ids, names, avatars, text parsing, logging, filesystem helpers. + +## Directory layout (top-level) +- `src/app`: Next.js App Router pages, layouts, global styles, and API routes. +- `src/features`: feature-first UI modules (currently canvas). +- `src/lib`: domain utilities, adapters, API clients, and shared logic. +- `src/components`: shared UI components (minimal use today). +- `src/styles`: shared styling assets. +- `public`: static assets. +- `tests`, `playwright.config.ts`, `vitest.config.ts`: automated testing. + +## Data flow & key boundaries +### 1) Project + tile state +- **Source of truth**: JSON store on disk at `~/.clawdbot/agent-canvas/projects.json`. +- **Server boundary**: `src/app/api/projects/*` handles validation, persistence, and side effects. +- **Client boundary**: `AgentCanvasProvider` loads store on startup, caches in memory, and persists via API. + +Flow: +1. UI dispatches action. +2. Client calls `lib/projects/client`. +3. API route mutates store + writes files/config. +4. API returns updated store. +5. Client hydrates store into runtime state. + +### 2) Agent runtime (gateway) +- **Client-side only**: `GatewayClient` uses WebSocket to connect to the local Clawdbot gateway. +- **API is not in the middle**: UI speaks directly to the gateway for streaming and agent events. + +Flow: +1. UI loads gateway URL/token from `/api/gateway`. +2. `GatewayClient` connects + sends `connect` request. +3. UI sends requests (frames) and receives event streams. +4. Canvas store updates tile output/state. + +### 3) Workspace files + heartbeat +- **Workspace files**: `AGENTS.md`, `SOUL.md`, `IDENTITY.md`, `USER.md`, `TOOLS.md`, `HEARTBEAT.md`, `MEMORY.md`. +- **Heartbeat**: stored in `moltbot.json` agent list entries. + +Flow: +1. UI requests workspace files/heartbeat via API. +2. API reads/writes filesystem + config. +3. UI reflects persisted state. + +### 4) Discord provisioning +- API route calls `createDiscordChannelForAgent`. +- Uses DISCORD_BOT_TOKEN from `~/.clawdbot/.env`. +- Updates `moltbot.json` bindings and channel config. + +## Cross-cutting concerns +- **Configuration**: `src/lib/env` validates env via zod; `lib/clawdbot` resolves config path and state dirs. +- **Logging**: `src/lib/logger` (console wrappers) used in API routes and gateway client. +- **Error handling**: + - API routes return JSON `{ error }` with appropriate status. + - `fetchJson` throws when `!res.ok`, surfaces errors to UI state. +- **Filesystem helpers**: `src/lib/projects/fs.server.ts` centralizes home-path expansion and agent workspace/state cleanup for API routes. +- **Tracing**: `src/instrumentation.ts` registers `@vercel/otel` for telemetry. +- **Validation**: request payload validation in API routes; typed payloads in `lib/projects/types`. + +## Major design decisions & trade-offs +- **Local JSON store over DB**: faster iteration, local-first; trade-off is no concurrency or multi-user support. +- **WebSocket gateway direct to client**: lowest latency for streaming; trade-off is tighter coupling to the gateway protocol in the UI. +- **Feature-first organization**: increases cohesion in UI; trade-off is more discipline to keep shared logic in `lib`. +- **Node runtime for API routes**: required for filesystem access; trade-off is Node-only server runtime. + +## Mermaid diagrams +### C4 Level 1 (System Context) +```mermaid +C4Context + title Clawdbot Agent UI - System Context + Person(user, "User", "Operates agent canvas locally") + System(ui, "Clawdbot Agent UI", "Next.js App Router UI") + System_Ext(gateway, "Clawdbot Gateway", "WebSocket runtime") + System_Ext(fs, "Local Filesystem", "projects.json, workspace files, moltbot.json") + System_Ext(discord, "Discord API", "Optional channel provisioning") + + Rel(user, ui, "Uses") + Rel(ui, gateway, "WebSocket frames") + Rel(ui, fs, "HTTP to API routes -> fs read/write") + Rel(ui, discord, "HTTP via API route") +``` + +### C4 Level 2 (Containers/Components) +```mermaid +C4Container + title Clawdbot Agent UI - Containers + Person(user, "User") + + Container_Boundary(app, "Next.js App") { + Container(client, "Client UI", "React", "Canvas UI, state, gateway client") + Container(api, "API Routes", "Next.js route handlers", "Projects, tiles, workspace, heartbeat, gateway config") + } + + Container_Ext(gateway, "Gateway", "WebSocket", "Agent runtime") + Container_Ext(fs, "Filesystem", "Local", "projects.json, workspace files, moltbot.json") + Container_Ext(discord, "Discord API", "REST", "Channel provisioning") + + Rel(user, client, "Uses") + Rel(client, api, "HTTP JSON") + Rel(client, gateway, "WebSocket") + Rel(api, fs, "Read/Write") + Rel(api, discord, "REST") +``` + +## Explicit forbidden patterns +- Do not read/write local files directly from client components. +- Do not bypass `lib/projects/client` for API calls from UI. +- Do not introduce a new persistence layer without a clear migration path from `projects.json`. +- Do not store gateway tokens or secrets in client-side persistent storage. +- Do not add new global mutable state outside `AgentCanvasProvider` for canvas data. +- Do not silently swallow errors in API routes; always return actionable errors. +- Do not add heavy abstractions or frameworks unless there is clear evidence of need. + +## Future-proofing notes +- If multi-user support becomes a goal, replace the JSON store with a DB-backed service and introduce authentication at the API boundary. +- If gateway protocol evolves, isolate changes within `src/lib/gateway` and keep UI call sites stable. diff --git a/src/app/api/projects/[projectId]/tiles/[tileId]/heartbeat/route.ts b/src/app/api/projects/[projectId]/tiles/[tileId]/heartbeat/route.ts new file mode 100644 index 00000000..7a9de8f3 --- /dev/null +++ b/src/app/api/projects/[projectId]/tiles/[tileId]/heartbeat/route.ts @@ -0,0 +1,230 @@ +import { NextResponse } from "next/server"; + +import { logger } from "@/lib/logger"; +import { loadClawdbotConfig, saveClawdbotConfig } from "@/lib/clawdbot/config"; +import type { + ProjectTileHeartbeat, + ProjectTileHeartbeatUpdatePayload, +} from "@/lib/projects/types"; +import { loadStore } from "../../../../store"; + +export const runtime = "nodejs"; + +type HeartbeatBlock = Record | null | undefined; + +type AgentEntry = Record & { id?: string }; + +type HeartbeatResolved = { + heartbeat: ProjectTileHeartbeat; + hasOverride: boolean; +}; + +const DEFAULT_EVERY = "30m"; +const DEFAULT_TARGET = "last"; +const DEFAULT_ACK_MAX_CHARS = 300; + +const coerceString = (value: unknown) => (typeof value === "string" ? value : undefined); + +const coerceBoolean = (value: unknown) => + typeof value === "boolean" ? value : undefined; + +const coerceNumber = (value: unknown) => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +const coerceActiveHours = (value: unknown) => { + if (!value || typeof value !== "object") return undefined; + const start = coerceString((value as Record).start); + const end = coerceString((value as Record).end); + if (!start || !end) return undefined; + return { start, end }; +}; + +const mergeHeartbeat = (defaults: HeartbeatBlock, override: HeartbeatBlock) => { + const merged = { + ...(defaults ?? {}), + ...(override ?? {}), + } as Record; + if (override && typeof override === "object" && "activeHours" in override) { + merged.activeHours = (override as Record).activeHours; + } else if (defaults && typeof defaults === "object" && "activeHours" in defaults) { + merged.activeHours = (defaults as Record).activeHours; + } + return merged; +}; + +const normalizeHeartbeat = (defaults: HeartbeatBlock, override: HeartbeatBlock) => { + const resolved = mergeHeartbeat(defaults, override); + const every = coerceString(resolved.every) ?? DEFAULT_EVERY; + const target = coerceString(resolved.target) ?? DEFAULT_TARGET; + const includeReasoning = coerceBoolean(resolved.includeReasoning) ?? false; + const ackMaxChars = + coerceNumber(resolved.ackMaxChars) ?? DEFAULT_ACK_MAX_CHARS; + const activeHours = coerceActiveHours(resolved.activeHours) ?? null; + return { + heartbeat: { + every, + target, + includeReasoning, + ackMaxChars, + activeHours, + }, + hasOverride: Boolean(override && typeof override === "object"), + } satisfies HeartbeatResolved; +}; + +const resolveTile = async ( + params: Promise<{ projectId: string; tileId: string }> +) => { + const { projectId, tileId } = await params; + const trimmedProjectId = projectId.trim(); + const trimmedTileId = tileId.trim(); + if (!trimmedProjectId || !trimmedTileId) { + return { + error: NextResponse.json( + { error: "Workspace id and tile id are required." }, + { status: 400 } + ), + }; + } + const store = loadStore(); + const project = store.projects.find((entry) => entry.id === trimmedProjectId); + if (!project) { + return { error: NextResponse.json({ error: "Workspace not found." }, { status: 404 }) }; + } + const tile = project.tiles.find((entry) => entry.id === trimmedTileId); + if (!tile) { + return { error: NextResponse.json({ error: "Tile not found." }, { status: 404 }) }; + } + return { projectId: trimmedProjectId, tileId: trimmedTileId, tile }; +}; + +const readAgentList = (config: Record): AgentEntry[] => { + const agents = (config.agents ?? {}) as Record; + const list = Array.isArray(agents.list) ? agents.list : []; + return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object")); +}; + +const writeAgentList = (config: Record, list: AgentEntry[]) => { + const agents = (config.agents ?? {}) as Record; + agents.list = list; + config.agents = agents; +}; + +const readHeartbeatDefaults = (config: Record): HeartbeatBlock => { + const agents = (config.agents ?? {}) as Record; + const defaults = (agents.defaults ?? {}) as Record; + return (defaults.heartbeat ?? null) as HeartbeatBlock; +}; + +export async function GET( + _request: Request, + context: { params: Promise<{ projectId: string; tileId: string }> } +) { + try { + const resolved = await resolveTile(context.params); + if ("error" in resolved) { + return resolved.error; + } + const { tile } = resolved; + const { config } = loadClawdbotConfig(); + const list = readAgentList(config); + const entry = list.find((item) => item.id === tile.agentId) ?? null; + const defaults = readHeartbeatDefaults(config); + const override = + entry && typeof entry === "object" + ? ((entry as Record).heartbeat as HeartbeatBlock) + : null; + return NextResponse.json(normalizeHeartbeat(defaults, override)); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load heartbeat settings."; + logger.error(message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + context: { params: Promise<{ projectId: string; tileId: string }> } +) { + try { + const resolved = await resolveTile(context.params); + if ("error" in resolved) { + return resolved.error; + } + const { tile } = resolved; + const body = (await request.json()) as ProjectTileHeartbeatUpdatePayload; + if (!body || typeof body.override !== "boolean" || !body.heartbeat) { + return NextResponse.json({ error: "Heartbeat payload is invalid." }, { status: 400 }); + } + + const every = typeof body.heartbeat.every === "string" ? body.heartbeat.every.trim() : ""; + const target = typeof body.heartbeat.target === "string" ? body.heartbeat.target.trim() : ""; + const includeReasoning = body.heartbeat.includeReasoning; + const ackMaxChars = body.heartbeat.ackMaxChars; + const activeHours = body.heartbeat.activeHours; + + if (!every) { + return NextResponse.json({ error: "Heartbeat interval is required." }, { status: 400 }); + } + if (!target) { + return NextResponse.json({ error: "Heartbeat target is required." }, { status: 400 }); + } + if (typeof includeReasoning !== "boolean") { + return NextResponse.json({ error: "includeReasoning must be true or false." }, { status: 400 }); + } + if (ackMaxChars !== undefined && ackMaxChars !== null) { + if (typeof ackMaxChars !== "number" || !Number.isFinite(ackMaxChars)) { + return NextResponse.json({ error: "ackMaxChars must be a number." }, { status: 400 }); + } + } + if (activeHours !== undefined && activeHours !== null) { + const start = coerceString(activeHours.start); + const end = coerceString(activeHours.end); + if (!start || !end) { + return NextResponse.json({ error: "Active hours must include start and end." }, { status: 400 }); + } + } + + const { config, configPath } = loadClawdbotConfig(); + const list = readAgentList(config); + const index = list.findIndex((entry) => entry.id === tile.agentId); + const entry: AgentEntry = index >= 0 ? { ...list[index] } : { id: tile.agentId }; + + if (!body.override) { + if ("heartbeat" in entry) { + delete entry.heartbeat; + } + } else { + const nextHeartbeat: Record = { + every, + target, + includeReasoning, + }; + if (ackMaxChars !== undefined && ackMaxChars !== null) { + nextHeartbeat.ackMaxChars = ackMaxChars; + } + if (activeHours) { + nextHeartbeat.activeHours = { start: activeHours.start, end: activeHours.end }; + } + entry.heartbeat = nextHeartbeat; + } + + if (index >= 0) { + list[index] = entry; + } else { + list.push(entry); + } + writeAgentList(config, list); + saveClawdbotConfig(configPath, config); + + const defaults = readHeartbeatDefaults(config); + const override = body.override + ? ((entry as Record).heartbeat as HeartbeatBlock) + : null; + return NextResponse.json(normalizeHeartbeat(defaults, override)); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to save heartbeat settings."; + logger.error(message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/projects/[projectId]/tiles/[tileId]/workspace-files/route.ts b/src/app/api/projects/[projectId]/tiles/[tileId]/workspace-files/route.ts index 5fd3d9ce..ab0254f0 100644 --- a/src/app/api/projects/[projectId]/tiles/[tileId]/workspace-files/route.ts +++ b/src/app/api/projects/[projectId]/tiles/[tileId]/workspace-files/route.ts @@ -6,26 +6,12 @@ import path from "node:path"; import { logger } from "@/lib/logger"; import { resolveAgentWorkspaceDir } from "@/lib/projects/agentWorkspace"; import { WORKSPACE_FILE_NAMES, type WorkspaceFileName } from "@/lib/projects/workspaceFiles"; +import { isWorkspaceFileName, readWorkspaceFile } from "@/lib/projects/workspaceFiles.server"; import type { ProjectTileWorkspaceFilesUpdatePayload } from "@/lib/projects/types"; import { loadStore } from "../../../../store"; export const runtime = "nodejs"; -const isWorkspaceFileName = (value: string): value is WorkspaceFileName => - WORKSPACE_FILE_NAMES.includes(value as WorkspaceFileName); - -const readWorkspaceFile = (workspaceDir: string, name: WorkspaceFileName) => { - const filePath = path.join(workspaceDir, name); - if (!fs.existsSync(filePath)) { - return { name, content: "", exists: false }; - } - const stat = fs.statSync(filePath); - if (!stat.isFile()) { - throw new Error(`${name} exists but is not a file.`); - } - return { name, content: fs.readFileSync(filePath, "utf8"), exists: true }; -}; - const resolveTile = async ( params: Promise<{ projectId: string; tileId: string }> ) => { diff --git a/src/app/globals.css b/src/app/globals.css index 7b1b14b2..b6c4d696 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -147,26 +147,7 @@ body { border-color: transparent; } -.tile-resize-handle { - height: 8px; - width: 112px; - border-radius: 999px; - border: 1px solid rgba(148, 163, 184, 0.7); - background-color: rgba(255, 255, 255, 0.9); - box-shadow: 0 6px 12px rgba(15, 23, 42, 0.18); - opacity: 0; - transition: opacity 160ms ease; - z-index: 2; -} - -.react-flow__node:hover .tile-resize-handle, -.react-flow__node.selected .tile-resize-handle { - opacity: 1; -} -.tile-resize-handle.react-flow__resize-control.bottom { - transform: translate(-50%, -100%); -} .fade-up { animation: fadeUp 600ms ease-out both; @@ -179,24 +160,24 @@ body { .agent-avatar-selected { animation: agentAvatarPulse 2.6s ease-in-out infinite; box-shadow: - 0 0 0 0 rgba(59, 130, 246, 0.22), + 0 0 0 0 rgba(34, 197, 94, 0.22), 0 8px 18px rgba(15, 23, 42, 0.12); } @keyframes agentAvatarPulse { 0% { box-shadow: - 0 0 0 0 rgba(59, 130, 246, 0.22), + 0 0 0 0 rgba(34, 197, 94, 0.22), 0 8px 18px rgba(15, 23, 42, 0.12); } 50% { box-shadow: - 0 0 0 6px rgba(59, 130, 246, 0.14), + 0 0 0 6px rgba(34, 197, 94, 0.14), 0 10px 22px rgba(15, 23, 42, 0.16); } 100% { box-shadow: - 0 0 0 0 rgba(59, 130, 246, 0.22), + 0 0 0 0 rgba(34, 197, 94, 0.22), 0 8px 18px rgba(15, 23, 42, 0.12); } } diff --git a/src/app/page.tsx b/src/app/page.tsx index e732302d..cb7baef2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; // (ReactFlowInstance import removed) import { CanvasFlow } from "@/features/canvas/components/CanvasFlow"; import { HeaderBar } from "@/features/canvas/components/HeaderBar"; -import { MIN_TILE_SIZE } from "@/features/canvas/components/AgentTile"; +import { MAX_TILE_HEIGHT, MIN_TILE_SIZE } from "@/lib/canvasTileDefaults"; import { screenToWorld, worldToScreen } from "@/features/canvas/lib/transform"; import { extractText } from "@/lib/text/extractText"; import { extractThinking, formatThinkingMarkdown } from "@/lib/text/extractThinking"; @@ -43,6 +43,8 @@ const RESET_PROMPT_RE = const MESSAGE_ID_RE = /\s*\[message_id:[^\]]+\]\s*/gi; const UI_METADATA_PREFIX_RE = /^(?:Project path:|Workspace path:|A new session was started via \/new or \/reset)/i; +const HEARTBEAT_PROMPT_RE = /^Read HEARTBEAT\.md if it exists\b/i; +const HEARTBEAT_PATH_RE = /Heartbeat file path:/i; const stripUiMetadata = (text: string) => { if (!text) return text; @@ -56,6 +58,12 @@ const stripUiMetadata = (text: string) => { return cleaned; }; +const isHeartbeatPrompt = (text: string) => { + if (!text) return false; + const trimmed = text.trim(); + if (!trimmed) return false; + return HEARTBEAT_PROMPT_RE.test(trimmed) || HEARTBEAT_PATH_RE.test(trimmed); +}; type ChatHistoryMessage = Record; @@ -78,6 +86,9 @@ const buildHistoryLines = (messages: ChatHistoryMessage[]) => { role === "assistant" ? formatThinkingMarkdown(extractThinking(message) ?? "") : ""; if (!text && !thinking) continue; if (role === "user") { + if (text && isHeartbeatPrompt(text)) { + continue; + } if (text) { lines.push(`> ${text}`); } @@ -211,7 +222,7 @@ const AgentCanvasPage = () => { const zoom = state.canvas.zoom; const effectiveSize = { - width: MIN_TILE_SIZE.width, + width: Math.max(tileSize.width, MIN_TILE_SIZE.width), height: Math.max(tileSize.height, MIN_TILE_SIZE.height), }; @@ -267,7 +278,7 @@ const AgentCanvasPage = () => { { x: tile.position.x, y: tile.position.y, - width: MIN_TILE_SIZE.width, + width: Math.max(tile.size.width, MIN_TILE_SIZE.width), height: Math.max(tile.size.height, MIN_TILE_SIZE.height), }, 24 @@ -906,8 +917,11 @@ const AgentCanvasPage = () => { tileId: id, patch: { size: { - width: MIN_TILE_SIZE.width, - height: Math.max(size.height, MIN_TILE_SIZE.height), + height: Math.min( + MAX_TILE_HEIGHT, + Math.max(size.height, MIN_TILE_SIZE.height) + ), + width: Math.max(size.width, MIN_TILE_SIZE.width), }, }, }) diff --git a/src/features/canvas/components/AgentTile.tsx b/src/features/canvas/components/AgentTile.tsx index 705dd7b0..3f1e515e 100644 --- a/src/features/canvas/components/AgentTile.tsx +++ b/src/features/canvas/components/AgentTile.tsx @@ -8,7 +8,9 @@ import { isTraceMarkdown, stripTraceMarkdown } from "@/lib/text/extractThinking" import { normalizeAgentName } from "@/lib/names/agentNames"; import { Settings, Shuffle } from "lucide-react"; import { + fetchProjectTileHeartbeat, fetchProjectTileWorkspaceFiles, + updateProjectTileHeartbeat, updateProjectTileWorkspaceFiles, } from "@/lib/projects/client"; import { @@ -17,21 +19,16 @@ import { WORKSPACE_FILE_PLACEHOLDERS, type WorkspaceFileName, } from "@/lib/projects/workspaceFiles"; +import { MAX_TILE_HEIGHT, MIN_TILE_SIZE } from "@/lib/canvasTileDefaults"; import { AgentAvatar } from "./AgentAvatar"; -export const MIN_TILE_SIZE = { width: 420, height: 520 }; +const HEARTBEAT_INTERVAL_OPTIONS = ["15m", "30m", "1h", "2h", "6h", "12h", "24h"]; const buildWorkspaceState = () => Object.fromEntries( WORKSPACE_FILE_NAMES.map((name) => [name, { content: "", exists: false }]) ) as Record; -const buildWorkspaceExpanded = () => - Object.fromEntries(WORKSPACE_FILE_NAMES.map((name) => [name, false])) as Record< - WorkspaceFileName, - boolean - >; - const isWorkspaceFileName = (value: string): value is WorkspaceFileName => WORKSPACE_FILE_NAMES.includes(value as WorkspaceFileName); @@ -49,6 +46,7 @@ type AgentTileProps = { onAvatarShuffle: () => void; onNameShuffle: () => void; onResize?: (size: TileSize) => void; + onResizeEnd?: (size: TileSize) => void; }; export const AgentTile = ({ @@ -65,20 +63,59 @@ export const AgentTile = ({ onAvatarShuffle, onNameShuffle, onResize, + onResizeEnd, }: AgentTileProps) => { const [nameDraft, setNameDraft] = useState(tile.name); const [settingsOpen, setSettingsOpen] = useState(false); const [workspaceFiles, setWorkspaceFiles] = useState(buildWorkspaceState); - const [workspaceExpanded, setWorkspaceExpanded] = useState(buildWorkspaceExpanded); + const [workspaceTab, setWorkspaceTab] = useState( + WORKSPACE_FILE_NAMES[0] + ); const [workspaceLoading, setWorkspaceLoading] = useState(false); const [workspaceSaving, setWorkspaceSaving] = useState(false); const [workspaceDirty, setWorkspaceDirty] = useState(false); const [workspaceError, setWorkspaceError] = useState(null); - const workspaceItemRefs = useRef>( - buildWorkspaceExpanded() - ); + const [heartbeatLoading, setHeartbeatLoading] = useState(false); + const [heartbeatSaving, setHeartbeatSaving] = useState(false); + const [heartbeatDirty, setHeartbeatDirty] = useState(false); + const [heartbeatError, setHeartbeatError] = useState(null); + const [heartbeatOverride, setHeartbeatOverride] = useState(false); + const [heartbeatEnabled, setHeartbeatEnabled] = useState(true); + const [heartbeatEvery, setHeartbeatEvery] = useState("30m"); + const [heartbeatIntervalMode, setHeartbeatIntervalMode] = useState< + "preset" | "custom" + >("preset"); + const [heartbeatCustomMinutes, setHeartbeatCustomMinutes] = useState("45"); + const [heartbeatTargetMode, setHeartbeatTargetMode] = useState< + "last" | "none" | "custom" + >("last"); + const [heartbeatTargetCustom, setHeartbeatTargetCustom] = useState(""); + const [heartbeatIncludeReasoning, setHeartbeatIncludeReasoning] = useState(false); + const [heartbeatActiveHoursEnabled, setHeartbeatActiveHoursEnabled] = + useState(false); + const [heartbeatActiveStart, setHeartbeatActiveStart] = useState("08:00"); + const [heartbeatActiveEnd, setHeartbeatActiveEnd] = useState("18:00"); + const [heartbeatAckMaxChars, setHeartbeatAckMaxChars] = useState("300"); const outputRef = useRef(null); const draftRef = useRef(null); + const resizeStateRef = useRef<{ + active: boolean; + axis: "height" | "width"; + startX?: number; + startY?: number; + startWidth?: number; + startHeight?: number; + } | null>(null); + const userResizedRef = useRef(false); + const resizeFrameRef = useRef(null); + const resizeSizeRef = useRef({ + width: tile.size.width, + height: tile.size.height, + }); + const resizeHandlersRef = useRef<{ + move: (event: PointerEvent) => void; + stop: () => void; + } | null>(null); const scrollOutputToBottom = useCallback(() => { const el = outputRef.current; if (!el) return; @@ -87,7 +124,6 @@ export const AgentTile = ({ const handleOutputWheel = useCallback( (event: React.WheelEvent) => { - if (!isSelected) return; const el = outputRef.current; if (!el) return; event.preventDefault(); @@ -99,7 +135,7 @@ export const AgentTile = ({ el.scrollTop = nextTop; el.scrollLeft = nextLeft; }, - [isSelected] + [] ); useEffect(() => { @@ -112,6 +148,7 @@ export const AgentTile = ({ if (!el) return; el.style.height = "auto"; el.style.height = `${el.scrollHeight}px`; + el.style.overflowY = el.scrollHeight > el.clientHeight ? "auto" : "hidden"; }, []); useEffect(() => { @@ -122,14 +159,137 @@ export const AgentTile = ({ resizeDraft(); }, [resizeDraft, tile.draft]); + useEffect(() => { + resizeSizeRef.current = { + width: tile.size.width, + height: tile.size.height, + }; + }, [tile.size.height, tile.size.width]); + + const stopResize = useCallback(() => { + if (!resizeStateRef.current?.active) return; + resizeStateRef.current = null; + if (resizeHandlersRef.current) { + window.removeEventListener("pointermove", resizeHandlersRef.current.move); + window.removeEventListener("pointerup", resizeHandlersRef.current.stop); + window.removeEventListener("pointercancel", resizeHandlersRef.current.stop); + resizeHandlersRef.current = null; + } + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + if (resizeFrameRef.current !== null) { + cancelAnimationFrame(resizeFrameRef.current); + resizeFrameRef.current = null; + } + if (onResizeEnd) { + onResizeEnd(resizeSizeRef.current); + } + }, [onResizeEnd]); + + const scheduleResize = useCallback( + (size: Partial) => { + resizeSizeRef.current = { + ...resizeSizeRef.current, + ...size, + }; + if (resizeFrameRef.current !== null) return; + resizeFrameRef.current = requestAnimationFrame(() => { + resizeFrameRef.current = null; + onResize?.(resizeSizeRef.current); + }); + }, + [onResize] + ); + + const startHeightResize = useCallback( + (event: React.PointerEvent) => { + if (!onResize) return; + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + const startY = event.clientY; + const startHeight = tile.size.height; + resizeStateRef.current = { + active: true, + axis: "height", + startY, + startHeight, + }; + document.body.style.cursor = "row-resize"; + document.body.style.userSelect = "none"; + const move = (moveEvent: PointerEvent) => { + if (!resizeStateRef.current?.active) return; + const delta = moveEvent.clientY - startY; + const nextHeight = Math.min( + MAX_TILE_HEIGHT, + Math.max(MIN_TILE_SIZE.height, startHeight + delta) + ); + scheduleResize({ height: nextHeight }); + }; + const stop = () => { + stopResize(); + }; + resizeHandlersRef.current = { move, stop }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", stop); + window.addEventListener("pointercancel", stop); + }, + [onResize, scheduleResize, stopResize, tile.size.height] + ); + + const startWidthResize = useCallback( + (event: React.PointerEvent) => { + if (!onResize) return; + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + userResizedRef.current = true; + event.currentTarget.setPointerCapture(event.pointerId); + const startX = event.clientX; + const startWidth = tile.size.width; + resizeStateRef.current = { + active: true, + axis: "width", + startX, + startWidth, + }; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + const move = (moveEvent: PointerEvent) => { + if (!resizeStateRef.current?.active) return; + const delta = moveEvent.clientX - startX; + const nextWidth = Math.max(MIN_TILE_SIZE.width, startWidth + delta); + scheduleResize({ width: nextWidth }); + }; + const stop = () => { + stopResize(); + }; + resizeHandlersRef.current = { move, stop }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", stop); + window.addEventListener("pointercancel", stop); + }, + [onResize, scheduleResize, stopResize, tile.size.width] + ); + useEffect(() => { const output = outputRef.current; if (!output) return; + if (resizeStateRef.current?.active || userResizedRef.current) return; const extra = Math.ceil(output.scrollHeight - output.clientHeight); if (extra <= 0) return; - onResize?.({ width: tile.size.width, height: tile.size.height + extra }); + const nextHeight = Math.min(MAX_TILE_HEIGHT, tile.size.height + extra); + if (nextHeight <= tile.size.height) return; + if (onResizeEnd) { + resizeSizeRef.current = { ...resizeSizeRef.current, height: nextHeight }; + onResizeEnd(resizeSizeRef.current); + } else { + onResize?.({ width: tile.size.width, height: nextHeight }); + } }, [ onResize, + onResizeEnd, tile.outputLines, tile.streamText, tile.thinkingTrace, @@ -137,6 +297,10 @@ export const AgentTile = ({ tile.size.width, ]); + useEffect(() => { + return () => stopResize(); + }, [stopResize]); + const commitName = async () => { const next = normalizeAgentName(nameDraft); if (!next) { @@ -156,10 +320,10 @@ export const AgentTile = ({ const statusColor = tile.status === "running" - ? "bg-amber-200 text-amber-900" + ? "bg-emerald-200 text-emerald-900" : tile.status === "error" ? "bg-rose-200 text-rose-900" - : "bg-emerald-200 text-emerald-900"; + : "bg-amber-200 text-amber-900"; const showThinking = tile.status === "running" && Boolean(tile.thinkingTrace); const showTranscript = tile.outputLines.length > 0 || Boolean(tile.streamText) || showThinking; @@ -222,10 +386,143 @@ export const AgentTile = ({ } }, [projectId, tile.id, workspaceFiles]); + const handleWorkspaceTabChange = useCallback( + (nextTab: WorkspaceFileName) => { + if (nextTab === workspaceTab) return; + if (workspaceDirty && !workspaceSaving) { + void saveWorkspaceFiles(); + } + setWorkspaceTab(nextTab); + }, + [saveWorkspaceFiles, workspaceDirty, workspaceSaving, workspaceTab] + ); + + const loadHeartbeat = useCallback(async () => { + if (!projectId) return; + setHeartbeatLoading(true); + setHeartbeatError(null); + try { + const result = await fetchProjectTileHeartbeat(projectId, tile.id); + const every = result.heartbeat.every ?? "30m"; + const enabled = every !== "0m"; + const isPreset = HEARTBEAT_INTERVAL_OPTIONS.includes(every); + if (isPreset) { + setHeartbeatIntervalMode("preset"); + } else { + setHeartbeatIntervalMode("custom"); + const parsed = + every.endsWith("m") + ? Number.parseInt(every, 10) + : every.endsWith("h") + ? Number.parseInt(every, 10) * 60 + : Number.parseInt(every, 10); + if (Number.isFinite(parsed) && parsed > 0) { + setHeartbeatCustomMinutes(String(parsed)); + } + } + const target = result.heartbeat.target ?? "last"; + const targetMode = + target === "last" || target === "none" ? target : "custom"; + setHeartbeatOverride(result.hasOverride); + setHeartbeatEnabled(enabled); + setHeartbeatEvery(enabled ? every : "30m"); + setHeartbeatTargetMode(targetMode); + setHeartbeatTargetCustom(targetMode === "custom" ? target : ""); + setHeartbeatIncludeReasoning(Boolean(result.heartbeat.includeReasoning)); + if (result.heartbeat.activeHours) { + setHeartbeatActiveHoursEnabled(true); + setHeartbeatActiveStart(result.heartbeat.activeHours.start); + setHeartbeatActiveEnd(result.heartbeat.activeHours.end); + } else { + setHeartbeatActiveHoursEnabled(false); + } + if (typeof result.heartbeat.ackMaxChars === "number") { + setHeartbeatAckMaxChars(String(result.heartbeat.ackMaxChars)); + } else { + setHeartbeatAckMaxChars("300"); + } + setHeartbeatDirty(false); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to load heartbeat settings."; + setHeartbeatError(message); + } finally { + setHeartbeatLoading(false); + } + }, [projectId, tile.id]); + + const saveHeartbeat = useCallback(async () => { + if (!projectId) return; + setHeartbeatSaving(true); + setHeartbeatError(null); + try { + const target = + heartbeatTargetMode === "custom" + ? heartbeatTargetCustom.trim() + : heartbeatTargetMode; + let every = heartbeatEnabled ? heartbeatEvery.trim() : "0m"; + if (heartbeatEnabled && heartbeatIntervalMode === "custom") { + const customValue = Number.parseInt(heartbeatCustomMinutes, 10); + if (!Number.isFinite(customValue) || customValue <= 0) { + setHeartbeatError("Custom interval must be a positive number."); + setHeartbeatSaving(false); + return; + } + every = `${customValue}m`; + } + const ackParsed = Number.parseInt(heartbeatAckMaxChars, 10); + const ackMaxChars = Number.isFinite(ackParsed) ? ackParsed : 300; + const activeHours = + heartbeatActiveHoursEnabled && heartbeatActiveStart && heartbeatActiveEnd + ? { start: heartbeatActiveStart, end: heartbeatActiveEnd } + : null; + const result = await updateProjectTileHeartbeat(projectId, tile.id, { + override: heartbeatOverride, + heartbeat: { + every, + target: target || "last", + includeReasoning: heartbeatIncludeReasoning, + ackMaxChars, + activeHours, + }, + }); + setHeartbeatOverride(result.hasOverride); + setHeartbeatDirty(false); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to save heartbeat settings."; + setHeartbeatError(message); + } finally { + setHeartbeatSaving(false); + } + }, [ + projectId, + tile.id, + heartbeatActiveEnd, + heartbeatActiveHoursEnabled, + heartbeatActiveStart, + heartbeatAckMaxChars, + heartbeatCustomMinutes, + heartbeatEnabled, + heartbeatEvery, + heartbeatIntervalMode, + heartbeatIncludeReasoning, + heartbeatOverride, + heartbeatTargetCustom, + heartbeatTargetMode, + ]); + useEffect(() => { if (!settingsOpen) return; void loadWorkspaceFiles(); - }, [loadWorkspaceFiles, settingsOpen]); + void loadHeartbeat(); + }, [loadWorkspaceFiles, loadHeartbeat, settingsOpen]); + + useEffect(() => { + if (!WORKSPACE_FILE_NAMES.includes(workspaceTab)) { + setWorkspaceTab(WORKSPACE_FILE_NAMES[0]); + } + }, [workspaceTab]); const settingsModal = settingsOpen && typeof document !== "undefined" @@ -247,8 +544,36 @@ export const AgentTile = ({
Agent settings
-
- {tile.name} +
+ setNameDraft(event.target.value)} + onBlur={() => { + void commitName(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + if (event.key === "Escape") { + setNameDraft(tile.name); + event.currentTarget.blur(); + } + }} + /> +
-
@@ -324,7 +649,7 @@ export const AgentTile = ({ {workspaceLoading ? "Loading..." : workspaceDirty - ? "Unsaved changes" + ? "Saving on tab change" : "All changes saved"}
@@ -333,112 +658,283 @@ export const AgentTile = ({ {workspaceError}
) : null} -
-
- {WORKSPACE_FILE_NAMES.map((name) => { - const meta = WORKSPACE_FILE_META[name]; - const isOpen = workspaceExpanded[name]; - const file = workspaceFiles[name]; - return ( -
{ - workspaceItemRefs.current[name] = node; +
+ {WORKSPACE_FILE_NAMES.map((name) => { + const active = name === workspaceTab; + const label = WORKSPACE_FILE_META[name].title.replace(".md", ""); + return ( + + ); + })} +
+
+
+
+
+ {WORKSPACE_FILE_META[workspaceTab].title} +
+
+ {WORKSPACE_FILE_META[workspaceTab].hint} +
+
+ {!workspaceFiles[workspaceTab].exists ? ( + + new + + ) : null} +
+ +