Compare commits

..

1 Commits

13 changed files with 331 additions and 266 deletions

View File

@@ -144,23 +144,4 @@ export async function maybeAutoNameChat(
updated_at: updated[0]!.updated_at, updated_at: updated[0]!.updated_at,
}); });
ctx.log.info({ chatId, name }, 'chat auto-named'); 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.`; `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 DB_FLUSH_INTERVAL_MS = 500;
const MAX_TOOL_LOOP_DEPTH = 15; const MAX_TOOL_LOOP_DEPTH = 5;
export interface InferenceFrame { export interface InferenceFrame {
type: type:

View File

@@ -9,7 +9,6 @@ import { Session } from '@/pages/Session';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { useUserEvents } from '@/hooks/useUserEvents'; import { useUserEvents } from '@/hooks/useUserEvents';
import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer'; import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { RightRailDrawerProvider, useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport'; import { useViewport } from '@/hooks/useViewport';
function SessionRightRail() { function SessionRightRail() {
@@ -27,11 +26,13 @@ function RightRailForSession({ sessionId }: { sessionId: string }) {
.catch((err) => console.warn('RightRail: failed to fetch session', err)); .catch((err) => console.warn('RightRail: failed to fetch session', err));
}, [sessionId]); }, [sessionId]);
if (!projectId) return null; if (!projectId) return null;
// v1.6.2: rendered on all viewports. On mobile, RightRail itself renders as // Hidden entirely below md breakpoint; mobile users get the file browser
// a right-side drawer toggled by the header's FolderTree button (via // via the FileBrowserPane infrastructure if/when it lands in workspace panes.
// useRightRailDrawer). On desktop, it renders inline as before with its return (
// own internal open/close state. <div className="max-md:hidden contents">
return <RightRail projectId={projectId} />; <RightRail projectId={projectId} />
</div>
);
} }
function MobileBackdrop() { function MobileBackdrop() {
@@ -47,19 +48,6 @@ 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() { function AppShell() {
useUserEvents(); useUserEvents();
return ( return (
@@ -73,7 +61,6 @@ function AppShell() {
<Route path="/session/:id" element={<Session />} /> <Route path="/session/:id" element={<Session />} />
</Routes> </Routes>
</main> </main>
<MobileRightRailBackdrop />
<Routes> <Routes>
<Route path="/session/:id" element={<SessionRightRail />} /> <Route path="/session/:id" element={<SessionRightRail />} />
</Routes> </Routes>
@@ -86,9 +73,7 @@ export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<SidebarDrawerProvider> <SidebarDrawerProvider>
<RightRailDrawerProvider> <AppShell />
<AppShell />
</RightRailDrawerProvider>
</SidebarDrawerProvider> </SidebarDrawerProvider>
</BrowserRouter> </BrowserRouter>
); );

View File

@@ -1,16 +1,26 @@
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react'; import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
import { Send } from 'lucide-react'; import { Send } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { flattenToMessage, inferLanguage, type Attachment } from '@/lib/attachments'; import {
flattenToMessage,
inferLanguage,
looksBinary,
MAX_FILE_SIZE_BYTES,
PASTE_INLINE_MAX_LINES,
type Attachment,
} from '@/lib/attachments';
import { AttachmentChip } from '@/components/AttachmentChip'; import { AttachmentChip } from '@/components/AttachmentChip';
import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal'; import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
import { FileMentionPopover } from '@/components/FileMentionPopover'; import { FileMentionPopover } from '@/components/FileMentionPopover';
import { DropOverlay } from '@/components/DropOverlay';
import { api } from '@/api/client'; import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { useViewport } from '@/hooks/useViewport'; import { useViewport } from '@/hooks/useViewport';
const MAX_ATTACHMENTS = 10;
interface Props { interface Props {
disabled?: boolean; disabled?: boolean;
projectId: string; projectId: string;
@@ -24,6 +34,9 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]); const [attachments, setAttachments] = useState<Attachment[]>([]);
const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null); 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<{ const [mentionState, setMentionState] = useState<{
open: boolean; open: boolean;
query: string; query: string;
@@ -35,8 +48,8 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
function addAttachment(a: Attachment) { function addAttachment(a: Attachment) {
setAttachments(prev => { setAttachments(prev => {
if (prev.length >= 10) { if (prev.length >= MAX_ATTACHMENTS) {
toast.error('Max 10 attachments per message'); toast.error(`Max ${MAX_ATTACHMENTS} attachments per message`);
return prev; return prev;
} }
return [...prev, a]; return [...prev, a];
@@ -185,6 +198,162 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
const closeMention = useCallback(() => setMentionState(null), []); 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>) { function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (mentionState?.open) return; if (mentionState?.open) return;
// IME safety: never act on Enter while an IME composition is in flight // IME safety: never act on Enter while an IME composition is in flight
@@ -228,7 +397,16 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
} }
return ( return (
<div className="border-t" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}> <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="max-w-[1000px] mx-auto w-full"> <div className="max-w-[1000px] mx-auto w-full">
{attachments.length > 0 && ( {attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 pt-3"> <div className="flex flex-wrap gap-1.5 px-4 pt-3">
@@ -248,6 +426,7 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
value={value} value={value}
onChange={handleChange} onChange={handleChange}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onPaste={onPaste}
placeholder={ placeholder={
isMobile isMobile
? 'Ask about this project. Tap send to submit.' ? 'Ask about this project. Tap send to submit.'

View File

@@ -123,10 +123,6 @@ export function ChatTabBar({
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<ContextMenuItem onSelect={() => onNewChat()}>
New chat
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}> <ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
Rename Rename
</ContextMenuItem> </ContextMenuItem>

View File

@@ -0,0 +1,18 @@
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,11 +4,8 @@ import { api } from '@/api/client';
import type { FileEntry } from '@/api/types'; import type { FileEntry } from '@/api/types';
import { inferLanguage } from '@/lib/attachments'; import { inferLanguage } from '@/lib/attachments';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport';
import { FileViewerOverlay } from '@/components/FileViewerOverlay'; import { FileViewerOverlay } from '@/components/FileViewerOverlay';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
interface Props { interface Props {
projectId: string; projectId: string;
@@ -28,8 +25,6 @@ function joinPath(parent: string, name: string): string {
} }
export function RightRail({ projectId }: Props) { export function RightRail({ projectId }: Props) {
const { isMobile } = useViewport();
const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer();
const [open, setOpen] = useState(() => { const [open, setOpen] = useState(() => {
try { return localStorage.getItem(`${STORAGE_KEY}.open`) !== 'false'; } catch { return true; } try { return localStorage.getItem(`${STORAGE_KEY}.open`) !== 'false'; } catch { return true; }
}); });
@@ -39,19 +34,6 @@ export function RightRail({ projectId }: Props) {
const [fullFileList, setFullFileList] = useState<string[] | null>(null); const [fullFileList, setFullFileList] = useState<string[] | null>(null);
const [viewerFile, setViewerFile] = useState<{ path: string; content: 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(() => { useEffect(() => {
// best-effort; ignore failure because localStorage may be unavailable (quota, private mode) // best-effort; ignore failure because localStorage may be unavailable (quota, private mode)
try { localStorage.setItem(`${STORAGE_KEY}.open`, String(open)); } catch {} try { localStorage.setItem(`${STORAGE_KEY}.open`, String(open)); } catch {}
@@ -74,9 +56,9 @@ export function RightRail({ projectId }: Props) {
}, [projectId]); }, [projectId]);
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!open) return;
if (!cache.has('')) void loadDir(''); if (!cache.has('')) void loadDir('');
}, [isOpen, cache, loadDir]); }, [open, cache, loadDir]);
function toggleDir(dirPath: string) { function toggleDir(dirPath: string) {
setExpandedDirs((prev) => { setExpandedDirs((prev) => {
@@ -126,14 +108,12 @@ export function RightRail({ projectId }: Props) {
useEffect(() => { useEffect(() => {
return sessionEvents.subscribe((event) => { return sessionEvents.subscribe((event) => {
if (event.type !== 'open_file_in_browser') return; if (event.type !== 'open_file_in_browser') return;
if (!isOpen) openRail(); if (!open) setOpen(true);
void openFile(event.path); void openFile(event.path);
}); });
}, [isOpen, openRail, projectId]); }, [open, projectId]);
// Desktop closed state: render the floating chevron handle. Mobile never if (!open) {
// shows the handle — the toggle lives in the Session header on mobile.
if (!isMobile && !open) {
return ( return (
<button <button
type="button" type="button"
@@ -148,25 +128,15 @@ export function RightRail({ projectId }: Props) {
const rootEntries = cache.get('') ?? []; 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 ( return (
<> <>
<aside className={asideCls}> <aside className="w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0"> <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> <span className="text-xs font-medium flex-1">Files</span>
<button <button
type="button" type="button"
onClick={closeRail} onClick={() => setOpen(false)}
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]" className="p-1 rounded hover:bg-muted text-muted-foreground"
aria-label="Close file browser" aria-label="Close file browser"
> >
<PanelRightClose size={14} /> <PanelRightClose size={14} />

View File

@@ -125,36 +125,34 @@ export function Workspace({ sessionId, projectId }: Props) {
return ( return (
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
{!isMobile && ( <div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0"> <DropdownMenu>
<DropdownMenu> <DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild> <button
<button type="button"
type="button" disabled={panes.length >= MAX_PANES}
disabled={panes.length >= MAX_PANES} className={cn(
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',
'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'
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent' )}
)} >
> <PanelRight size={14} />
<PanelRight size={14} /> Split
Split </button>
</button> </DropdownMenuTrigger>
</DropdownMenuTrigger> <DropdownMenuContent>
<DropdownMenuContent> <DropdownMenuItem onSelect={() => addSplitPane('chat')}>
<DropdownMenuItem onSelect={() => addSplitPane('chat')}> <MessageSquare size={14} /> Chat
<MessageSquare size={14} /> Chat </DropdownMenuItem>
</DropdownMenuItem> <DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}> <Terminal size={14} /> Terminal
<Terminal size={14} /> Terminal </DropdownMenuItem>
</DropdownMenuItem> <DropdownMenuItem onSelect={() => addSplitPane('agent')}>
<DropdownMenuItem onSelect={() => addSplitPane('agent')}> <Bot size={14} /> Agent
<Bot size={14} /> Agent </DropdownMenuItem>
</DropdownMenuItem> </DropdownMenuContent>
</DropdownMenuContent> </DropdownMenu>
</DropdownMenu> </div>
</div>
)}
{isMobile && panes.length > 1 && ( {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"> <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

@@ -1,35 +0,0 @@
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,6 +8,34 @@ export type Attachment = {
source: '@' | 'line-select' | 'drop' | 'paste'; 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> = { export const LANG_MAP: Record<string, string> = {
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx', ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
mjs: 'javascript', cjs: 'javascript', mjs: 'javascript', cjs: 'javascript',

View File

@@ -81,32 +81,32 @@ export function Project() {
return ( return (
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<header <header
className="border-b px-3 sm:px-6 py-2 sm:py-3 flex items-center justify-between gap-2" className="border-b px-6 py-3 flex items-center justify-between gap-2"
style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }} style={{ paddingTop: 'max(0.75rem, env(safe-area-inset-top))' }}
> >
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
{isMobile && ( {isMobile && (
<button <button
type="button" type="button"
onClick={() => setDrawerOpen(true)} onClick={() => setDrawerOpen(true)}
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" 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"
aria-label="Open sidebar" aria-label="Open sidebar"
> >
<Menu className="size-5" /> <Menu className="size-5" />
</button> </button>
)} )}
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-base sm:text-lg font-semibold tracking-tight truncate"> <h1 className="text-lg font-semibold tracking-tight truncate">
{project?.name ?? '…'} {project?.name ?? '…'}
</h1> </h1>
<div className="text-xs text-muted-foreground font-mono truncate hidden sm:block"> <div className="text-xs text-muted-foreground font-mono truncate">
{project?.path} {project?.path}
</div> </div>
</div> </div>
</div> </div>
<Button onClick={handleNew} disabled={creating} className="shrink-0" aria-label="New session"> <Button onClick={handleNew} disabled={creating} className="shrink-0">
<Plus /> <Plus />
<span className="hidden sm:inline">New session</span> New session
</Button> </Button>
</header> </header>

View File

@@ -1,12 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom'; import { Link, useNavigate, useParams } from 'react-router-dom';
import { ChevronRight, FolderTree, Menu } from 'lucide-react'; import { ChevronRight, Menu } from 'lucide-react';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { Project, Session as SessionType } from '@/api/types'; import type { Project, Session as SessionType } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { useActivePane } from '@/hooks/useActivePane'; import { useActivePane } from '@/hooks/useActivePane';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer'; import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport'; import { useViewport } from '@/hooks/useViewport';
import { Workspace } from '@/components/Workspace'; import { Workspace } from '@/components/Workspace';
import { ModelPicker } from '@/components/ModelPicker'; import { ModelPicker } from '@/components/ModelPicker';
@@ -20,7 +19,6 @@ export function Session() {
const [editingName, setEditingName] = useState(false); const [editingName, setEditingName] = useState(false);
const active = useActivePane(); const active = useActivePane();
const { setOpen: setDrawerOpen } = useSidebarDrawer(); const { setOpen: setDrawerOpen } = useSidebarDrawer();
const { toggle: toggleRightRail } = useRightRailDrawer();
const { isMobile } = useViewport(); const { isMobile } = useViewport();
useEffect(() => { useEffect(() => {
@@ -89,42 +87,33 @@ export function Session() {
return ( return (
<div className="flex-1 flex flex-col min-h-0"> <div className="flex-1 flex flex-col min-h-0">
<header <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))' }}>
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 && ( {isMobile && (
<button <button
type="button" type="button"
onClick={() => setDrawerOpen(true)} onClick={() => setDrawerOpen(true)}
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" 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"
aria-label="Open sidebar" aria-label="Open sidebar"
> >
<Menu className="size-5" /> <Menu className="size-5" />
</button> </button>
)} )}
<Link to="/" className="text-muted-foreground hover:text-foreground">
{/* Breadcrumb — desktop only */} Projects
<div className="hidden sm:flex items-center gap-1.5 min-w-0"> </Link>
<Link to="/" className="text-muted-foreground hover:text-foreground shrink-0 text-xs"> <ChevronRight className="size-3 text-muted-foreground/60" />
Projects {project ? (
<Link
to={`/project/${project.id}`}
className="text-muted-foreground hover:text-foreground truncate max-w-[200px]"
title={project.name}
>
{project.name}
</Link> </Link>
<ChevronRight className="size-3 text-muted-foreground/60 shrink-0" /> ) : (
{project ? ( <span className="text-muted-foreground/60"></span>
<Link )}
to={`/project/${project.id}`} <ChevronRight className="size-3 text-muted-foreground/60" />
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 ? ( {editingName ? (
<input <input
autoFocus autoFocus
@@ -138,34 +127,30 @@ export function Session() {
setEditingName(false); setEditingName(false);
} }
}} }}
className="bg-transparent border-b border-border px-1 py-0.5 text-sm font-medium outline-none focus:border-ring min-w-0" className="bg-transparent border-b border-border px-1 py-0.5 text-sm font-medium outline-none focus:border-ring"
/> />
) : ( ) : (
<button <button
type="button" type="button"
className="text-sm font-medium hover:underline truncate max-w-[140px] sm:max-w-[280px] min-w-0" className="text-sm font-medium hover:underline truncate max-w-[280px]"
onClick={() => setEditingName(true)} onClick={() => setEditingName(true)}
title={session?.name ?? ''} title={session?.name ?? ''}
> >
{session?.name ?? '…'} {session?.name ?? '…'}
</button> </button>
)} )}
{/* Active file — desktop only */}
{showActiveFile && active.activeFile && ( {showActiveFile && active.activeFile && (
<> <>
<span className="text-muted-foreground/40 mx-1 hidden sm:inline">·</span> <span className="text-muted-foreground/40 mx-1">·</span>
<span <span
className="text-xs font-mono text-muted-foreground truncate max-w-[200px] hidden sm:inline" className="text-xs font-mono text-muted-foreground truncate max-w-[320px]"
title={active.activeFile} title={active.activeFile}
> >
{active.activeFile} {active.activeFile}
</span> </span>
</> </>
)} )}
<div className="ml-auto">
{/* Model picker — right-aligned */}
<div className="ml-auto shrink-0">
{session && ( {session && (
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1"> <div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
<ModelPicker <ModelPicker
@@ -178,18 +163,6 @@ export function Session() {
</div> </div>
)} )}
</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> </header>
{id && session && ( {id && session && (

View File

@@ -27,9 +27,8 @@ 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.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 |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.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|✅ Merged |`57c883b..943ae7d` (6 commits) | |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 |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.1-cleanup |Stale code audit, overengineering audit, secrets hygiene, RightRail mobile fix |Planned (next) | |
|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.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.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 | |v1.9 |Web search backend: SearXNG `web_search` + `web_fetch` tools |Planned |Was Batch 8 |
@@ -140,16 +139,15 @@ Dockerfile (git installed in container), docker-compose.yml, project_bootstrap.t
----- -----
### v1.6-mobile-pass ### v1.6-mobile-pass 🔄
**Merged via 6 commits `57c883b..943ae7d`** (5 functional + 1 docs): **Hand-back received, uncommitted on `v1.6-mobile-pass`.** 5-commit sequence proposed:
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. `chore: fix resolveProjectPath whitelist-root bypass` (H1 — dropped `real !== whitelistReal` short-circuit; 23/23 pass).
1. `a643b5f feat(mobile): viewport hook + sidebar drawer + hamburger headers` (M1 + M2 + M6-header). 1. `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. `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. `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. `feat(mobile): pull-to-refresh sidebar list` (A1).
1. `943ae7d docs: add v1.x roadmap snapshot` (this file).
**Decisions:** **Decisions:**
@@ -170,41 +168,21 @@ Dockerfile (git installed in container), docker-compose.yml, project_bootstrap.t
----- -----
### v1.6.1-cleanup ✅ (`6a9fe18`) ### v1.6.1-cleanup — Stale + overengineering audit + secrets hygiene (next)
**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.) **Depends on:** v1.6 committed.
**Audited but not shipped (queued for follow-ups):** **Scope:**
- **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. 1. RightRail mobile fix (`max-md:hidden` on outer container).
- **`.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. 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.
- **Unused exports:** neither `knip` nor `ts-prune` installed. Proposal pending. 1. Fix agent SSH key path so future Claude Code dispatches dont fall back to in-repo keys.
- **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. 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.
- **Unused imports:** web `tsc --noUnusedLocals --noUnusedParameters` returns 0 warnings. 1. Overengineering audit: places where hand-rolled patterns are more complex than necessary, places where singleton hooks should consolidate (`useSessionStream` refcount).
- **`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. 1. PATCH `/api/panes/:id` session-ownership check tightening.
- **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. 1. `/opt:/opt:ro` mount whitelist tightening (precursor to BooCoder).
- **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.
----- -----
@@ -429,17 +407,14 @@ settings
## Known open items ## Known open items
- **`useSessionStream` refcount.** Two ChatPanes = two WS. Apply singleton pattern. Audited in v1.6.1, queued. - **`useSessionStream` refcount.** Two ChatPanes = two WS. Apply singleton pattern. Tracked in v1.6.1.
- **`/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. - **PATCH `/api/panes/:id` lacks session-ownership check.** Single-user fine; tighten in v1.6.1.
- **`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. - **`/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.
- **Dormant in-boolab BooCode mode.** Reference only. - **Dormant in-boolab BooCode mode.** Reference only.
- **BooCoder container.** Post-v1.x. - **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 ## Dependency graph
@@ -481,10 +456,7 @@ v1.5.1 (bootstrap hotfix) │
v1.6-mobile-pass │ 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 v1.7 (drag-drop) ◄── v1.1-batch3.5