From c56d169ef9d991b0d2c47510791be98ca01d5b00 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Mon, 1 Jun 2026 14:28:49 +0000 Subject: [PATCH] feat: shared PaneHeaderActions + chat-resolve WorkspaceState fix (v2.7.7) In-flight workspace UX work. - Extract a shared PaneHeaderActions cluster (+/Split/Reopen/History/Close) used by ChatTabBar + the Workspace coder/terminal pane headers, replacing the divergent per-header copies; SessionLandingPage history + useWorkspacePanes tweaks. - Fix coder-side correctness bug: resolveChatId read sessions.workspace_panes as a bare WorkspacePane[] but v2.6.5 widened it to a WorkspaceState envelope, so it mis-read panes and clobbered tabNumbers/nextTabNumber/closedPaneStack on every pane-chat write. New normalizeWorkspaceState handles either shape and preserves the envelope (+ regression test). - CLAUDE.md doc-sync (coder vitest suite, deploy-by-surface, dual-remote push, in-flight-web-WIP staging, release-branch naming). Web tsc + coder build + coder tests green. Builds on v2.7.6. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 + CLAUDE.md | 7 +- .../src/routes/__tests__/chat-resolve.test.ts | 110 +++++++++++++ apps/coder/src/routes/chat-resolve.ts | 38 ++++- apps/web/src/components/ChatTabBar.tsx | 102 ++---------- apps/web/src/components/PaneHeaderActions.tsx | 139 ++++++++++++++++ .../web/src/components/SessionLandingPage.tsx | 155 ++++++++++++++---- apps/web/src/components/Workspace.tsx | 119 ++++---------- apps/web/src/hooks/useWorkspacePanes.ts | 18 +- 9 files changed, 469 insertions(+), 223 deletions(-) create mode 100644 apps/coder/src/routes/__tests__/chat-resolve.test.ts create mode 100644 apps/web/src/components/PaneHeaderActions.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d7323..51891ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. +## v2.7.7-pane-header-actions — 2026-06-01 + +In-flight workspace UX work, committed alongside the v2.7 review batches. Extracts a shared `PaneHeaderActions` cluster (the +/Split/Reopen-closed-pane/Session-history/Close controls) used across the `ChatTabBar` and the desktop coder + terminal pane headers in `Workspace`, replacing the divergent per-header copies, with `SessionLandingPage` history enhancements and `useWorkspacePanes` tweaks. Also fixes a coder-side correctness bug: `resolveChatId` (`apps/coder/src/routes/chat-resolve.ts`) still read `sessions.workspace_panes` as a bare `WorkspacePane[]`, but `v2.6.5-panes-tabs-composer` widened it to a `WorkspaceState` envelope — so it mis-read the panes and, worse, clobbered `tabNumbers`/`nextTabNumber`/`closedPaneStack` back to a bare array on every pane-chat write; a new `normalizeWorkspaceState` accepts either shape and preserves the envelope (with a regression test). Plus a CLAUDE.md doc-sync (apps/coder vitest suite, deploy-by-surface, dual-remote push, in-flight-web-WIP staging, release-branch naming). Web tsc + coder build + coder tests green. Builds on `v2.7.6-agent-status-normalize`. + ## v2.7.6-agent-status-normalize — 2026-06-01 The scoped half of `boocode_code_review_v2.md` §1 #10 — normalized external-agent status, surfaced from BooCoder's own dispatch observation (the heavier config-injection notify-hook, clean-room from superset's ELv2 `agent-setup`, is documented as the follow-on). The review's premise ("PTY agents have no status") had partly aged out — warm-ACP/opencode/SDK already carry working/done — so the real gap was that BooCoder never *published* a normalized per-`(chat,agent)` status (blocked-on-permission was invisible; crash/idle weren't pushed). Adds an `agent_status_updated` WS frame (`working|blocked|idle|error`, server+web parity) published from the dispatcher's turn boundaries across all four external paths (warm-acp/opencode/sdk/pty — `working` at start, `idle`/`error` at end) and the permission flow (`blocked` on request, `working` on resolve), best-effort so it never breaks a turn. A clean-room `normalizeAgentEvent` helper (superset's ~30-vendor-event → Start/blocked/Stop collapse, reimplemented with the event names as facts) ships now with 25 tests so the deferred notify-hook injection reuses it verbatim. The `AgentComposerBar` gains a normalized status dot (working=spinner, blocked=amber, idle=gray, error=red) distinct from the WS-liveness dot, fed by a `useAgentStatus` map `CoderPane` tracks per `(chat,agent)`. Built by two parallel agents (data plane + view plane) against a pinned frame contract; server 545 + coder 294 tests passing (25 new), web tsc + builds clean, ws-frames parity green. Clears the actionable review backlog (#1/#3/#4/#6–#12). Builds on `v2.7.5-claude-sdk-sessionstore`; openspec `agent-status-normalize`. diff --git a/CLAUDE.md b/CLAUDE.md index 306545c..19f1d22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ npx tsc -p apps/web/tsconfig.app.json --noEmit # web app specifically docker compose build --no-cache boocode && docker compose up -d ``` -Tests: `pnpm -C apps/server test` runs the vitest suite. No test harness on `apps/web` (adding it requires installing vitest as a new devDep). Vitest pinned to `^3` because Vite 5 / vitest 4 are incompatible. No linters configured. Vitest include glob is `src/**/__tests__/**/*.test.ts` (see `apps/server/vitest.config.ts`) — tests outside `src/**/__tests__/` silently won't run; match the per-domain convention (`apps/server/src/services/__tests__/foo.test.ts`). +Tests: `pnpm -C apps/server test` runs the vitest suite. No test harness on `apps/web` (adding it requires installing vitest as a new devDep). Vitest pinned to `^3` because Vite 5 / vitest 4 are incompatible. No linters configured. Vitest include glob is `src/**/__tests__/**/*.test.ts` (see `apps/server/vitest.config.ts`) — tests outside `src/**/__tests__/` silently won't run; match the per-domain convention (`apps/server/src/services/__tests__/foo.test.ts`). `apps/coder` has its own vitest suite too — `pnpm -C apps/coder test` (same `src/**/__tests__/**/*.test.ts` glob; `globals:false`, so import `describe`/`it`/`expect` from `vitest`). Extract pure helpers to unit-test (`backends/turn-guard.ts`, `lifecycle-decisions.ts` are the pattern). ## Architecture @@ -81,6 +81,7 @@ Route registration: all routes registered in `index.ts` via `register*Routes(app - **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. apps/server must build FIRST. - Build + deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`. - After `pnpm -C apps/coder build` the host `boocoder.service` keeps running the OLD process until `sudo systemctl restart boocoder` — a stale process shows **new routes 404 with `{error:'not found'}` while old routes still 200** (the `/api` not-found handler returns that shape). Restart, don't re-debug. +- **Deploy by surface:** an `apps/coder` change → `sudo systemctl restart boocoder`; an `apps/web` or `apps/server` change → `docker compose up --build -d boocode` (rebuilds web+server from the working tree). `:9502/api/health` is down ~15–20s after a boocoder restart while the startup agent-probe scan runs — retry; an early connection-refused is not a failed deploy. - Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Follows Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`. - systemd hardening: only `NoNewPrivileges=true` is safe. `ProtectSystem`, `ProtectHome`, `PrivateTmp` all break agent dispatch (agents need full filesystem access to read configs, write to worktrees). - `apps/server/tsconfig.json` has `declaration: true` so `.d.ts` files exist for workspace consumers. @@ -148,12 +149,14 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo ## Workflow - Sam reviews all diffs and commits manually. Do not commit unless explicitly asked. +- Sam often has uncommitted `apps/web` work in flight mid-session — stage your own commits **explicitly by path** (never `git add -A`); and `docker compose up --build -d boocode` builds the working tree, so a container rebuild also ships his uncommitted web changes. +- Cutting a release: name the feature branch DIFFERENTLY from the tag (branch `f1-interrupt-guard`, tag `v2.6.7-interrupt-guard`) — identical branch+tag names trigger `warning: refname ... is ambiguous`. - Per-batch docs live under `openspec/changes//{proposal,tasks,design}.md`. Already-shipped batches are snapshots in `openspec/changes/archived/`. New batches follow the proposal+tasks shape; see `openspec/README.md` for the convention. - Tag naming: `vMAJOR.MINOR.PATCH-slug` (e.g. `v1.13.13-ws-publish`). Monotonic per minor — the slug describes the batch's content so the tag name alone is enough to recall what shipped. No letter suffixes (`-a`/`-b`), no pseudo-ranges (`v1.11.x`), no slug-only sub-versions sharing a number (`v1.13.15-tools` + `-openspec` + `-agentlint` — split into sequential patches instead). - `CHANGELOG.md` is the per-tag release log, most-recent on top. When a new tag is created, add a `## ` section with a 3–6 sentence paragraph summarizing what shipped, drawn from the commit body. Cross-reference other tags by name when the batch builds on, fixes, or pairs with prior work (e.g. "pairs with `v1.13.12-ws-schemas`", "fixed in `v1.13.5-stability-bundle`"). No nested bullets — one paragraph. - Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue). - The `boocode` container is `build: .` — it builds web+server from the **working tree**, so uncommitted changes deploy. Web edits are live on the Vite dev server (HMR) but NOT on production (`:9500` / code.indifferentketchup.com) until `docker compose up --build -d boocode`. -- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin `. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`. +- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin `. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`. Keep both remotes synced: push `main` + the release tag to `origin` (Gitea, deploy key above) AND `backup` (`git@github.com:indifferentketchup/boocode.git`, default key). - Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge. - DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. `psql` is not on the host PATH — for an interactive query use `docker exec boocode_db psql -U boocode -d boochat -c "..."`. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference. - Host-side smoke endpoint: `curl http://100.114.205.53:9500/api/...`. The boocode container's port mapping binds to the Tailscale IP, not `0.0.0.0`, so `localhost:9500` doesn't work from the host shell. Same for booterm at `:9501`. diff --git a/apps/coder/src/routes/__tests__/chat-resolve.test.ts b/apps/coder/src/routes/__tests__/chat-resolve.test.ts new file mode 100644 index 0000000..2d034c6 --- /dev/null +++ b/apps/coder/src/routes/__tests__/chat-resolve.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { resolveChatId } from '../chat-resolve.js'; +import type { Sql } from '../../db.js'; + +// Mock the porsager/postgres surface that chat-resolve.ts uses: a tagged-template +// `tx` (dispatched by query substring), `tx.json`, and `sql.begin(fn)` which just +// runs fn(tx). Captures the value written back to workspace_panes so we can assert +// the WorkspaceState envelope survives the UPDATE. +interface MockState { + stored: unknown; // initial sessions.workspace_panes value + existingChatOpen: boolean; // whether `SELECT id FROM chats ...` finds the active chat + newChatId: string; + written?: unknown; // captured tx.json(...) payload from `UPDATE sessions` + inserted: boolean; // whether INSERT INTO chats ran +} + +interface MockTx { + (strings: TemplateStringsArray): Promise; + json: (v: unknown) => unknown; +} + +function mockSql(state: MockState): Sql { + const tx = ((strings: TemplateStringsArray) => { + const q = strings.join(''); + if (q.includes('SELECT workspace_panes FROM sessions')) { + return Promise.resolve([{ workspace_panes: state.stored }]); + } + if (q.includes('FROM chats')) { + return Promise.resolve(state.existingChatOpen ? [{ id: 'placeholder' }] : []); + } + if (q.includes('INSERT INTO chats')) { + state.inserted = true; + return Promise.resolve([{ id: state.newChatId }]); + } + if (q.includes('UPDATE sessions')) { + return Promise.resolve([]); + } + return Promise.resolve([]); + }) as unknown as MockTx; + tx.json = (v: unknown) => { + state.written = v; + return v; + }; + const sql = { + begin: (fn: (t: Sql) => Promise) => fn(tx as unknown as Sql), + }; + return sql as unknown as Sql; +} + +const ENVELOPE = () => ({ + panes: [{ id: 'pane-1', kind: 'coder', chatIds: [] as string[], activeChatIdx: 0 }], + tabNumbers: { 'chat-x': 3 }, + nextTabNumber: 7, + closedPaneStack: [{ kind: 'coder', chatIds: ['old'], activeChatIdx: 0 }], +}); + +describe('resolveChatId — v2.6.5 WorkspaceState envelope', () => { + it('reads panes from the envelope without crashing (regression: panes.findIndex is not a function)', async () => { + const state: MockState = { + stored: ENVELOPE(), + existingChatOpen: false, + newChatId: 'new-chat-1', + inserted: false, + }; + const chatId = await resolveChatId(mockSql(state), 'session-1', 'pane-1'); + expect(chatId).toBe('new-chat-1'); + expect(state.inserted).toBe(true); + }); + + it('preserves the envelope (tabNumbers/nextTabNumber/closedPaneStack) on write-back', async () => { + const state: MockState = { + stored: ENVELOPE(), + existingChatOpen: false, + newChatId: 'new-chat-1', + inserted: false, + }; + await resolveChatId(mockSql(state), 'session-1', 'pane-1'); + const w = state.written as Record; + expect(Array.isArray(w.panes)).toBe(true); // envelope, not a bare array + expect(w.tabNumbers).toEqual({ 'chat-x': 3 }); + expect(w.nextTabNumber).toBe(7); + expect(w.closedPaneStack).toEqual([{ kind: 'coder', chatIds: ['old'], activeChatIdx: 0 }]); + }); + + it('returns the existing open chat when the pane already has one', async () => { + const env = ENVELOPE(); + env.panes[0]!.chatIds = ['existing-1']; + const state: MockState = { + stored: env, + existingChatOpen: true, + newChatId: 'should-not-be-used', + inserted: false, + }; + const chatId = await resolveChatId(mockSql(state), 'session-1', 'pane-1'); + expect(chatId).toBe('existing-1'); + expect(state.inserted).toBe(false); + }); + + it('still accepts a legacy bare WorkspacePane[] array', async () => { + const state: MockState = { + stored: [{ id: 'pane-1', kind: 'coder', chatId: 'legacy-1', chatIds: ['legacy-1'], activeChatIdx: 0 }], + existingChatOpen: true, + newChatId: 'should-not-be-used', + inserted: false, + }; + const chatId = await resolveChatId(mockSql(state), 'session-1', 'pane-1'); + expect(chatId).toBe('legacy-1'); + expect(state.inserted).toBe(false); + }); +}); diff --git a/apps/coder/src/routes/chat-resolve.ts b/apps/coder/src/routes/chat-resolve.ts index 075c27d..638a389 100644 --- a/apps/coder/src/routes/chat-resolve.ts +++ b/apps/coder/src/routes/chat-resolve.ts @@ -8,6 +8,36 @@ interface WorkspacePaneRow { activeChatIdx?: number; } +// v2.6.5: sessions.workspace_panes widened from a bare WorkspacePane[] to a +// WorkspaceState envelope { panes, tabNumbers, nextTabNumber, closedPaneStack }. +// (See the union validator in apps/server routes/sessions.ts + normalizeWorkspaceState +// in apps/server read_tab_by_number.ts — this is the coder-side mirror.) +interface WorkspaceStateRow { + panes: WorkspacePaneRow[]; + tabNumbers: Record; + nextTabNumber: number; + closedPaneStack: unknown[]; +} + +// MIGRATION: the stored value may be the legacy bare array OR the envelope. +// Normalize to a full envelope so callers always read `.panes` as an array and +// write the envelope back intact (preserving tabNumbers/nextTabNumber/closedPaneStack). +export function normalizeWorkspaceState(v: unknown): WorkspaceStateRow { + if (Array.isArray(v)) { + return { panes: v as WorkspacePaneRow[], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] }; + } + if (v && typeof v === 'object' && Array.isArray((v as { panes?: unknown }).panes)) { + const env = v as Partial; + return { + panes: env.panes ?? [], + tabNumbers: env.tabNumbers ?? {}, + nextTabNumber: env.nextTabNumber ?? 1, + closedPaneStack: env.closedPaneStack ?? [], + }; + } + return { panes: [], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] }; +} + function chatNameForKind(kind: string): string { if (kind === 'coder' || kind === 'agent') return 'BooCoder'; if (kind === 'terminal') return 'Terminal'; @@ -28,12 +58,13 @@ export async function resolveChatId( paneId: string, ): Promise { return sql.begin(async (tx) => { - const sessionRows = await tx<{ workspace_panes: WorkspacePaneRow[] }[]>` + const sessionRows = await tx<{ workspace_panes: unknown }[]>` SELECT workspace_panes FROM sessions WHERE id = ${sessionId} FOR UPDATE `; if (sessionRows.length === 0) return null; - const panes = sessionRows[0]!.workspace_panes ?? []; + const state = normalizeWorkspaceState(sessionRows[0]!.workspace_panes); + const panes = state.panes; const paneIdx = panes.findIndex((p) => p.id === paneId); if (paneIdx < 0) return null; @@ -69,9 +100,10 @@ export async function resolveChatId( : p, ); + const nextState: WorkspaceStateRow = { ...state, panes: nextPanes }; await tx` UPDATE sessions - SET workspace_panes = ${tx.json(nextPanes as never)}, + SET workspace_panes = ${tx.json(nextState as never)}, updated_at = clock_timestamp() WHERE id = ${sessionId} `; diff --git a/apps/web/src/components/ChatTabBar.tsx b/apps/web/src/components/ChatTabBar.tsx index aa7aeb7..57a1370 100644 --- a/apps/web/src/components/ChatTabBar.tsx +++ b/apps/web/src/components/ChatTabBar.tsx @@ -1,7 +1,8 @@ import { useState } from 'react'; -import { Code, Columns2, History, MessageSquare, Plus, RotateCcw, Terminal, X } from 'lucide-react'; +import { History, MessageSquare, X } from 'lucide-react'; import type { Chat, WorkspacePane } from '@/api/types'; import { StatusDot } from '@/components/StatusDot'; +import { PaneHeaderActions } from '@/components/PaneHeaderActions'; import { ContextMenu, ContextMenuContent, @@ -9,12 +10,6 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from '@/components/ui/context-menu'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; import { useLongPress } from '@/hooks/useLongPress'; import { sessionEvents } from '@/hooks/sessionEvents'; import { cn } from '@/lib/utils'; @@ -191,90 +186,15 @@ export function ChatTabBar({ )} -
- - - - - - {/* New BooChat opens a tab in THIS pane; terminal/coder can't be - tabs, so they split into a new pane (matches the Split menu). */} - - New BooChat - - onSplitPane('terminal')}> - New BooTerm - - onSplitPane('coder')}> - New BooCode - - - - - - - - - onSplitPane('chat')}> - New BooChat - - onSplitPane('terminal')}> - New BooTerm - - onSplitPane('coder')}> - New BooCode - - - - {onReopenPane && ( - - )} - - {onRemovePane && ( - - )} -
+ ); } diff --git a/apps/web/src/components/PaneHeaderActions.tsx b/apps/web/src/components/PaneHeaderActions.tsx new file mode 100644 index 0000000..ef57c6b --- /dev/null +++ b/apps/web/src/components/PaneHeaderActions.tsx @@ -0,0 +1,139 @@ +import { Code, Columns2, History, MessageSquare, Plus, RotateCcw, Terminal, X } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { cn } from '@/lib/utils'; + +// Shared pane-header action cluster: + (new) / Split / Reopen-closed-pane / +// Session history / Close. Rendered in the chat tab bar (ChatTabBar) and the +// desktop coder + terminal pane headers (Workspace) so all pane kinds share one +// control set. Extracted to avoid a divergent copy per header. +interface Props { + // When provided (chat panes), the "+" menu's New BooChat opens an in-pane + // tab. When omitted (coder/terminal panes, which can't host tabs), New BooChat + // splits into a new pane instead. + onNewTab?: () => void; + onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void; + onReopenPane?: () => void; + onShowHistory: () => void; + onRemovePane?: () => void; + // Highlights the History button when the pane is showing the landing page. + historyActive?: boolean; + // Positioning/spacing supplied by the parent (e.g. "ml-auto px-1"). + className?: string; +} + +const BTN = + 'inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]'; + +export function PaneHeaderActions({ + onNewTab, + onSplitPane, + onReopenPane, + onShowHistory, + onRemovePane, + historyActive, + className, +}: Props) { + return ( +
+ + + + + + {/* Chat panes: New BooChat opens a tab in THIS pane. Coder/terminal + panes can't host tabs, so it splits into a new pane. */} + onSplitPane('chat'))}> + New BooChat + + onSplitPane('terminal')}> + New BooTerm + + onSplitPane('coder')}> + New BooCode + + + + + + + + + + onSplitPane('chat')}> + New BooChat + + onSplitPane('terminal')}> + New BooTerm + + onSplitPane('coder')}> + New BooCode + + + + + {onReopenPane && ( + + )} + + + + {onRemovePane && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/SessionLandingPage.tsx b/apps/web/src/components/SessionLandingPage.tsx index 5a9a8a2..c6900fb 100644 --- a/apps/web/src/components/SessionLandingPage.tsx +++ b/apps/web/src/components/SessionLandingPage.tsx @@ -1,7 +1,15 @@ import { useCallback, useEffect, useState } from 'react'; -import { Archive, MessageSquare, RotateCcw } from 'lucide-react'; +import { Archive, Code, MessageSquare, RotateCcw, Terminal, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { ChatInput } from '@/components/ChatInput'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { api } from '@/api/client'; import type { Chat } from '@/api/types'; @@ -22,6 +30,8 @@ interface Props { chats: Chat[]; onOpenChat: (chatId: string) => void; onUnarchiveChat: (chatId: string) => Promise; + onArchiveChat: (chatId: string) => Promise; + onDeleteChat: (chatId: string) => Promise; } function formatRelative(iso: string): string { @@ -42,6 +52,16 @@ function byRecent(a: Chat, b: Chat): number { return (b.updated_at ?? '').localeCompare(a.updated_at ?? ''); } +// Pick the row icon by the chat's seed name: coder and terminal panes create +// placeholder chats named 'BooCoder' / 'Terminal' (see useWorkspacePanes +// chatNameForPaneKind + the coder chat-resolve). A name heuristic keeps this +// frontend-only — matches ProjectSidebar's isCoderSessionName approach. +function iconForChat(name: string | null) { + if (name === 'BooCoder') return Code; + if (name === 'Terminal') return Terminal; + return MessageSquare; +} + export function SessionLandingPage({ projectId, sessionId, @@ -53,9 +73,13 @@ export function SessionLandingPage({ chats, onOpenChat, onUnarchiveChat, + onArchiveChat, + onDeleteChat, }: Props) { const [chatId, setChatId] = useState(null); const [archived, setArchived] = useState([]); + // Plain Cancel/Confirm delete (no type-to-confirm), mirroring ProjectSidebar. + const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string | null } | null>(null); // Archived chats aren't in the default (open-only) list, so fetch them. One // shot on session change — the history view is transient (pick a chat and @@ -130,25 +154,52 @@ export function SessionLandingPage({ Conversations
- {openChats.map((c) => ( - - ))} + {openChats.map((c) => { + const Icon = iconForChat(c.name); + return ( +
+ +
+ + +
+
+ ); + })}
)} @@ -159,21 +210,34 @@ export function SessionLandingPage({
{archivedChats.map((c) => ( - + + +
))} @@ -195,6 +259,31 @@ export function SessionLandingPage({ messages={[]} modelContextLimit={null} /> + { if (!open) setDeleteConfirm(null); }} + > + + + Delete chat? + + Permanently deletes "{deleteConfirm?.name ?? 'New chat'}" and all its messages. This cannot be undone. + + +
+ + +
+
+
); } diff --git a/apps/web/src/components/Workspace.tsx b/apps/web/src/components/Workspace.tsx index 667ad4e..9b1c3ee 100644 --- a/apps/web/src/components/Workspace.tsx +++ b/apps/web/src/components/Workspace.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { MessageSquare, Terminal, Code, Clipboard, Plus, X } from 'lucide-react'; +import { Terminal, Code, Clipboard } from 'lucide-react'; import { api } from '@/api/client'; import type { Chat, Project, Session, WorkspacePane } from '@/api/types'; import { MAX_PANES, activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes'; @@ -13,13 +13,8 @@ import { CoderPane } from '@/components/panes/CoderPane'; import { MarkdownArtifactPane } from '@/components/MarkdownArtifactPane'; import { HtmlArtifactPane } from '@/components/HtmlArtifactPane'; import { ChatTabBar } from '@/components/ChatTabBar'; +import { PaneHeaderActions } from '@/components/PaneHeaderActions'; import { SessionLandingPage } from '@/components/SessionLandingPage'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; interface Props { @@ -223,41 +218,13 @@ export function Workspace({
BooCode -
- - - - - - onAddPane('chat')}> - New BooChat - - onAddPane('terminal')}> - New BooTerm - - onAddPane('coder')}> - New BooCode - - - - {panes.length > 1 && ( - - )} -
+ showLandingPage(idx)} + onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined} + />
)} {isTerminal && ( @@ -266,61 +233,31 @@ export function Workspace({ {terminalLabels.get(pane.id) ?? 'Terminal'} - - - - - - onAddPane('chat')}> - New BooChat - - onAddPane('terminal')}> - New BooTerm - - onAddPane('coder')}> - New BooCode - - - - {/* v1.10.4: iOS Safari restricts navigator.clipboard.readText - outside direct user gestures. A real button click IS a - gesture, so this works where keystroke-driven paste may - not on iOS. The action lives in TerminalPane behind the - registry's paste() callback. */} - - {panes.length > 1 && ( +
+ {/* v1.10.4: iOS Safari restricts navigator.clipboard.readText + outside direct user gestures. A real button click IS a + gesture, so this works where keystroke-driven paste may + not on iOS. The action lives in TerminalPane behind the + registry's paste() callback. */} - )} + showLandingPage(idx)} + onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined} + /> +
)} @@ -395,6 +332,8 @@ export function Workspace({ chats={chats} onOpenChat={(chatId) => openChatInPane(idx, chatId)} onUnarchiveChat={unarchiveChat} + onArchiveChat={archiveChat} + onDeleteChat={deleteChat} /> )} diff --git a/apps/web/src/hooks/useWorkspacePanes.ts b/apps/web/src/hooks/useWorkspacePanes.ts index c21b78c..3885b22 100644 --- a/apps/web/src/hooks/useWorkspacePanes.ts +++ b/apps/web/src/hooks/useWorkspacePanes.ts @@ -640,13 +640,23 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { const showLandingPage = useCallback((paneIdx: number) => { setPanes((prev) => { const pane = prev[paneIdx]; - // Coder/terminal panes are not chat hosts — history button is chat-only. - if (!pane || pane.kind === 'coder' || pane.kind === 'terminal') return prev; + if (!pane) return prev; const next = [...prev]; - next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined }; + if (pane.kind === 'coder' || pane.kind === 'terminal') { + // Scoped panes don't host chat tabs. Leaving one for the session + // history closes it: drop the pane→chat binding, and for terminals + // kill the tmux session (terminals are ephemeral — closing = killing, + // mirroring removePane). + if (pane.kind === 'terminal') { + api.terminals.kill(sessionId, pane.id).catch(() => { /* non-fatal */ }); + } + next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 }; + } else { + next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined }; + } return next; }); - }, []); + }, [sessionId]); const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'coder'): string | null => { // Generate the id outside the updater so we can return it deterministically.