- usePullToRefresh: hand-rolled hook. Records startY only when the
scroll container is at scrollTop=0 to avoid hijacking mid-scroll
pulls. Tracks downward delta on touchmove; fires onRefresh on
touchend if delta >= 80px threshold. Holds the refreshing state for
600ms minimum so the action feels intentional.
- ProjectSidebar: wires usePullToRefresh(() => retry()) on the nav
element, mobile-only. A status indicator above the nav grows with
pullDist (max 80px) and cycles 'Pull to refresh' -> 'Release to
refresh' -> 'Refreshing...'. retry() is from useSidebar and refetches
GET /api/sidebar.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ChatInput: e.nativeEvent.isComposing early-return added (CJK IME
safety — first Enter of a composition no longer submits). Bare-Enter
send path gated by !isMobile so mobile inserts a newline; send is
button-only. Cmd/Ctrl+Enter and Shift+Cmd/Ctrl+Enter retained as
desktop secondary bindings. Placeholder is now viewport-aware. Outer
wrapper gets paddingBottom: env(safe-area-inset-bottom) so iOS home
indicator doesn't overlap.
- MessageBubble: ActionRow buttons (Copy / Regenerate / Fork / Trash)
bumped to max-md min-h/min-w 44px; opacity-100 on mobile so actions
don't hide behind a hover-to-reveal pattern. User bubble and
assistant content wrapper gain break-words + min-w-0 so long
unbreakable strings (URLs / paths) wrap rather than blowing out
the column on narrow viewports.
- ChatPane: queued-message dropdown + close X + Stop-generating button
hit max-md 44px sizing.
- ChatTabBar: per-tab X, +/History/Close-pane action buttons hit
max-md 44px. Tab close X is force-visible on mobile (no
hover-to-reveal).
- M8: CodeBlock / Markdown tables / ToolCallCard already wrap
overflow-x-auto pre-existing — no source change needed there; the
break-words + min-w-0 additions above are the new defensive layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Workspace: on mobile renders only panes[activePaneIdx] (rest skipped).
When panes.length > 1, adds a horizontal pane-navigator strip built
from SwipeablePaneTab above the active pane. URL state ?pane=<paneId>
written by switchActivePane (user-initiated only) and read on URL
change (back-button + deep-link). Bare URL resets activePaneIdx to 0.
- useLongPress: 500ms touchstart timer; on fire, dispatches a synthetic
contextmenu event on target.closest('[data-tab-id]') so the existing
Radix ContextMenuTrigger opens at the touch coordinates. Suppresses
the synthetic click that follows touchend. Cancels on touchmove /
touchend / touchcancel.
- ChatTabBar: each tab gets data-tab-id, touch handlers wired to
useLongPress, and WebkitTouchCallout: 'none' to disable iOS Safari's
text-selection callout.
- SwipeablePaneTab: tracks horizontal drag; bails if vertical delta
exceeds 30px (so vertical scroll still works); past 60px on release
fires onClose (removePane), else snaps back. Opacity fades 1->0.4
approaching the threshold. Hand-rolled per spec.
- Pane drag-and-drop disabled on mobile (HTML5 drag is broken on touch
anyway; mobile uses the navigator + swipe-to-close instead).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- useViewport: matchMedia-based hook (no resize polling). Breakpoints
mobile <768 / tablet 768-1023 / desktop >=1024. SSR-safe.
- useSidebarDrawer: Context provider with open/setOpen/toggle + auto-close
on useLocation().pathname change.
- App.tsx: wraps SidebarDrawerProvider around AppShell, renders a
MobileBackdrop (z-30) when the drawer is open on mobile.
- ProjectSidebar: aside is fixed/translate-x-full off-screen on mobile,
slides in (z-40, 200ms transform) when drawerOpen. Inline column on
desktop, unchanged.
- Session.tsx + Project.tsx: hamburger (Menu icon, >=44x44 min) on mobile
opens the drawer. Headers gain paddingTop: max(0.75rem,
env(safe-area-inset-top)) for notch devices. Home.tsx left alone
(sidebar content duplicates the home page).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The scope check at routes/projects.ts:56 short-circuited when
real === whitelistReal, allowing the whitelist directory itself to
resolve as a valid project root. Dropped the `real !== whitelistReal`
half of the && so the predicate becomes the strict prefix check.
Flipped the unit test from a "BEHAVIOR GAP" assertion (documenting
the bug) to a strict-rejection assertion. 23/23 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Dockerfile: install git + openssh-client in runtime image; pre-populate
/root/.ssh/known_hosts with the Tailscale ssh-keyscan for
100.114.205.53:2222 (Gitea SSH). Without these, the bootstrap push
step from inside the container fails with "command not found" or
host-key prompts.
- docker-compose.yml: mount ./secrets/boocode_gitea as
/root/.ssh/id_ed25519:ro so the container can authenticate to Gitea
over SSH for the initial push.
- .gitignore: add secrets/ so the keypair never lands in the repo.
- project_bootstrap.ts: rewrite the Gitea-returned ssh_url's hostname
from git.indifferentketchup.com to 100.114.205.53 before adding it
as origin, so the push hits the Tailscale interface that the
known_hosts entry covers.
- CreateProjectModal.tsx: preview label now reads "Folder:
/opt/projects/<name>" to match the new BOOTSTRAP_ROOT (was /opt/).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a floating popover above the chat input showing current
context-window usage. Modeled on Paseo's tracker.
- New hook useChatContextStats(chatId, messages) finds the latest
assistant message in the chat with both ctx_used and ctx_max set,
computes percent, and returns null when data unavailable.
- New component ChatContextPopover renders a small card with the
"Context window" label, big percent, and "used / max tokens"
subline. Hidden when stats is null.
- Color thresholds: <60% muted, 60-85 amber, >85 destructive.
- Not a portal — absolutely positioned inside a new relative
wrapper around ChatInput in ChatPane.tsx, so it's pane-local
(multi-pane safe).
- Live updates via the existing messages-array dependency.
- No API / schema / WS changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splits the previous /opt:/opt:rw bind into two mounts to narrow the
writable scope of the container:
- /opt:/opt:ro — read-only mount for legacy/existing project
add-existing flow. resolveProjectPath still uses
PROJECT_ROOT_WHITELIST (/opt by default) so existing projects under
/opt/<name> (analytics, boolab, boocode itself) continue to resolve
and serve their file-tree via the read-only tools.
- /opt/projects:/opt/projects:rw — writable mount targeted at the
create-new-project bootstrap path.
Picked Option B from the spec (simpler than two scan roots):
PROJECT_ROOT_WHITELIST stays /opt, new BOOTSTRAP_ROOT env var defaults
to /opt/projects and is used by project_bootstrap.ts as the mkdir
target. Bootstrap path-escape check now compares against
BOOTSTRAP_ROOT.
Prereq: host must `mkdir -p /opt/projects` before next container
restart. Documented in CLAUDE.md and .env.example.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swallowed-error logging (audit Feature 3):
- file_index.ts:36-37 (git mtime probes): comment — best-effort, project
may not be a git repo.
- useUserEvents.ts:44 / 53 (ws.close on error / unmount): comments —
best-effort, socket may already be closing.
- RightRail.tsx:38 (localStorage write): comment — best-effort, quota or
private mode.
- App.tsx:21 (api.sessions.get for RightRail projectId): replaced silent
catch with console.warn.
- Session.tsx:38, 41 (session fetch + project list for breadcrumb):
replaced silent catches with console.warn.
H1: ProjectSidebar.tsx:189 — dropped the local sessionEvents.emit
({type:'session_renamed'}) after PATCH. Server publishes via
broker.publishUser since v1.4; useUserEvents forwards.
H2: useSessionStream.ts session_renamed case removed (dead — no
server code path publishes session_renamed on the per-session WS
channel; only user channel via broker.publishUser). Also dropped the
session_renamed variant from WsFrame (in apps/web/src/api/types.ts)
to keep the discriminated-union switch exhaustive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds vitest 3.x (pinned to ^3 because vitest 4 requires Vite 6, while the
web app pins Vite 5). Tests live under src/**/__tests__/**.
Three target functions:
- sanitizeFolderName (project_bootstrap.ts): 8 cases covering happy path,
path-traversal stripping, empty-after-sanitize, control chars, truncation
at 64, null bytes, leading/trailing dot/slash stripping.
- resolveProjectPath (projects.ts): 7 cases including symlink-escape via
realpath, outside-whitelist rejection, nonexistent path, AND a flagged
BEHAVIOR GAP: passing the whitelist path itself currently returns success
rather than erroring out (function early-exits the scope check when
real === whitelistReal). Test asserts current behavior with explicit
comment flagging the spec violation — function NOT silently patched.
Function made exportable for testing (single keyword change).
- buildMessagesPayload (inference.ts): 8 cases for compact-marker logic
(no marker, marker present, multiple compacts, tool-message position).
tsconfig.json excludes __tests__ + *.test.ts from emit so dist/ stays clean.
pnpm -C apps/server test => 23 passed in ~340ms.
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>
Sessions created before Batch 3 have no rows in session_panes, so the
Workspace renders "No panes" on first open. Idempotent INSERT inserts
a default chat pane at position 0 for any session without one. NOT
EXISTS guard makes the statement a no-op after the first run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cloneElement does not carry el.key through unless explicitly passed.
Without it, react-markdown's inline-element siblings (strong/em/text)
lose their reconciler keys on every render, causing potential diffing
churn. Pass el.key (with fallback) explicitly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- MessageBubble & ToolCallCard: detect path-like strings in rendered text
via regex requiring slash+extension; clicks dispatch open_file_in_browser
- Session.tsx: now renders <Workspace sessionId projectId>; on mount,
emits session_loaded so sidebar can highlight even deep-linked sessions
not in the recent_sessions cache
- ProjectSidebar: active project's chevron visually disabled (50% opacity,
cursor-not-allowed) and click no-op; activeSession from useSidebar used
as fallback when active session isn't in cache
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When no file_browser pane exists, two rapid open_file_in_browser events
could both trigger create() since the ref check happens before the first
create resolves. Add a creating flag/promise so the second event waits
for the first create then updates the newly-created pane's state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Boolean indirection (const stateOnly = ...; if (stateOnly)) didn't carry
the narrowing to body.state inside the branch. Inline the check so
TypeScript narrows correctly and the explicit cast on pendingState
becomes unnecessary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- remove: capture snapshot inside setPanes functional updater to avoid
stale-closure rollback under concurrent renders
- flushPendingState: call refresh() on PATCH failure so server truth and
optimistic local state can't silently diverge
- Drop body.state! non-null assertion via narrowed local
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- hooks/usePanes: per-session panes CRUD; debounced (300ms) state PATCH;
immediate position-change PATCH with refresh
- CodeBlock: shiki async highlighting via codeToHtml + github-dark theme;
LANG_MAP for ts/tsx/js/jsx/py/go/rs/rb/java/c/cpp/cs/php/sh/yaml/json/
toml/md/sql/dockerfile/html/css; falls back to plain pre on unknown lang
or async failure
- package.json: + shiki
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>
- Drop unused Stats type import and its no-op suppression expression
- Comment getProjectFiles concurrent-miss race (benign, accepted)
- Comment findFiles deliberate post-limit counting (differs from grep)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- file_ops.MAX_FIND_RESULTS: 1000 -> 200 to match existing tool cap and
preserve LLM behavior
- tools.find_files now delegates to file_ops.findFiles (parallels how
grep already delegates); drops ~50 LOC of duplicated path resolution
and rg subprocess
- Drop unused basename import in file_ops
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces six non-null assertions on req.user with the requireUser helper
from auth.ts, which throws a descriptive error if the auth hook didn't
populate req.user. Adds an inline comment in /api/ws/user explaining the
manual auth check is defensive (the global hook already enforces auth).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- broker.subscribeUser/publishUser via separate user topics map
- /api/ws/user WS route subscribes to the user channel
- projects/sessions POST/DELETE handlers emit lifecycle frames
- inference 3 terminal-state sites emit session_updated with RETURNING
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A concurrent DELETE between the count read and the transaction could allow
an invalid position value to slip in. Mirror the POST fix by validating
count + bounds inside the transaction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Move sentinel from -1 to -100 (outside the negate range) so moves from
position 0 no longer collide with negated row at -1
- Pull count check + position validation inside sql.begin in POST so two
concurrent inserts can't both pass the max-5 guard
- Wrap movePane + state UPDATE in a single transaction in PATCH so partial
failures roll back consistently
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds /api/sessions/:id/panes (GET, POST), /api/panes/:id (PATCH, DELETE)
with transactional position-shift logic (negate-and-restore pattern to
avoid UNIQUE collisions). Max 5 panes per session enforced.
Sessions.POST now creates the session and a default Chat pane at position
0 atomically via sql.begin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Restructure Pane as a tagged union over kind so checking kind narrows state
- Rename session_panes_session_idx -> idx_session_panes_session for naming
consistency with idx_sessions_project, idx_messages_session
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the session_panes table, Pane/PaneState/PaneCreate/PaneUpdate types,
UserStreamFrame discriminated union, and extends SidebarSession with
project_id (also added to the sidebar SELECT).
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>
Four features land together on this branch:
1. Markdown rendering — assistant messages go through react-markdown +
remark-gfm. Fenced code blocks render via existing CodeBlock (with copy
button); inline `code` is styled inline. User messages stay plain text.
No raw HTML (no rehype-raw).
2. Per-message Copy + Regenerate. New endpoint
POST /api/sessions/:id/messages/:message_id/regenerate validates the
target (404/400/409), atomically deletes the target plus any later
messages in the session, inserts a fresh streaming assistant row, and
enqueues a normal inference run. The DELETE bound uses a SQL subquery
(`created_at >= (SELECT created_at FROM messages WHERE id = $1)`)
instead of a JS round-trip so postgres TIMESTAMPTZ µs precision is
preserved — otherwise sub-ms clock_timestamp() differences between the
user row and the assistant row collapsed to the same JS Date, pulling
the triggering user message into the >= bound. New `messages_deleted`
WS frame so already-connected clients prune the stale tail without
needing a full snapshot resend.
3. tok/s + ctx counter. Five new nullable message columns: tokens_used,
ctx_used, ctx_max, started_at, finished_at. started_at is set right
before the OpenAI call in services/inference.ts (not in the route, not
in the frame handler); finished_at + tokens_used + ctx_used + ctx_max
are committed in the same UPDATE that flips status to 'complete'. The
inference request now opts into stream_options.include_usage so the
final chunk carries usage; defensive parsing also picks up timings.n_ctx
when llama.cpp emits it (currently absent for our llama-swap models, so
ctx_max stays NULL and the UI just shows `<used> ctx`). message_complete
frame extended with tokens_used / ctx_used / ctx_max / started_at /
finished_at / model. Frontend StatsLine in MessageBubble computes tok/s
client-side from the timestamps and renders muted mono text below the
body of completed assistant messages.
4. AI chat naming after the first turn. Backend services/auto_name.ts
runs via setImmediate after the top-level inference resolves; it
checks that there is exactly one completed assistant message and that
the session has not been user-renamed (`name IS NULL OR name = '' OR
name = 'New session'`), then fires a single non-streaming chat
completion with the spec prompt. Qwen3 chat templates emit chain-of-
thought into reasoning_content and burn the entire max_tokens budget
without producing visible output, so the request includes
`chat_template_kwargs: { enable_thinking: false }` and max_tokens=30.
Title is trimmed, quote-stripped, "Title:" prefix dropped, and
truncated to 60 chars before a guarded UPDATE on sessions.name. New
`session_renamed` WS frame propagates to the open session view
directly and to the project's session list via a tiny module-scope
event bus (apps/web/src/hooks/sessionEvents.ts) — kept dumb: one event
type, two methods, no library.
Cleanups: dropped the now-unused splitCodeBlocks export from CodeBlock.tsx
(react-markdown supersedes it), and added a long-form NOTE in auto_name.ts
documenting the enable_thinking + max_tokens pattern for any future Qwen-
family non-streaming utility calls (planned: fork-message, agent-routing,
web-search summarization).
Schema bootstrap remains idempotent (ADD COLUMN IF NOT EXISTS). Auth,
broker, clock_timestamp() conventions, and zod validation all unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>