Compare commits
7 Commits
v1.7-drag-
...
v1.6.5-ses
| Author | SHA1 | Date | |
|---|---|---|---|
| c750ce9e62 | |||
| bbf9fac936 | |||
| 6fa6eb7f32 | |||
| 5932682193 | |||
| 9d0d41bcb3 | |||
| e167f851fd | |||
| f6c7e12dbf |
@@ -120,6 +120,15 @@ export function registerSessionRoutes(
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
const { name, model, system_prompt } = parsed.data;
|
||||
// Read the prior name so the post-update publish can skip no-op renames
|
||||
// (PATCH { name: "Foo" } where the session is already "Foo"). The window
|
||||
// between SELECT and UPDATE is sub-millisecond in the same request handler;
|
||||
// a concurrent rename in that gap would just mean one stale publish, which
|
||||
// existing clients dedup by id.
|
||||
const before = await sql<{ name: string }[]>`
|
||||
SELECT name FROM sessions WHERE id = ${req.params.id}
|
||||
`;
|
||||
const priorName = before[0]?.name;
|
||||
const rows = await sql<Session[]>`
|
||||
UPDATE sessions
|
||||
SET
|
||||
@@ -135,7 +144,7 @@ export function registerSessionRoutes(
|
||||
return { error: 'session not found' };
|
||||
}
|
||||
const session = rows[0]!;
|
||||
if (name !== undefined) {
|
||||
if (name !== undefined && session.name !== priorName) {
|
||||
broker.publishUser('default', {
|
||||
type: 'session_renamed',
|
||||
session_id: session.id,
|
||||
|
||||
@@ -10,7 +10,7 @@ const BASE_SYSTEM_PROMPT = (projectPath: string) =>
|
||||
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
|
||||
|
||||
const DB_FLUSH_INTERVAL_MS = 500;
|
||||
const MAX_TOOL_LOOP_DEPTH = 5;
|
||||
const MAX_TOOL_LOOP_DEPTH = 15;
|
||||
|
||||
export interface InferenceFrame {
|
||||
type:
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Session } from '@/pages/Session';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { useUserEvents } from '@/hooks/useUserEvents';
|
||||
import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
||||
import { RightRailDrawerProvider, useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
|
||||
function SessionRightRail() {
|
||||
@@ -26,13 +27,11 @@ function RightRailForSession({ sessionId }: { sessionId: string }) {
|
||||
.catch((err) => console.warn('RightRail: failed to fetch session', err));
|
||||
}, [sessionId]);
|
||||
if (!projectId) return null;
|
||||
// Hidden entirely below md breakpoint; mobile users get the file browser
|
||||
// via the FileBrowserPane infrastructure if/when it lands in workspace panes.
|
||||
return (
|
||||
<div className="max-md:hidden contents">
|
||||
<RightRail projectId={projectId} />
|
||||
</div>
|
||||
);
|
||||
// v1.6.2: rendered on all viewports. On mobile, RightRail itself renders as
|
||||
// a right-side drawer toggled by the header's FolderTree button (via
|
||||
// useRightRailDrawer). On desktop, it renders inline as before with its
|
||||
// own internal open/close state.
|
||||
return <RightRail projectId={projectId} />;
|
||||
}
|
||||
|
||||
function MobileBackdrop() {
|
||||
@@ -48,6 +47,19 @@ function MobileBackdrop() {
|
||||
);
|
||||
}
|
||||
|
||||
function MobileRightRailBackdrop() {
|
||||
const { open, setOpen } = useRightRailDrawer();
|
||||
const { isMobile } = useViewport();
|
||||
if (!isMobile || !open) return null;
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-30 bg-black/40 md:hidden"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
useUserEvents();
|
||||
return (
|
||||
@@ -61,6 +73,7 @@ function AppShell() {
|
||||
<Route path="/session/:id" element={<Session />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<MobileRightRailBackdrop />
|
||||
<Routes>
|
||||
<Route path="/session/:id" element={<SessionRightRail />} />
|
||||
</Routes>
|
||||
@@ -73,7 +86,9 @@ export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<SidebarDrawerProvider>
|
||||
<AppShell />
|
||||
<RightRailDrawerProvider>
|
||||
<AppShell />
|
||||
</RightRailDrawerProvider>
|
||||
</SidebarDrawerProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
@@ -123,6 +123,10 @@ export function ChatTabBar({
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => onNewChat()}>
|
||||
New chat
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -4,8 +4,11 @@ import { api } from '@/api/client';
|
||||
import type { FileEntry } from '@/api/types';
|
||||
import { inferLanguage } from '@/lib/attachments';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
@@ -25,6 +28,8 @@ function joinPath(parent: string, name: string): string {
|
||||
}
|
||||
|
||||
export function RightRail({ projectId }: Props) {
|
||||
const { isMobile } = useViewport();
|
||||
const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer();
|
||||
const [open, setOpen] = useState(() => {
|
||||
try { return localStorage.getItem(`${STORAGE_KEY}.open`) !== 'false'; } catch { return true; }
|
||||
});
|
||||
@@ -34,6 +39,19 @@ export function RightRail({ projectId }: Props) {
|
||||
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
|
||||
const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null);
|
||||
|
||||
// Combined open state: on mobile use the global drawer state (toggled by
|
||||
// the Session header's FolderTree button); on desktop use the persistent
|
||||
// internal state.
|
||||
const isOpen = isMobile ? drawerOpen : open;
|
||||
const closeRail = useCallback(() => {
|
||||
if (isMobile) setDrawerOpen(false);
|
||||
else setOpen(false);
|
||||
}, [isMobile, setDrawerOpen]);
|
||||
const openRail = useCallback(() => {
|
||||
if (isMobile) setDrawerOpen(true);
|
||||
else setOpen(true);
|
||||
}, [isMobile, setDrawerOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
// best-effort; ignore failure because localStorage may be unavailable (quota, private mode)
|
||||
try { localStorage.setItem(`${STORAGE_KEY}.open`, String(open)); } catch {}
|
||||
@@ -56,9 +74,9 @@ export function RightRail({ projectId }: Props) {
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!isOpen) return;
|
||||
if (!cache.has('')) void loadDir('');
|
||||
}, [open, cache, loadDir]);
|
||||
}, [isOpen, cache, loadDir]);
|
||||
|
||||
function toggleDir(dirPath: string) {
|
||||
setExpandedDirs((prev) => {
|
||||
@@ -108,12 +126,14 @@ export function RightRail({ projectId }: Props) {
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((event) => {
|
||||
if (event.type !== 'open_file_in_browser') return;
|
||||
if (!open) setOpen(true);
|
||||
if (!isOpen) openRail();
|
||||
void openFile(event.path);
|
||||
});
|
||||
}, [open, projectId]);
|
||||
}, [isOpen, openRail, projectId]);
|
||||
|
||||
if (!open) {
|
||||
// Desktop closed state: render the floating chevron handle. Mobile never
|
||||
// shows the handle — the toggle lives in the Session header on mobile.
|
||||
if (!isMobile && !open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -128,15 +148,25 @@ export function RightRail({ projectId }: Props) {
|
||||
|
||||
const rootEntries = cache.get('') ?? [];
|
||||
|
||||
// Mobile: render as fixed-position right-side drawer (always mounted so
|
||||
// the transform transition can animate in/out). Desktop: inline aside.
|
||||
const asideCls = isMobile
|
||||
? cn(
|
||||
'fixed inset-y-0 right-0 z-40 w-[85vw] max-w-sm border-l bg-sidebar flex flex-col overflow-hidden',
|
||||
'transition-transform duration-200 ease-out',
|
||||
drawerOpen ? 'translate-x-0' : 'translate-x-full',
|
||||
)
|
||||
: 'w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden';
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden">
|
||||
<aside className={asideCls}>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
|
||||
<span className="text-xs font-medium flex-1">Files</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
||||
onClick={closeRail}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Close file browser"
|
||||
>
|
||||
<PanelRightClose size={14} />
|
||||
|
||||
@@ -125,34 +125,36 @@ export function Workspace({ sessionId, projectId }: Props) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={panes.length >= MAX_PANES}
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted max-md:min-h-[44px] max-md:px-3',
|
||||
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
|
||||
)}
|
||||
>
|
||||
<PanelRight size={14} />
|
||||
Split
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
|
||||
<MessageSquare size={14} /> Chat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
|
||||
<Terminal size={14} /> Terminal
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('agent')}>
|
||||
<Bot size={14} /> Agent
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={panes.length >= MAX_PANES}
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
|
||||
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
|
||||
)}
|
||||
>
|
||||
<PanelRight size={14} />
|
||||
Split
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
|
||||
<MessageSquare size={14} /> Chat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
|
||||
<Terminal size={14} /> Terminal
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('agent')}>
|
||||
<Bot size={14} /> Agent
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMobile && panes.length > 1 && (
|
||||
<div className="flex items-center gap-1 overflow-x-auto border-b border-border bg-muted/10 px-2 py-1 shrink-0">
|
||||
|
||||
35
apps/web/src/hooks/useRightRailDrawer.tsx
Normal file
35
apps/web/src/hooks/useRightRailDrawer.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
interface RightRailDrawerState {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<RightRailDrawerState | null>(null);
|
||||
|
||||
export function RightRailDrawerProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
// Auto-close on route change. Same pattern as useSidebarDrawer — keeps the
|
||||
// drawer from leaking between sessions when the user navigates.
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
const toggle = useCallback(() => setOpen((v) => !v), []);
|
||||
|
||||
return <Ctx.Provider value={{ open, setOpen, toggle }}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useRightRailDrawer(): RightRailDrawerState {
|
||||
const ctx = useContext(Ctx);
|
||||
if (!ctx) {
|
||||
// Soft fallback so consumers don't crash if rendered outside a provider.
|
||||
return { open: false, setOpen: () => {}, toggle: () => {} };
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -81,32 +81,32 @@ export function Project() {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col">
|
||||
<header
|
||||
className="border-b px-6 py-3 flex items-center justify-between gap-2"
|
||||
style={{ paddingTop: 'max(0.75rem, env(safe-area-inset-top))' }}
|
||||
className="border-b px-3 sm:px-6 py-2 sm:py-3 flex items-center justify-between gap-2"
|
||||
style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{isMobile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="inline-flex items-center justify-center -ml-2 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
|
||||
className="inline-flex items-center justify-center -ml-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold tracking-tight truncate">
|
||||
<h1 className="text-base sm:text-lg font-semibold tracking-tight truncate">
|
||||
{project?.name ?? '…'}
|
||||
</h1>
|
||||
<div className="text-xs text-muted-foreground font-mono truncate">
|
||||
<div className="text-xs text-muted-foreground font-mono truncate hidden sm:block">
|
||||
{project?.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleNew} disabled={creating} className="shrink-0">
|
||||
<Button onClick={handleNew} disabled={creating} className="shrink-0" aria-label="New session">
|
||||
<Plus />
|
||||
New session
|
||||
<span className="hidden sm:inline">New session</span>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { ChevronRight, Menu } from 'lucide-react';
|
||||
import { ChevronRight, FolderTree, Menu } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import type { Project, Session as SessionType } from '@/api/types';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { useActivePane } from '@/hooks/useActivePane';
|
||||
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
||||
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { Workspace } from '@/components/Workspace';
|
||||
import { ModelPicker } from '@/components/ModelPicker';
|
||||
@@ -19,6 +20,7 @@ export function Session() {
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const active = useActivePane();
|
||||
const { setOpen: setDrawerOpen } = useSidebarDrawer();
|
||||
const { toggle: toggleRightRail } = useRightRailDrawer();
|
||||
const { isMobile } = useViewport();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -87,33 +89,42 @@ export function Session() {
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<header className="border-b px-4 py-2 flex items-center gap-1.5 shrink-0 text-sm" style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}>
|
||||
<header
|
||||
className="border-b px-3 sm:px-4 py-2 flex items-center gap-1.5 shrink-0 text-sm"
|
||||
style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}
|
||||
>
|
||||
{isMobile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="inline-flex items-center justify-center -ml-1 mr-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="inline-flex items-center justify-center -ml-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
<Link to="/" className="text-muted-foreground hover:text-foreground">
|
||||
Projects
|
||||
</Link>
|
||||
<ChevronRight className="size-3 text-muted-foreground/60" />
|
||||
{project ? (
|
||||
<Link
|
||||
to={`/project/${project.id}`}
|
||||
className="text-muted-foreground hover:text-foreground truncate max-w-[200px]"
|
||||
title={project.name}
|
||||
>
|
||||
{project.name}
|
||||
|
||||
{/* Breadcrumb — desktop only */}
|
||||
<div className="hidden sm:flex items-center gap-1.5 min-w-0">
|
||||
<Link to="/" className="text-muted-foreground hover:text-foreground shrink-0 text-xs">
|
||||
Projects
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground/60">…</span>
|
||||
)}
|
||||
<ChevronRight className="size-3 text-muted-foreground/60" />
|
||||
<ChevronRight className="size-3 text-muted-foreground/60 shrink-0" />
|
||||
{project ? (
|
||||
<Link
|
||||
to={`/project/${project.id}`}
|
||||
className="text-muted-foreground hover:text-foreground truncate max-w-[200px]"
|
||||
title={project.name}
|
||||
>
|
||||
{project.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground/60">…</span>
|
||||
)}
|
||||
<ChevronRight className="size-3 text-muted-foreground/60 shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* Session name — always visible, truncated, editable */}
|
||||
{editingName ? (
|
||||
<input
|
||||
autoFocus
|
||||
@@ -127,30 +138,34 @@ export function Session() {
|
||||
setEditingName(false);
|
||||
}
|
||||
}}
|
||||
className="bg-transparent border-b border-border px-1 py-0.5 text-sm font-medium outline-none focus:border-ring"
|
||||
className="bg-transparent border-b border-border px-1 py-0.5 text-sm font-medium outline-none focus:border-ring min-w-0"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-medium hover:underline truncate max-w-[280px]"
|
||||
className="text-sm font-medium hover:underline truncate max-w-[140px] sm:max-w-[280px] min-w-0"
|
||||
onClick={() => setEditingName(true)}
|
||||
title={session?.name ?? ''}
|
||||
>
|
||||
{session?.name ?? '…'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Active file — desktop only */}
|
||||
{showActiveFile && active.activeFile && (
|
||||
<>
|
||||
<span className="text-muted-foreground/40 mx-1">·</span>
|
||||
<span className="text-muted-foreground/40 mx-1 hidden sm:inline">·</span>
|
||||
<span
|
||||
className="text-xs font-mono text-muted-foreground truncate max-w-[320px]"
|
||||
className="text-xs font-mono text-muted-foreground truncate max-w-[200px] hidden sm:inline"
|
||||
title={active.activeFile}
|
||||
>
|
||||
{active.activeFile}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
|
||||
{/* Model picker — right-aligned */}
|
||||
<div className="ml-auto shrink-0">
|
||||
{session && (
|
||||
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
|
||||
<ModelPicker
|
||||
@@ -163,6 +178,18 @@ export function Session() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File browser toggle — mobile only */}
|
||||
{isMobile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleRightRail}
|
||||
className="inline-flex items-center justify-center -mr-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
|
||||
aria-label="Toggle file browser"
|
||||
>
|
||||
<FolderTree className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{id && session && (
|
||||
|
||||
@@ -27,8 +27,9 @@ Live at `https://code.indifferentketchup.com` (Caddy → Authelia → Tailscale
|
||||
|v1.4 |Fork from message + delete message + header polish + housekeeping |✅ Merged |Was original “Batch 5” |
|
||||
|v1.5 |Refactor splits, vitest harness (23 tests), error-log surfacing, `/opt:ro` + `BOOTSTRAP_ROOT`, persistent context-window tracker |✅ Merged |— |
|
||||
|v1.5.1 |Bootstrap hotfix: git in container, SSH keypair, known_hosts, SSH URL rewrite, /opt/projects label |✅ Merged |`4a9f207` |
|
||||
|v1.6-mobile-pass|Mobile pass: drawer, pane stacking, long-press, swipe-to-close, pull-to-refresh, IME safety, safe-area, tap targets + H1 path-guard fix|🔄 Hand-back received, uncommitted|Was original “Batch 4” |
|
||||
|v1.6.1-cleanup |Stale code audit, overengineering audit, secrets hygiene, RightRail mobile fix |Planned (next) |— |
|
||||
|v1.6-mobile-pass|Mobile pass: drawer, pane stacking, long-press, swipe-to-close, pull-to-refresh, IME safety, safe-area, tap targets + H1 path-guard fix|✅ Merged |`57c883b..943ae7d` (6 commits) |
|
||||
|v1.6.1-cleanup |Mostly audit-only; one fix shipped: RightRail `max-md:hidden` wrapper. Audit reports for secrets, stale code, panes, mount scope, hand-rolled patterns deferred to follow-ups |✅ Merged |`6a9fe18` |
|
||||
|v1.6.2-mobile-ui-fixes|Mobile UI polish from device testing: kill single-pane navigator chrome, header rework, “New chat” in long-press menu, RightRail as mobile drawer (reverts v1.6.1 wrapper) |🔄 Hand-back received, uncommitted|— |
|
||||
|v1.7 |Drag-drop + paste-as-attachment (chip infra extension) |Planned |Was Batch 6 |
|
||||
|v1.8 |Settings drawer (system prompt per project + session, web search toggle) |Planned |Was Batch 7 |
|
||||
|v1.9 |Web search backend: SearXNG `web_search` + `web_fetch` tools |Planned |Was Batch 8 |
|
||||
@@ -139,15 +140,16 @@ Dockerfile (git installed in container), docker-compose.yml, project_bootstrap.t
|
||||
|
||||
-----
|
||||
|
||||
### v1.6-mobile-pass 🔄
|
||||
### v1.6-mobile-pass ✅
|
||||
|
||||
**Hand-back received, uncommitted on `v1.6-mobile-pass`.** 5-commit sequence proposed:
|
||||
**Merged via 6 commits `57c883b..943ae7d`** (5 functional + 1 docs):
|
||||
|
||||
1. `chore: fix resolveProjectPath whitelist-root bypass` (H1 — dropped `real !== whitelistReal` short-circuit; 23/23 pass).
|
||||
1. `feat(mobile): viewport hook + sidebar drawer + hamburger headers` (M1 + M2 + M6-header).
|
||||
1. `feat(mobile): single-pane stack + long-press tab menu + swipe-to-close` (M3 + M4 + A2).
|
||||
1. `feat(mobile): chat input keybinds + safe-area + tap targets + overflow safety` (M5 + M6-bottom + M7 + M8).
|
||||
1. `feat(mobile): pull-to-refresh sidebar list` (A1).
|
||||
1. `57c883b chore: fix resolveProjectPath whitelist-root bypass` (H1 — dropped `real !== whitelistReal` short-circuit; flipped the v1.5 BEHAVIOR GAP test; 23/23 pass).
|
||||
1. `a643b5f feat(mobile): viewport hook + sidebar drawer + hamburger headers` (M1 + M2 + M6-header).
|
||||
1. `cd897d6 feat(mobile): single-pane stack + long-press tab menu + swipe-to-close` (M3 + M4 + A2).
|
||||
1. `273eeac feat(mobile): chat input keybinds + safe-area + tap targets + overflow safety` (M5 + M6-bottom + M7 + M8).
|
||||
1. `4b5b9b2 feat(mobile): pull-to-refresh sidebar list` (A1).
|
||||
1. `943ae7d docs: add v1.x roadmap snapshot` (this file).
|
||||
|
||||
**Decisions:**
|
||||
|
||||
@@ -168,21 +170,41 @@ Dockerfile (git installed in container), docker-compose.yml, project_bootstrap.t
|
||||
|
||||
-----
|
||||
|
||||
### v1.6.1-cleanup — Stale + overengineering audit + secrets hygiene (next)
|
||||
### v1.6.1-cleanup ✅ (`6a9fe18`)
|
||||
|
||||
**Depends on:** v1.6 committed.
|
||||
**Shipped:** RightRail wrapped in `<div className="max-md:hidden contents">` so it's hidden entirely below the md breakpoint on mobile. (Note: v1.6.2 reverses this and replaces with a proper mobile drawer — see below.)
|
||||
|
||||
**Scope:**
|
||||
**Audited but not shipped (queued for follow-ups):**
|
||||
|
||||
1. RightRail mobile fix (`max-md:hidden` on outer container).
|
||||
1. Secrets audit: rotate `secrets/boocode_gitea`, confirm `.gitignore` covers `secrets/`, scan git history (`git log --all -- secrets/`), `git filter-repo` or BFG if exposed in history, force-push if rewriting.
|
||||
1. Fix agent SSH key path so future Claude Code dispatches don’t fall back to in-repo keys.
|
||||
1. Stale code audit: pruning unused exports, dead WS frames (e.g. `session_renamed` server publisher TODO from Batch 1), backup `.bak` files, unused imports.
|
||||
1. Overengineering audit: places where hand-rolled patterns are more complex than necessary, places where singleton hooks should consolidate (`useSessionStream` refcount).
|
||||
1. PATCH `/api/panes/:id` session-ownership check tightening.
|
||||
1. `/opt:/opt:ro` mount whitelist tightening (precursor to BooCoder).
|
||||
- **Secrets hygiene:** `secrets/boocode_gitea` is NOT tracked; never committed to any branch; `.gitignore` already covers `secrets/`. Rotation is a Gitea-side action, no repo change needed.
|
||||
- **`.bak` files:** 3 leftover from v1.5.1 (`docker-compose.yml.bak-20260516`, `Dockerfile.bak-20260516`, `apps/web/src/components/CreateProjectModal.tsx.bak-20260516`). Git-invisible via global `~/.gitignore_global` (`*.bak*`). Decide per file.
|
||||
- **Unused exports:** neither `knip` nor `ts-prune` installed. Proposal pending.
|
||||
- **Dead WS frames:** `session_renamed` HAS a server publisher (`routes/sessions.ts:140`, added in v1.4) — the roadmap's "no server publisher" open item is **STALE**, crossed off. The `InferenceFrame` union still declares `session_renamed` as a type variant but no code publishes it on the per-session channel; trivial 1-line cleanup deferred.
|
||||
- **Unused imports:** web `tsc --noUnusedLocals --noUnusedParameters` returns 0 warnings.
|
||||
- **`useSessionStream` refcount:** opportunity confirmed (~90 lines diff to apply the `useSidebar`-style module-scope singleton pattern). Risk LOW. Queued for v1.6.2 or later.
|
||||
- **PATCH `/api/panes/:id` ownership:** **MOOT** — endpoint does not exist (the pane REST API was never re-introduced after pane state moved to client-side localStorage in v1.2). Crossed off open items.
|
||||
- **Hand-rolled patterns vs library:** 5 hand-rolled hooks/components total 336 lines. None duplicates anything in existing deps; library swap (`@use-gesture`, `react-pull-to-refresh`) not worth the dep cost yet.
|
||||
- **`/opt:/opt:ro` mount tightening:** Two-option plan documented for v1.6.2 — Option A (per-project bind-mounts) or Option B (deny `.env` pattern in `pathGuard`). Option B is the simpler short-term fix.
|
||||
|
||||
**No new features. No schema changes.**
|
||||
-----
|
||||
|
||||
### v1.6.2-mobile-ui-fixes 🔄
|
||||
|
||||
**Hand-back received, uncommitted on `v1.6.2-mobile-ui-fixes`.** 4-commit sequence proposed:
|
||||
|
||||
1. `fix(mobile): hide Split button + single-pane navigator chrome` (G1 — wrap the Workspace Split row in `!isMobile`).
|
||||
1. `feat(mobile): rework Session and Project headers for narrow viewports` (G2 — breadcrumb `hidden sm:flex`, session name cap `max-w-[140px] sm:max-w-[280px]`, project page heading `text-base sm:text-lg`, “New session” icon-only on mobile).
|
||||
1. `feat(mobile): add "New chat" to tab long-press context menu` (G3 — top of menu, separator, then existing items).
|
||||
1. `feat(mobile): right-rail as drawer on mobile, header toggle button` (G4 option b — new `useRightRailDrawer` Context hook, `RightRail` renders as fixed `w-[85vw] max-w-sm` drawer on mobile, FolderTree button in Session header, **reverts v1.6.1's `max-md:hidden` wrapper**).
|
||||
|
||||
**Decisions:**
|
||||
|
||||
- G4 option b chosen: mobile file browsing IS useful; drawer pattern mirrors `useSidebarDrawer`.
|
||||
- G2 single-row session-name+model layout (model picker right-aligned), per spec example.
|
||||
- G3 "New chat" at top, separator, then Rename.
|
||||
- G2 "New session" button: icon-only on mobile via `<span className="hidden sm:inline">New session</span>`.
|
||||
|
||||
**Adjacent uncommitted change (not part of v1.6.2):** `MAX_TOOL_LOOP_DEPTH 5 → 15` in `apps/server/src/services/inference.ts`. Sam-authored, sitting in working tree on `v1.6.2-mobile-ui-fixes`. **NOT on main as of this update.** Commit separately.
|
||||
|
||||
-----
|
||||
|
||||
@@ -407,14 +429,17 @@ settings
|
||||
|
||||
## Known open items
|
||||
|
||||
- **`useSessionStream` refcount.** Two ChatPanes = two WS. Apply singleton pattern. Tracked in v1.6.1.
|
||||
- **PATCH `/api/panes/:id` lacks session-ownership check.** Single-user fine; tighten in v1.6.1.
|
||||
- **`/opt:/opt:ro` mount exposes all `.env` files.** Whitelist scope before BooCoder. Tracked in v1.6.1.
|
||||
- **`session_renamed` no server WS publisher.** Carried from Batch 2. Tracked in v1.6.1.
|
||||
- **`secrets/boocode_gitea` in repo.** v1.5.1 dispatch fallback. Rotation + history scrub in v1.6.1.
|
||||
- **`useSessionStream` refcount.** Two ChatPanes = two WS. Apply singleton pattern. Audited in v1.6.1, queued.
|
||||
- **`/opt:/opt:ro` mount exposes all `.env` files.** Whitelist scope before BooCoder. Two-option plan documented in v1.6.1 audit; ship in v1.6.2 or v1.7.
|
||||
- **`secrets/boocode_gitea` in repo working tree.** Never committed (git-invisible via global ignore). Rotate the Gitea-side key when convenient; no repo action required.
|
||||
- **Dormant in-boolab BooCode mode.** Reference only.
|
||||
- **BooCoder container.** Post-v1.x.
|
||||
|
||||
**Closed since last update:**
|
||||
|
||||
- ~~`session_renamed` no server WS publisher~~ — server publishes via `broker.publishUser` from `routes/sessions.ts:140` (added in v1.4). Confirmed in v1.6.1 audit.
|
||||
- ~~PATCH `/api/panes/:id` lacks session-ownership check~~ — endpoint does not exist; the pane REST API was never re-introduced after v1.2 moved pane state to localStorage.
|
||||
|
||||
-----
|
||||
|
||||
## Dependency graph
|
||||
@@ -456,7 +481,10 @@ v1.5.1 (bootstrap hotfix) │
|
||||
v1.6-mobile-pass │
|
||||
│ │
|
||||
▼ │
|
||||
v1.6.1-cleanup ◄─────────────┘
|
||||
v1.6.1-cleanup │
|
||||
│ │
|
||||
▼ │
|
||||
v1.6.2-mobile-ui-fixes ◄─────┘
|
||||
│
|
||||
▼
|
||||
v1.7 (drag-drop) ◄── v1.1-batch3.5
|
||||
|
||||
Reference in New Issue
Block a user