Status indicator (StatusDot): drops the flat amber pulse for a richer set
of states — orbiting amber for streaming, spinning sky ring for tool_running,
static violet for waiting_for_input, plus the existing idle/error. Backend
chat_status frame widens from 'working|idle|error' to discriminate streaming
vs tool execution vs paused for user input.
Workspace pane sync: pane layout moves from per-device localStorage to
server-side sessions.workspace_panes jsonb. PATCH /api/sessions/:id/workspace
broadcasts session_workspace_updated on the user channel for cross-device live
sync. Echo dedup via JSON comparison so the round-trip frame doesn't loop.
Legacy localStorage seeds the server on first hydrate, then is deleted.
Deprecated session_panes table dropped.
Resilience: startup sweep marks any stale 'streaming' message older than
5 minutes as 'failed' so v1.12.0-style hung rows clear on container restart.
useWorkspacePanes gains validatePanes() to prune dead chatId references from
saved pane state when the chat list lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v1.9 settings pane had no way to dismiss once opened. ChatTabBar
(which owns the per-pane close X for chat panes) is skipped for
settings panes, and the pane header itself only rendered the maximize
toggle (desktop-only). Mobile users had zero controls beyond the
section tabs.
Add three close paths:
- X button in SettingsPane header, visible on mobile + desktop, sits
next to the maximize toggle. Tap-target sized per the v1.6 mobile
convention (max-md:min-h-[44px]).
- Esc when the settings pane is the active pane and no input/textarea/
dialog has focus. Maximize-restore still wins when maximized.
- Sidebar Settings button is now a strict toggle: opens on first click,
closes on second. Renamed openOrFocusSettingsPane →
toggleSettingsPane in the panes hook.
Edge case: removing the settings pane when it's the only pane left
falls back to an empty pane to preserve the "always one pane"
invariant. In normal flow this is unreachable (the toggle only
appends), but defensive against future entry points.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a singleton, ephemeral 'settings' pane kind to the workspace.
Opened via a new bottom-pinned button in ProjectSidebar (emits an
open_settings_pane event when a session is mounted; navigates to
/settings otherwise). Pane has three sections — Session, Project,
Theme — and a maximize toggle that hides sibling pane columns via
display:none on desktop only. Settings panes don't count toward
MAX_PANES and are filtered out of the localStorage persistence layer
so reload always restores a clean workspace.
Schema (additive):
- projects.default_system_prompt TEXT NOT NULL DEFAULT ''
- projects.default_web_search_enabled BOOLEAN NOT NULL DEFAULT false
- sessions.web_search_enabled BOOLEAN (nullable; null = inherit)
Inference resolves user_prompt = session.system_prompt.trim() ||
project.default_system_prompt.trim() — empty/whitespace at either
layer means "no override". Keeps the columns NOT NULL and matches
the existing inherit semantics.
Server routes:
- GET /api/projects/:id (new; settings pane refetches on
project_updated)
- PATCH /api/projects/:id accepts default_system_prompt,
default_web_search_enabled
- PATCH /api/sessions/:id accepts web_search_enabled (tri-state)
- POST /api/projects/:id/sessions/archive-all + GET
/api/projects/:id/sessions/open-count
- POST /api/sessions/:id/chats/archive-all + GET
/api/sessions/:id/chats/open-count
- PATCH /api/sessions/:id now broadcasts session_updated on every
successful PATCH (was rename-only). Lets SettingsPane open in
another tab pick up edits without a refetch.
Bulk-archive publishes one session_archived / chat_archived frame
per affected id so useSidebar's existing reducer cases handle them
incrementally — no new frame type, no payload widening.
ModelPicker refactored: shared ModelList inside a responsive shell.
Desktop = labeled trigger + DropdownMenu, mobile = icon-only Cpu
button + BottomSheet. Header in Session.tsx drops the pill wrap on
mobile since the new trigger is the visual.
ChatInput gains an icon-only '+' DropdownMenu next to AgentPicker
when sessionId + webSearchEnabled props are provided. One item for
now — Web search — with a checkmark reflecting the stored value
(true), not the effective one. Click PATCHes the override; to
restore inherit-from-project the user opens SettingsPane.
ThemePicker lifted out of pages/Settings.tsx into a reusable
component. The standalone /settings route is now a thin wrapper
that mounts <ThemePicker /> with a Back button on top
(navigate(-1) with fallback to '/'); the SettingsPane Theme tab
renders the same picker bare.
Project section delete-flow removed (button + confirm dialog +
handler). Replaced with "Archive all sessions" using the same
two-step count → confirm → fire pattern as "Archive all chats" in
the Session section. api.projects.remove() stays in the client
because useProjects.ts still uses it.
Hand-rolled Switch primitive in SettingsPane (no shadcn switch in
the project; spec said no new deps). Section nav is plain buttons
(no shadcn Tabs).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mobile header is now two rows. Row 1: hamburger | project · branch
indicator (live via GET /api/projects/:id/git, 30s poll) | ModelPicker |
FolderTree. Row 2: pane-switcher pill (hand-rolled BottomSheet) +
NewPaneMenu. Chat-within-pane navigation hidden on mobile; users switch
panes via the sheet. Cross-tab status sync via chat_status frames
published from inference.ts at working/idle/error transitions; StatusDot
component renders amber-pulse/green/red/gray on each pane row and on
desktop ChatTabBar tabs. Level 1 git awareness exposes a read-only
git_status tool to the model, backed by services/git_meta.ts (execFile
+ 2s timeout + 30s cache). Workspace.tsx now receives panes/chats hooks
as props (hoisted into Session.tsx) so the header pill shares state
with the pane grid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fork: POST /api/chats/:id/fork creates a new chat in the same session,
copies messages up to target (status=complete) with row-offset
clock_timestamp() for stable ordering. Client emits open_chat_in_active_pane
event; Workspace opens it in the active pane. No maybeAutoNameChat on forks.
- Delete: DELETE /api/chats/:id/messages/:message_id with 409 if the chat is
currently streaming. Cascading-forward delete (created_at >= target).
MessageBubble Trash button + confirm Dialog.
- Header: Projects -> Project -> Session breadcrumb, model badge pill,
inline session rename, active file path via new useActivePane() hook.
Server now publishes session_renamed on PATCH /api/sessions/:id;
client-side dup emit removed from Session.tsx.
- Housekeeping: NOW() -> clock_timestamp() in schema.sql defaults, dead
PaneTab.tsx and panes/PaneShell.tsx removed, session_panes backfill
INSERT removed (CREATE TABLE retained), Tailnet trust comment near
app.listen().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Feature 1 — Tab close menu (pure local pane state, no API):
- ChatTabBar context menu: Rename / sep / Close / Close others / Close to right / Close all
- Workspace bulk-tab primitives: closeOtherTabs, closeTabsToRight, closeAllTabs (manipulate panes[].chatIds, no fetch)
- Drop in-bar Delete; landing card's name-typed Delete is the canonical destructive path
Feature 2 — Chat archive + delete:
- chats.status vocabulary aligned with projects ('open' | 'archived'); DROP old inline CHECK, UPDATE 'closed' → 'archived', ADD new named chats_status_chk
- POST /api/chats/:id/archive (204) + POST /api/chats/:id/unarchive (200) + GET /api/sessions/:id/chats?status=archived; DELETE publishes chat_deleted; PATCH simplified to name-only
- 3 new WS frames: chat_archived, chat_unarchived, chat_deleted (renamed from chat_closed)
- Same dedup discipline: server-only publish, no local sessionEvents.emit in client
- SessionLandingPage: right-click ContextMenu (Open / Rename / Archive / sep / Delete-destructive), inline rename, archive confirm dialog, delete dialog with name-typed Input gated until typed text === chat.name, Archived chats collapsible section with Restore
- Card-level Archive + Delete icon buttons reusing the same dialog state setters; stopPropagation on both so card click still opens the chat; archived cards keep only Restore
UX — chat content width cap:
- ChatPane content (MessageList, queue chips, stop button, ChatInput) wrapped in inner max-w-[1000px] mx-auto w-full so messages center; outer border-t / scroll containers stay full-width so pane chrome and backgrounds remain edge-to-edge
- No new deps, no media queries (narrow viewports collapse to width naturally)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server:
- projects.status + projects.gitea_remote (additive) with CHECK ('open','archived')
- GET /api/projects?status=archived; PATCH /api/projects/:id (rename);
POST /api/projects/:id/archive | unarchive; POST /api/projects/create
- POST /api/projects ON CONFLICT (path) DO UPDATE SET status='open': re-add
of archived path restores existing row (preserves id + FKs); already-open
path returns 409. Detected-repos picker now excludes only status='open'.
- New gitea.ts (createGiteaRepo + GiteaRepoExistsError) and
project_bootstrap.ts (sanitize name, mkdir under PROJECT_ROOT_WHITELIST,
git init -b main + first commit with -c user.name/email per-command, optional
Gitea repo create + remote add + push; all via execFile, no shell).
- 3 new user-stream frames: project_archived, project_unarchived, project_updated.
- sidebar.ts now selects path + gitea_remote and filters status='open'.
- Gitea env added to config.ts (GITEA_BASE_URL, GITEA_USER, GITEA_TOKEN,
GITEA_SSH_HOST).
- docker-compose.yml /opt mount flipped to rw so create-project can mkdir.
- auto_name.ts gate relaxed from `!== 1` to `< 1` (fires on every turn while
chat name is empty, not only the first).
Web:
- ProjectSidebar: project rows use proper Radix ContextMenu; items Rename /
Archive / Open in Gitea. Inline rename, archive confirm dialog.
Removed obsolete handleRemove + DropdownMenu hack.
- Home: Add-existing + Create-new buttons; collapsible Archived Projects
section with Restore.
- New CreateProjectModal: name + live folder preview, commit msg, Private/
Public radio, create-Gitea-remote checkbox, toast on success/warnings.
- New projectUrls.ts giteaUrlFor() — uses gitea_remote when present,
falls back to convention URL.
- 3 new event types in sessionEvents.ts with idempotent useSidebar handlers.
- SidebarProject extended with path + gitea_remote so Open-in-Gitea can
resolve without a separate fetch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fastify global empty-JSON-body parser fixes archive/unarchive/stop 400s
- Removed redundant local sessionEvents.emit at all 5+2 sites with server-side WS publishers; added dedupe guards in useSidebar/Workspace/Project handlers
- Sidebar session right-click adds Delete (destructive) with confirm Dialog
- Session.tsx navigates away on session_deleted/session_archived for the active session
- SessionLandingPage chat rows show message_count, effective_context_tokens, last_message_preview via LATERAL joins on GET /api/sessions/:id/chats
- Workspace.tsx pane drag-to-reorder using native HTML5 events (no new deps)
- CompactCard: Copy toast, Send-to-chat with target chat name, empty-state in share popover, Re-run button
- auto_name.ts: filter count gate and assistant-fetch by content <> '' so tool-call assistant rows don't trip the once-and-only-once guard
- Adds CLAUDE.md and apps/web/src/lib/format.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Session 1:N Chat data model with backfill. Workspace switches to client-side
multi-tab pane management. Right-rail file browser with float-over viewer and
click-drag line selection replaces FileBrowserPane. Adds /compact streaming
summarizer (respects compact markers in context builder), force-send (cancels
in-flight, persists partial as 'cancelled', awaits cancellation completion via
deferred Promise + 5s timeout), message queue, stop generation, chat
auto-rename, session archive/unarchive with Closed Sessions section on repo
landing page. CHECK constraints on sessions.status, messages.role,
messages.status with KEEP IN SYNC comments tying to MESSAGE_ROLES /
MESSAGE_STATUSES const arrays. Deletes dead pane routes/hook and the
api.panes.* client block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- useUserEvents: double delay before scheduling, producing 1/2/4/8/16/30s
- useSidebar: activeSessionProjectId -> activeSession {session_id,project_id}
so consumers can verify URL match and ignore stale values
- api.panes.create/update: drop redundant Content-Type (request helper sets)
- useUserEvents: minimal type guard on incoming WS frame before emit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Mirror Pane/PaneState/UserStream types
- api.panes.* CRUD methods
- sessionEvents adds session_updated, session_loaded, open_file_in_browser
- useUserEvents hook: single app-level WS to /api/ws/user with reconnect
- useSidebar handles session_updated (in-place patch + re-sort) and
session_loaded (active-project highlight gap fix); open_file_in_browser
is a deliberate no-op here, consumed by Workspace later
- App.tsx mounts useUserEvents once
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schema (idempotent):
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp();
The column already exists from v1 (DEFAULT NOW()); ALTER is a no-op kept for
self-documentation. Explicit clock_timestamp() bumps now run wherever the
column actually matters — see services/inference.ts and routes/sessions.ts.
Backend updated_at maintenance:
- services/inference.ts: after each terminal status UPDATE on the assistant
message (failure / tool-call complete / clean complete), also bump
sessions.updated_at = clock_timestamp() so the parent session jumps to
the top of recency ordering on every assistant turn.
- routes/sessions.ts PATCH: NOW() → clock_timestamp() for consistency.
New endpoint GET /api/sidebar (routes/sidebar.ts):
{ projects: [{ id, name, recent_sessions[≤6], total_sessions }] }
One outer query for projects ordered added_at DESC; per-project Promise.all
over (recent_sessions LIMIT 6 ORDER BY updated_at DESC) and COUNT(*)::int.
Outer Promise.all parallelizes across projects. Two queries per project; the
composite idx_sessions_project(project_id, updated_at DESC) serves the inner
query. Auth via the global Remote-User hook. types/api.ts gains
SidebarSession / SidebarProject / SidebarResponse; index.ts wires the route.
Frontend foundations:
- api/types.ts mirrors the three sidebar interfaces.
- api/client.ts: api.sidebar.get() → Promise<SidebarResponse>.
- hooks/sessionEvents.ts: five-variant union — added project_created,
project_deleted, session_created, session_deleted. session_renamed
unchanged from Batch 1. Bus internals untouched (still a dumb
Set<Listener>, no validation).
New hooks/useSidebar.ts (module-singleton):
- Module-scope sharedData/sharedError/sharedLoading/initialized/fetchInFlight/
subscribers; a single sessionEvents.subscribe at module-top-level mutates
sharedData via an exhaustive switch over the five events. load() dedupes
parallel calls via fetchInFlight. Hook is a thin subscription layer: any
number of mount points share state and the very first one triggers the
single GET /api/sidebar. Subsequent mounts read cached state synchronously
(no skeleton flash). Public shape: { data, error, loading, retry }.
- Lift to module-scope was driven by the "ONE sidebar request on mount"
spec promise — both ProjectSidebar AND Home consume the hook now, and
they share the singleton.
Frontend UI:
- components/ProjectSidebar.tsx (rewrite, 234 lines): per-project chevron +
folder + name; chevron toggles expand, name navigates /project/:id.
Expanded → ≤5 sessions with MessageSquare + name + muted relTime()
timestamp. "View all (N)" link when total_sessions > 5, routing to
/project/:id. Active session row uses bg-sidebar-accent. Active project
always renders expanded (URL-derived: direct /project/:id or scan of
recent_sessions for /session/:id). Expanded ids persisted in
localStorage['boocode.sidebar.expanded'] with try/catch on both read and
write. Loading shows 4 muted-pulse skeleton blocks; empty + error +
retry button; error toast guarded by ref so it fires once per distinct
message and resets on recovery. Remove path calls api.projects.remove
directly + explicit project_deleted emit (replaced the prior
useProjects() dependency which fired a redundant /api/projects on
mount, violating the one-fetch promise).
- components/AddProjectModal.tsx: captures returned Project and emits
project_created before onAdded() / onOpenChange(false).
- pages/Project.tsx: emits session_created after create(); trash button is
now async with try/catch — emits session_deleted on success,
toast.error on failure.
- pages/Home.tsx: switched from useProjects to useSidebar so loading /
fires exactly one /api/sidebar, with no parallel /api/projects.
- pages/Session.tsx: manual inline rename now emits session_renamed on
the success path so the sidebar updates live without a refresh (also
fixes the regression made visible by Batch 2 — the sidebar caches
session names where the project page used to re-fetch on every visit).
useProjects.ts retains a project_deleted emit inside remove for any future
caller; no live consumer uses it (ProjectSidebar calls api.projects.remove
directly). Acknowledged dead code, to be removed in the next cleanup pass
along with three remaining NOW() → clock_timestamp() consistency flips at
routes/messages.ts:70, routes/messages.ts:127, and services/auto_name.ts:144.
Cross-tab parity for session_created/session_deleted/project_created/
project_deleted is deferred — those events are tab-local in Batch 2 per
spec. session_renamed continues to propagate cross-tab via the existing
WS frame from Batch 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>