From 2bce4d85fa5db3e14305a997f0b8551766891e90 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sat, 16 May 2026 20:07:53 +0000 Subject: [PATCH] feat(mobile): v1.8 tab switcher + branch indicator + git_status tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/server/src/routes/projects.ts | 33 + apps/server/src/services/git_meta.ts | 92 +++ apps/server/src/services/inference.ts | 8 + apps/server/src/services/tools.ts | 35 + apps/server/src/types/api.ts | 11 +- apps/web/src/api/client.ts | 3 + apps/web/src/api/types.ts | 9 + apps/web/src/components/BottomSheet.tsx | 92 +++ apps/web/src/components/ChatTabBar.tsx | 4 +- apps/web/src/components/MobileTabSwitcher.tsx | 207 ++++++ apps/web/src/components/NewPaneMenu.tsx | 44 ++ apps/web/src/components/StatusDot.tsx | 36 + apps/web/src/components/Workspace.tsx | 132 +--- apps/web/src/hooks/sessionEvents.ts | 13 +- apps/web/src/hooks/useChatStatus.ts | 71 ++ apps/web/src/hooks/useProjectGit.ts | 41 ++ apps/web/src/hooks/useSidebar.ts | 1 + apps/web/src/pages/Session.tsx | 352 +++++++--- boocode_roadmap.md | 655 +++++++----------- 19 files changed, 1217 insertions(+), 622 deletions(-) create mode 100644 apps/server/src/services/git_meta.ts create mode 100644 apps/web/src/components/BottomSheet.tsx create mode 100644 apps/web/src/components/MobileTabSwitcher.tsx create mode 100644 apps/web/src/components/NewPaneMenu.tsx create mode 100644 apps/web/src/components/StatusDot.tsx create mode 100644 apps/web/src/hooks/useChatStatus.ts create mode 100644 apps/web/src/hooks/useProjectGit.ts diff --git a/apps/server/src/routes/projects.ts b/apps/server/src/routes/projects.ts index 56cb106..2717748 100644 --- a/apps/server/src/routes/projects.ts +++ b/apps/server/src/routes/projects.ts @@ -9,6 +9,7 @@ import type { Project, AvailableProject } from '../types/api.js'; import { resolveProjectRoot, PathScopeError } from '../services/path_guard.js'; import { listDir, viewFile } from '../services/file_ops.js'; import { getProjectFiles } from '../services/file_index.js'; +import { getGitMeta } from '../services/git_meta.js'; import { bootstrapProject, BootstrapNameError, @@ -381,6 +382,38 @@ export function registerProjectRoutes( } ); + // GET /api/projects/:id/git + // v1.8 mobile-tabs: feeds the header branch indicator and is the same + // resolver the model's git_status tool uses. Returns 200 with branch=null + // for non-git directories (not 404) so the UI can degrade gracefully. + app.get<{ Params: { id: string } }>( + '/api/projects/:id/git', + async (req, reply) => { + const { id } = req.params; + const rows = await sql` + SELECT id, name, path, added_at, last_session_id, status, gitea_remote + FROM projects WHERE id = ${id} + `; + if (rows.length === 0) { + reply.code(404); + return { error: 'not found' }; + } + const project = rows[0]!; + let projectRoot: string; + try { + projectRoot = await resolveProjectRoot(project.path); + } catch (err) { + if (err instanceof PathScopeError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + const meta = await getGitMeta(projectRoot); + return meta ?? { branch: null, is_dirty: false, ahead: 0, behind: 0 }; + } + ); + // GET /api/projects/:id/files app.get<{ Params: { id: string } }>( '/api/projects/:id/files', diff --git a/apps/server/src/services/git_meta.ts b/apps/server/src/services/git_meta.ts new file mode 100644 index 0000000..29e160f --- /dev/null +++ b/apps/server/src/services/git_meta.ts @@ -0,0 +1,92 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +const CACHE_TTL_MS = 30_000; +const GIT_TIMEOUT_MS = 2_000; +// Cap stdout size so a pathological repo can't blow the buffer. Branch + status +// porcelain + diverge counts never approach this on a real repo. +const GIT_MAX_BUFFER = 1024 * 1024; + +export interface GitMeta { + branch: string | null; + is_dirty: boolean; + ahead: number; + behind: number; +} + +interface CacheEntry { + at: number; + value: GitMeta | null; +} + +const cache = new Map(); + +// Runs a single git invocation with a hard 2s timeout. Returns null on any +// failure (non-zero exit, timeout, git not installed) so callers can decide +// how to degrade. Stderr is intentionally swallowed; we don't surface git's +// error text to the model or UI. +async function runGit(args: string[], cwd: string): Promise { + try { + const { stdout } = await execFileAsync('git', args, { + cwd, + timeout: GIT_TIMEOUT_MS, + windowsHide: true, + maxBuffer: GIT_MAX_BUFFER, + }); + return stdout.toString(); + } catch { + return null; + } +} + +export async function getGitMeta(rootPath: string): Promise { + const cached = cache.get(rootPath); + const now = Date.now(); + if (cached && now - cached.at < CACHE_TTL_MS) { + return cached.value; + } + + // Three calls in parallel. rev-parse establishes repo + branch name; + // status --porcelain detects dirtiness with no false-positives from formatting; + // rev-list --left-right --count compares HEAD to upstream and is allowed to + // fail silently (returns null → ahead/behind = 0) when no upstream is set. + const [branchOut, statusOut, divergedOut] = await Promise.all([ + runGit(['rev-parse', '--abbrev-ref', 'HEAD'], rootPath), + runGit(['status', '--porcelain'], rootPath), + runGit(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], rootPath), + ]); + + // If rev-parse fails, this isn't a git repo (or git isn't installed). Cache + // the null result so the next 30s of requests don't re-probe. + if (branchOut === null) { + cache.set(rootPath, { at: now, value: null }); + return null; + } + + const branch = branchOut.trim() || null; + const is_dirty = statusOut !== null && statusOut.trim().length > 0; + + let ahead = 0; + let behind = 0; + if (divergedOut !== null) { + const match = divergedOut.trim().match(/^(\d+)\s+(\d+)/); + if (match) { + ahead = Number(match[1]); + behind = Number(match[2]); + } + } + + const value: GitMeta = { branch, is_dirty, ahead, behind }; + cache.set(rootPath, { at: now, value }); + return value; +} + +export function invalidateGitMetaCache(rootPath?: string): void { + if (rootPath) { + cache.delete(rootPath); + } else { + cache.clear(); + } +} diff --git a/apps/server/src/services/inference.ts b/apps/server/src/services/inference.ts index 704ca20..b2eb1fb 100644 --- a/apps/server/src/services/inference.ts +++ b/apps/server/src/services/inference.ts @@ -493,6 +493,9 @@ async function handleAbortOrError( RETURNING project_id, name, updated_at `; ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: failSessRow!.project_id, name: failSessRow!.name, updated_at: failSessRow!.updated_at }); + // v1.8 mobile-tabs: cancellation is a user-initiated stop, treat as idle; + // genuine errors flip the dot red. + ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: isAbort ? 'idle' : 'error', at: new Date().toISOString() }); if (isAbort) { ctx.publish(sessionId, { type: 'message_complete', @@ -638,6 +641,7 @@ async function finalizeCompletion( RETURNING project_id, name, updated_at `; ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: completeSessRow!.project_id, name: completeSessRow!.name, updated_at: completeSessRow!.updated_at }); + ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() }); ctx.publish(sessionId, { type: 'message_complete', message_id: assistantMessageId, @@ -683,6 +687,7 @@ async function runAssistantTurn( chat_id: chatId, error: 'tool loop depth exceeded', }); + ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'error', at: new Date().toISOString() }); return; } @@ -820,6 +825,9 @@ export function createInferenceRunner( ...ctx, publishUser: (frame) => publishUserFn(user, frame), }; + // v1.8 mobile-tabs: announce working before the async loop starts so + // every device subscribed to the user channel sees the amber dot. + callCtx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'working', at: new Date().toISOString() }); const controller = new AbortController(); let resolveCompleted!: () => void; const completed = new Promise((res) => { resolveCompleted = res; }); diff --git a/apps/server/src/services/tools.ts b/apps/server/src/services/tools.ts index 1918460..45bb6d4 100644 --- a/apps/server/src/services/tools.ts +++ b/apps/server/src/services/tools.ts @@ -3,6 +3,7 @@ import { resolve, basename, relative } from 'node:path'; import { z } from 'zod'; import { pathGuard, PathScopeError } from './path_guard.js'; import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js'; +import { getGitMeta } from './git_meta.js'; const MAX_FILE_BYTES = 5 * 1024 * 1024; const DEFAULT_VIEW_LINES = 200; @@ -266,11 +267,45 @@ export const findFiles: ToolDef = { }, }; +// v1.8 Level 1 branch awareness: gives the model a read-only view of the +// project's git state. No path input — operates on the inference-resolved +// project root via getGitMeta. Subprocess runs with a 2s timeout (see git_meta). +const GitStatusInput = z.object({}).strict(); +type GitStatusInputT = z.infer; + +export const gitStatus: ToolDef = { + name: 'git_status', + description: + "Returns the current git branch, whether the working tree is dirty, and ahead/behind counts vs upstream. Read-only. Use when you need to know which branch the user is currently working on.", + inputSchema: GitStatusInput, + jsonSchema: { + type: 'function', + function: { + name: 'git_status', + description: + 'Returns the current git branch, dirty flag, and ahead/behind counts vs upstream. Read-only.', + parameters: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + }, + async execute(_input, projectRoot) { + const meta = await getGitMeta(projectRoot); + if (meta === null) { + return { repo: false, branch: null, is_dirty: false, ahead: 0, behind: 0 }; + } + return { repo: true, ...meta }; + }, +}; + export const ALL_TOOLS: ReadonlyArray> = [ viewFile as ToolDef, listDir as ToolDef, grep as ToolDef, findFiles as ToolDef, + gitStatus as ToolDef, ]; export const TOOLS_BY_NAME: Record> = Object.fromEntries( diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts index 8d06e97..c99bd04 100644 --- a/apps/server/src/types/api.ts +++ b/apps/server/src/types/api.ts @@ -246,6 +246,14 @@ export interface ProjectUpdatedFrame { project_id: string; name: string; } +// v1.8 mobile-tabs: server can't know about client-side panes, so status +// is keyed by chat_id. Frontend dot derives pane status from pane.activeChatId. +export interface ChatStatusFrame { + type: 'chat_status'; + chat_id: string; + status: 'working' | 'idle' | 'error'; + at: string; +} export type UserStreamFrame = | ProjectCreatedFrame | ProjectDeletedFrame @@ -261,4 +269,5 @@ export type UserStreamFrame = | ChatDeletedFrame | ProjectArchivedFrame | ProjectUnarchivedFrame - | ProjectUpdatedFrame; + | ProjectUpdatedFrame + | ChatStatusFrame; diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 056719a..6db65fd 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -9,6 +9,7 @@ import type { ListDirResult, ViewFileResult, AgentsResponse, + GitMeta, } from './types'; export class ApiError extends Error { @@ -87,6 +88,8 @@ export const api = { request(`/api/projects/${id}/view_file?path=${encodeURIComponent(path)}`), files: (id: string) => request<{ files: string[] }>(`/api/projects/${id}/files`), + git: (id: string) => + request(`/api/projects/${id}/git`), }, sessions: { diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 855a106..020c582 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -175,6 +175,15 @@ export interface PaneUpdateRequest { position?: number; } +// v1.8 mobile-tabs: shape returned by GET /api/projects/:id/git. Mirrors +// services/git_meta.ts on the server. branch=null means "not a git repo". +export interface GitMeta { + branch: string | null; + is_dirty: boolean; + ahead: number; + behind: number; +} + export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty'; export interface WorkspacePane { diff --git a/apps/web/src/components/BottomSheet.tsx b/apps/web/src/components/BottomSheet.tsx new file mode 100644 index 0000000..1ceb188 --- /dev/null +++ b/apps/web/src/components/BottomSheet.tsx @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState, type ReactNode, type TouchEvent } from 'react'; +import { cn } from '@/lib/utils'; + +interface Props { + open: boolean; + onClose: () => void; + children: ReactNode; + title?: string; +} + +// Past this drag distance, release dismisses the sheet. +const SWIPE_DISMISS_THRESHOLD_PX = 80; + +export function BottomSheet({ open, onClose, children, title }: Props) { + const [dragY, setDragY] = useState(0); + const startYRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + + useEffect(() => { + if (!open) { + setDragY(0); + startYRef.current = null; + } + }, [open]); + + function onTouchStart(e: TouchEvent) { + const t = e.touches[0]; + if (!t) return; + startYRef.current = t.clientY; + } + function onTouchMove(e: TouchEvent) { + const t = e.touches[0]; + if (!t || startYRef.current === null) return; + const dy = t.clientY - startYRef.current; + // Clamp to downward drags so the sheet doesn't "rubber-band" up. + if (dy > 0) setDragY(dy); + } + function onTouchEnd() { + if (dragY > SWIPE_DISMISS_THRESHOLD_PX) { + onClose(); + } else { + setDragY(0); + } + startYRef.current = null; + } + + if (!open) return null; + + return ( + <> +