feat(mobile): v1.8 tab switcher + branch indicator + git_status tool

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>
This commit is contained in:
2026-05-16 20:07:53 +00:00
parent 92bd3b1cdf
commit 2bce4d85fa
19 changed files with 1217 additions and 622 deletions

View File

@@ -115,6 +115,16 @@ export interface ProjectUpdatedEvent {
name: string;
}
// v1.8 mobile-tabs: broadcast on user channel from inference.ts so any device
// subscribed sees a chat working/idle/error. Frontend stores per-chat; panes
// derive their dot from pane.activeChatId.
export interface ChatStatusEvent {
type: 'chat_status';
chat_id: string;
status: 'working' | 'idle' | 'error';
at: string;
}
export type SessionEvent =
| SessionRenamedEvent
| ProjectCreatedEvent
@@ -134,7 +144,8 @@ export type SessionEvent =
| ChatDeletedEvent
| ProjectArchivedEvent
| ProjectUnarchivedEvent
| ProjectUpdatedEvent;
| ProjectUpdatedEvent
| ChatStatusEvent;
type Listener = (event: SessionEvent) => void;
const listeners = new Set<Listener>();

View File

@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import { sessionEvents } from './sessionEvents';
export type RawStatus = 'working' | 'idle' | 'error';
export type DerivedStatus = 'working' | 'idle_warm' | 'idle_cold' | 'error';
// Window during which an idle dot stays green; after this, it fades to gray.
const WARM_WINDOW_MS = 30_000;
const TICK_MS = 5_000;
interface Entry {
status: RawStatus;
at: string;
}
// Module-scope shared state so every StatusDot in the app shares one map
// (mirrors useSidebar's singleton pattern). The map is ephemeral — cleared on
// page reload; WS reconnect repopulates as new frames arrive.
const statuses = new Map<string, Entry>();
const subscribers = new Set<() => void>();
function notify(): void {
for (const s of subscribers) {
try { s(); } catch { /* swallow */ }
}
}
// Guard against duplicate listeners during Vite HMR.
const G = globalThis as Record<string, unknown>;
if (!G.__boocode_chat_status_subscribed) {
G.__boocode_chat_status_subscribed = true;
sessionEvents.subscribe((ev) => {
if (ev.type !== 'chat_status') return;
statuses.set(ev.chat_id, { status: ev.status, at: ev.at });
notify();
});
// Single shared ticker: re-notify so any green dot whose 30s window just
// expired re-renders as gray. We only notify if there's something warm —
// avoids waking sleeping components for nothing.
setInterval(() => {
const now = Date.now();
for (const entry of statuses.values()) {
if (entry.status === 'idle') {
const age = now - new Date(entry.at).getTime();
if (age < WARM_WINDOW_MS + TICK_MS) {
notify();
return;
}
}
}
}, TICK_MS);
}
function derive(entry: Entry | undefined): DerivedStatus {
if (!entry) return 'idle_cold';
if (entry.status === 'working') return 'working';
if (entry.status === 'error') return 'error';
const age = Date.now() - new Date(entry.at).getTime();
return age < WARM_WINDOW_MS ? 'idle_warm' : 'idle_cold';
}
export function useChatStatus(chatId: string | null | undefined): DerivedStatus {
const [, force] = useState({});
useEffect(() => {
const sub = () => force({});
subscribers.add(sub);
return () => { subscribers.delete(sub); };
}, []);
if (!chatId) return 'idle_cold';
return derive(statuses.get(chatId));
}

View File

@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react';
import { api } from '@/api/client';
import type { GitMeta } from '@/api/types';
const POLL_INTERVAL_MS = 30_000;
// Live-ish git meta for the project header indicator. Backed by the server's
// 30s cache, so a 30s client poll plus the cache TTL bounds total staleness
// to ~60s in the worst case. Returns null while the first fetch is in flight
// or if the request failed.
export function useProjectGit(projectId: string | null | undefined): GitMeta | null {
const [meta, setMeta] = useState<GitMeta | null>(null);
useEffect(() => {
if (!projectId) {
setMeta(null);
return;
}
let cancelled = false;
const fetchOnce = () => {
api.projects
.git(projectId)
.then((m) => {
if (!cancelled) setMeta(m);
})
.catch(() => {
if (!cancelled) setMeta(null);
});
};
fetchOnce();
const t = setInterval(fetchOnce, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(t);
};
}, [projectId]);
return meta;
}

View File

@@ -171,6 +171,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'chat_archived':
case 'chat_unarchived':
case 'chat_deleted':
case 'chat_status':
return prev;
case 'project_archived': {
const next = prev.projects.filter((p) => p.id !== event.project_id);