Compare commits

..

7 Commits

Author SHA1 Message Date
5ee266a4d9 feat(auto_name): propagate first chat name to parent session
When a chat is auto-named, also rename the parent session if it is
still on its default 'New session' label. UPDATE is gated by an
atomic WHERE clause so user renames and prior propagations are not
clobbered. Publishes session_renamed via broker.publishUser; useSidebar
already listens.

Closes the gap where sessions auto-created from the sidebar would
stay 'New session' forever.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:23:11 +00:00
bbf9fac936 docs(roadmap): reconcile post-v1.6.1 + v1.6.2 in-flight
Update version summary: v1.6-mobile-pass and v1.6.1-cleanup are now
merged with SHAs; v1.6.2-mobile-ui-fixes added as in-flight with its
4-commit plan. v1.6.1-cleanup details rewritten to reflect what
actually shipped (B1) vs what was audited-only (secrets, panes,
unused exports, hand-rolled patterns, mount scope, etc.).

Closed two open items: session_renamed has a server publisher since
v1.4; PATCH /api/panes/:id is moot (endpoint never re-introduced).
Dependency graph updated with v1.6.2 node between v1.6.1 and v1.7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:37:27 +00:00
6fa6eb7f32 feat(inference): raise MAX_TOOL_LOOP_DEPTH from 5 to 15
Allows assistant turns up to 15 tool calls in a single chain before
the loop-depth guard trips. Real chats commonly need 6-10 tool calls
(grep -> view_file -> view_file -> grep -> view_file -> answer); the
old cap of 5 was firing on legitimate investigation patterns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:37:27 +00:00
5932682193 feat(mobile): right-rail as drawer on mobile, header toggle button
Reverts v1.6.1's max-md:hidden wrapper around RightRail. On mobile,
RightRail now renders as a fixed right-side drawer (w-[85vw],
max-w-sm) toggled by a new FolderTree button in the Session header.

- New useRightRailDrawer hook mirrors useSidebarDrawer (Context +
  auto-close on route change).
- New MobileRightRailBackdrop component in App.tsx mirrors the
  existing MobileBackdrop for the left sidebar.
- RightRail computes an isOpen synthesis: on mobile, reads the
  drawer Context; on desktop, reads the persistent internal state.
  The existing tree-load effect and open_file_in_browser
  subscription share this plumbing via openRail / closeRail
  helpers.
- The desktop floating chevron handle is hidden on mobile (the
  Session header's FolderTree button replaces it).
- Session header gains a mobile-only FolderTree button after the
  ModelPicker, calling toggle() on the drawer Context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:37:13 +00:00
9d0d41bcb3 feat(mobile): add "New chat" to tab long-press context menu
With the Split button hidden on mobile (G1), users need another path
to create additional chat panes. Add a "New chat" ContextMenuItem at
the top of each tab's context menu, separated from Rename / Close /
etc. by a ContextMenuSeparator. Wired to the existing onNewChat prop
— no plumbing change. Available on both long-press (mobile) and
right-click (desktop).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:37:03 +00:00
e167f851fd feat(mobile): rework Session and Project headers for narrow viewports
Session header: breadcrumb (Projects > project) wrapped in
hidden sm:flex; active file path hidden on mobile; session name cap
max-w-[140px] sm:max-w-[280px]; padding px-3 sm:px-4. Mobile gets
just hamburger | session name | model pill.

Project header: px-3 sm:px-6, py-2 sm:py-3, heading text-base
sm:text-lg, project path hidden sm:block, "New session" button is
icon-only on mobile via <span className="hidden sm:inline">. Both
headers retain the safe-area-inset-top padding from v1.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:36:36 +00:00
f6c7e12dbf fix(mobile): hide Split button + single-pane navigator chrome
v1.6 left the Workspace's Split-button row visible on mobile even
when only one pane was open — ~36px of dead chrome above the chat.
Wrap the entire Split-row in !isMobile so mobile gets header → chat
with no intermediate strip. The existing mobile pane-navigator strip
(gated to panes.length > 1) is unchanged and still appears once a
second pane is created via the long-press "New chat" menu item (G3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:36:03 +00:00
13 changed files with 266 additions and 331 deletions

View File

@@ -144,4 +144,23 @@ export async function maybeAutoNameChat(
updated_at: updated[0]!.updated_at,
});
ctx.log.info({ chatId, name }, 'chat auto-named');
// Propagate to the parent session if it's still on its default name.
// The WHERE guard makes the check atomic — if the user has already
// renamed (or a prior chat already propagated), this UPDATE matches
// zero rows and we do nothing. First chat wins; manual renames win.
const renamedSession = await ctx.sql<{ id: string; name: string }[]>`
UPDATE sessions
SET name = ${name}
WHERE id = ${sessionId} AND name = 'New session'
RETURNING id, name
`;
if (renamedSession.length > 0) {
ctx.publishUser({
type: 'session_renamed',
session_id: sessionId,
name,
});
ctx.log.info({ sessionId, name }, 'session auto-named from chat');
}
}

View File

@@ -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:

View File

@@ -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>
);

View File

@@ -1,26 +1,16 @@
import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { Send } from 'lucide-react';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import {
flattenToMessage,
inferLanguage,
looksBinary,
MAX_FILE_SIZE_BYTES,
PASTE_INLINE_MAX_LINES,
type Attachment,
} from '@/lib/attachments';
import { flattenToMessage, inferLanguage, type Attachment } from '@/lib/attachments';
import { AttachmentChip } from '@/components/AttachmentChip';
import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
import { FileMentionPopover } from '@/components/FileMentionPopover';
import { DropOverlay } from '@/components/DropOverlay';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useViewport } from '@/hooks/useViewport';
const MAX_ATTACHMENTS = 10;
interface Props {
disabled?: boolean;
projectId: string;
@@ -34,9 +24,6 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
const [busy, setBusy] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null);
const [isDraggingOver, setIsDraggingOver] = useState(false);
const dropRootRef = useRef<HTMLDivElement | null>(null);
const pasteCounterRef = useRef(0);
const [mentionState, setMentionState] = useState<{
open: boolean;
query: string;
@@ -48,8 +35,8 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
function addAttachment(a: Attachment) {
setAttachments(prev => {
if (prev.length >= MAX_ATTACHMENTS) {
toast.error(`Max ${MAX_ATTACHMENTS} attachments per message`);
if (prev.length >= 10) {
toast.error('Max 10 attachments per message');
return prev;
}
return [...prev, a];
@@ -198,162 +185,6 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
const closeMention = useCallback(() => setMentionState(null), []);
// ---- Drag & drop (F1 + F3 + F4) ----------------------------------------
// The drop zone is the outer ChatInput container (ref'd as dropRootRef).
// onDragLeave only clears the highlight when the cursor leaves the
// container, not when it crosses into a child element.
async function processDroppedFile(file: File) {
// Size gate
if (file.size > MAX_FILE_SIZE_BYTES) {
const mb = (file.size / (1024 * 1024)).toFixed(1);
toast.error(`File ${file.name} is too large (${mb} MB). Limit is 5 MB.`);
return;
}
// Read once as ArrayBuffer so we can do byte-level binary detection
// before deciding whether to decode as text.
let buf: ArrayBuffer;
try {
buf = await file.arrayBuffer();
} catch (err) {
toast.error(`Failed to read ${file.name}: ${err instanceof Error ? err.message : String(err)}`);
return;
}
if (looksBinary(buf)) {
toast.error(`${file.name} appears to be binary.`);
return;
}
const text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
addAttachment({
id: crypto.randomUUID(),
kind: 'file',
filename: file.name,
language: inferLanguage(file.name),
content: text,
source: 'drop',
});
}
function isFolderItem(item: DataTransferItem | undefined): boolean {
if (!item) return false;
// webkitGetAsEntry is non-standard but supported in Chromium + Safari.
// If unavailable, we conservatively treat the entry as a file.
const entry =
typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
if (entry && entry.isDirectory) return true;
// Heuristic fallback: folders dragged from Finder have type === '' and
// a 0-byte File. The empty-type alone isn't reliable for files (some
// plaintext drops also lack a type), so we only flag when the entry
// explicitly says directory.
return false;
}
async function handleDroppedItems(dt: DataTransfer) {
// Snapshot items first because reading files inside the loop can
// detach the DataTransfer between awaits.
const itemsArray: { file: File | null; isFolder: boolean }[] = [];
if (dt.items && dt.items.length > 0) {
for (let i = 0; i < dt.items.length; i++) {
const it = dt.items[i];
if (!it || it.kind !== 'file') continue;
const folder = isFolderItem(it);
const file = folder ? null : it.getAsFile();
itemsArray.push({ file, isFolder: folder });
}
} else {
for (let i = 0; i < dt.files.length; i++) {
const f = dt.files[i];
if (f) itemsArray.push({ file: f, isFolder: false });
}
}
let remainingSlots = MAX_ATTACHMENTS - attachments.length;
let folderRejected = false;
for (const { file, isFolder } of itemsArray) {
if (isFolder) {
if (!folderRejected) {
toast.error('Folders are not supported');
folderRejected = true;
}
continue;
}
if (!file) continue;
if (remainingSlots <= 0) {
toast.error(`Attachment limit reached (${MAX_ATTACHMENTS}).`);
return;
}
await processDroppedFile(file);
remainingSlots -= 1;
}
}
function onDragEnter(e: DragEvent<HTMLDivElement>) {
if (disabled || busy) return;
e.preventDefault();
setIsDraggingOver(true);
}
function onDragOver(e: DragEvent<HTMLDivElement>) {
if (disabled || busy) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
function onDragLeave(e: DragEvent<HTMLDivElement>) {
// Only clear when the cursor actually leaves the root container.
// relatedTarget is the element being entered; if it's inside the root,
// ignore — we're just crossing into a child.
const root = dropRootRef.current;
if (!root) return;
const related = e.relatedTarget as Node | null;
if (related && root.contains(related)) return;
setIsDraggingOver(false);
}
function onDrop(e: DragEvent<HTMLDivElement>) {
e.preventDefault();
setIsDraggingOver(false);
if (disabled || busy) return;
void handleDroppedItems(e.dataTransfer);
}
// ---- end Drag & drop -----------------------------------------------------
// ---- Paste-as-attachment (F2) -------------------------------------------
// Pasting >PASTE_INLINE_MAX_LINES lines of text becomes a chip rather than
// inline content. Image pastes are rejected with a toast. If both text and
// image are present (e.g. screenshot tool that sets both), prefer text.
function onPaste(e: React.ClipboardEvent<HTMLTextAreaElement>) {
const cd = e.clipboardData;
if (!cd) return;
const text = cd.getData('text/plain');
const hasImage = Array.from(cd.items ?? []).some((it) =>
it.type.startsWith('image/'),
);
if (text) {
const lineCount = text.split('\n').length;
if (lineCount > PASTE_INLINE_MAX_LINES) {
e.preventDefault();
pasteCounterRef.current += 1;
addAttachment({
id: crypto.randomUUID(),
kind: 'paste',
filename: `pasted-${pasteCounterRef.current}.txt`,
language: 'plaintext',
content: text,
source: 'paste',
});
}
// <= threshold: let default paste insert inline.
return;
}
if (hasImage) {
e.preventDefault();
toast.error('Image paste is not supported. Drop a file or paste text.');
}
}
// ---- end Paste-as-attachment --------------------------------------------
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (mentionState?.open) return;
// IME safety: never act on Enter while an IME composition is in flight
@@ -397,16 +228,7 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
}
return (
<div
ref={dropRootRef}
className="border-t relative"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<DropOverlay visible={isDraggingOver} />
<div className="border-t" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
<div className="max-w-[1000px] mx-auto w-full">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
@@ -426,7 +248,6 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
value={value}
onChange={handleChange}
onKeyDown={onKeyDown}
onPaste={onPaste}
placeholder={
isMobile
? 'Ask about this project. Tap send to submit.'

View File

@@ -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>

View File

@@ -1,18 +0,0 @@
interface Props {
visible: boolean;
}
// Visual cue layered over the ChatInput while a drag is in progress.
// Pointer-events: none so the underlying drop handler still receives the
// drop event. Renders nothing when not visible (cheap and out of layout).
export function DropOverlay({ visible }: Props) {
if (!visible) return null;
return (
<div
className="absolute inset-0 z-10 pointer-events-none flex items-center justify-center rounded border-2 border-dashed border-primary bg-background/85"
aria-hidden="true"
>
<div className="text-sm font-medium text-primary">Drop to attach</div>
</div>
);
}

View File

@@ -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} />

View File

@@ -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">

View 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;
}

View File

@@ -8,34 +8,6 @@ export type Attachment = {
source: '@' | 'line-select' | 'drop' | 'paste';
};
// v1.7: caps shared between drag-drop and paste-as-attachment so both paths
// reject the same way. Match the existing 10-attachment cap in
// ChatInput.addAttachment.
export const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
export const PASTE_INLINE_MAX_LINES = 8;
// First-8KB null-byte scan. Returns true if the content looks binary.
// Accepts a string (post-decode), an ArrayBuffer (pre-decode), or a Uint8Array.
// For binary files like PNG, scanning bytes is more reliable than scanning
// post-UTF-8-decode strings because invalid sequences may be replaced rather
// than preserved.
export function looksBinary(content: string | ArrayBuffer | Uint8Array): boolean {
const SCAN_BYTES = 8192;
if (typeof content === 'string') {
const max = Math.min(content.length, SCAN_BYTES);
for (let i = 0; i < max; i++) {
if (content.charCodeAt(i) === 0) return true;
}
return false;
}
const bytes = content instanceof Uint8Array ? content : new Uint8Array(content);
const max = Math.min(bytes.length, SCAN_BYTES);
for (let i = 0; i < max; i++) {
if (bytes[i] === 0) return true;
}
return false;
}
export const LANG_MAP: Record<string, string> = {
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
mjs: 'javascript', cjs: 'javascript',

View File

@@ -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>

View File

@@ -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 && (

View File

@@ -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 dont 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