Compare commits
8 Commits
v1.5.1-boo
...
v1.7-drag-
| Author | SHA1 | Date | |
|---|---|---|---|
| 50a756aca1 | |||
| 6a9fe187bd | |||
| 943ae7df03 | |||
| 4b5b9b2cb3 | |||
| 273eeac68c | |||
| cd897d6893 | |||
| a643b5f67f | |||
| 57c883b775 |
@@ -71,22 +71,14 @@ describe('resolveProjectPath', () => {
|
||||
expect(result.error.toLowerCase()).toContain('path must be under');
|
||||
});
|
||||
|
||||
it('BEHAVIOR GAP: currently accepts the whitelist itself as a project root', async () => {
|
||||
// SPEC says: the whitelist directory itself should be rejected — a
|
||||
// project's parent can't be the project. The current implementation does
|
||||
// NOT enforce this: the scope check is
|
||||
// if (real !== whitelistReal && !real.startsWith(whitelistReal + sep))
|
||||
// which evaluates to false when real === whitelistReal, so the whitelist
|
||||
// path falls through and is accepted as a valid project root.
|
||||
//
|
||||
// This test documents the ACTUAL current behavior. Reported as a bug in
|
||||
// the harness report; not silently fixed here. To tighten the check,
|
||||
// change the condition to:
|
||||
// if (!real.startsWith(whitelistReal + sep))
|
||||
it('rejects the whitelist directory itself as a project root', async () => {
|
||||
// A project's parent can't be the project. The scope check must require
|
||||
// the candidate path to be strictly below the whitelist (whitelist + sep
|
||||
// prefix), not just equal to it.
|
||||
const result = await resolveProjectPath(whitelist, whitelist);
|
||||
expect('error' in result).toBe(false);
|
||||
if ('error' in result) return;
|
||||
expect(result.real).toBe(whitelist);
|
||||
expect('error' in result).toBe(true);
|
||||
if (!('error' in result)) return;
|
||||
expect(result.error.toLowerCase()).toContain('path must be under');
|
||||
});
|
||||
|
||||
it('rejects non-directory targets (file under whitelist)', async () => {
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function resolveProjectPath(
|
||||
return { error: 'path does not exist' };
|
||||
}
|
||||
const whitelistReal = await realpath(whitelist);
|
||||
if (real !== whitelistReal && !real.startsWith(whitelistReal + sep)) {
|
||||
if (!real.startsWith(whitelistReal + sep)) {
|
||||
return { error: `path must be under ${whitelist}` };
|
||||
}
|
||||
if (!(await isDir(real))) return { error: 'path is not a directory' };
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Project } from '@/pages/Project';
|
||||
import { Session } from '@/pages/Session';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { useUserEvents } from '@/hooks/useUserEvents';
|
||||
import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
|
||||
function SessionRightRail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -24,7 +26,26 @@ function RightRailForSession({ sessionId }: { sessionId: string }) {
|
||||
.catch((err) => console.warn('RightRail: failed to fetch session', err));
|
||||
}, [sessionId]);
|
||||
if (!projectId) return null;
|
||||
return <RightRail projectId={projectId} />;
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileBackdrop() {
|
||||
const { open, setOpen } = useSidebarDrawer();
|
||||
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() {
|
||||
@@ -32,6 +53,7 @@ function AppShell() {
|
||||
return (
|
||||
<div className="dark h-screen flex bg-background text-foreground">
|
||||
<ProjectSidebar />
|
||||
<MobileBackdrop />
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
@@ -50,7 +72,9 @@ function AppShell() {
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppShell />
|
||||
<SidebarDrawerProvider>
|
||||
<AppShell />
|
||||
</SidebarDrawerProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
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 { toast } from 'sonner';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
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 { 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;
|
||||
@@ -18,10 +29,14 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
||||
const { isMobile } = useViewport();
|
||||
const [value, setValue] = useState('');
|
||||
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;
|
||||
@@ -33,8 +48,8 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
||||
|
||||
function addAttachment(a: Attachment) {
|
||||
setAttachments(prev => {
|
||||
if (prev.length >= 10) {
|
||||
toast.error('Max 10 attachments per message');
|
||||
if (prev.length >= MAX_ATTACHMENTS) {
|
||||
toast.error(`Max ${MAX_ATTACHMENTS} attachments per message`);
|
||||
return prev;
|
||||
}
|
||||
return [...prev, a];
|
||||
@@ -183,8 +198,169 @@ 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
|
||||
// (CJK input methods commit composition via Enter). Without this, the
|
||||
// first Enter of a Japanese/Chinese/Korean composition would submit
|
||||
// instead of finalizing the candidate.
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
if (e.key === 'Enter' && e.shiftKey && (e.metaKey || e.ctrlKey) && onForceSend) {
|
||||
e.preventDefault();
|
||||
void forceSubmit();
|
||||
@@ -195,7 +371,9 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
||||
void submit();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
// Bare Enter: sends on desktop, inserts a newline on mobile (per spec —
|
||||
// send is via the dedicated button on touch devices).
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
@@ -219,7 +397,16 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t">
|
||||
<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">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
|
||||
@@ -239,7 +426,12 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Ask about this project. Enter to send, Shift+Enter for newline."
|
||||
onPaste={onPaste}
|
||||
placeholder={
|
||||
isMobile
|
||||
? 'Ask about this project. Tap send to submit.'
|
||||
: 'Ask about this project. Enter to send · Shift+Enter for newline.'
|
||||
}
|
||||
disabled={disabled || busy}
|
||||
rows={3}
|
||||
className="resize-none min-h-[68px] max-h-[240px]"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu';
|
||||
import { useLongPress } from '@/hooks/useLongPress';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
@@ -40,6 +41,18 @@ export function ChatTabBar({
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
|
||||
// Long-press: dispatch a synthetic contextmenu event on the tab so the
|
||||
// existing Radix ContextMenuTrigger opens at the touch coordinates. Works
|
||||
// because asChild composition makes the tab div the trigger element.
|
||||
const longPress = useLongPress(({ clientX, clientY, target }) => {
|
||||
if (!target || !(target instanceof Element)) return;
|
||||
const tab = target.closest('[data-tab-id]') as HTMLElement | null;
|
||||
if (!tab) return;
|
||||
tab.dispatchEvent(
|
||||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX, clientY }),
|
||||
);
|
||||
});
|
||||
|
||||
function startRename(chatId: string, currentName: string | null) {
|
||||
setRenamingId(chatId);
|
||||
setRenameValue(currentName ?? '');
|
||||
@@ -63,7 +76,13 @@ export function ChatTabBar({
|
||||
<ContextMenu key={chat.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
data-tab-id={chat.id}
|
||||
onClick={() => onSwitchTab(tabIdx)}
|
||||
onTouchStart={longPress.onTouchStart}
|
||||
onTouchMove={longPress.onTouchMove}
|
||||
onTouchEnd={longPress.onTouchEnd}
|
||||
onTouchCancel={longPress.onTouchCancel}
|
||||
style={{ WebkitTouchCallout: 'none' }}
|
||||
className={cn(
|
||||
'group flex items-center gap-1.5 px-3 py-1.5 text-xs border-r border-border cursor-default select-none shrink-0',
|
||||
isActive
|
||||
@@ -96,7 +115,7 @@ export function ChatTabBar({
|
||||
e.stopPropagation();
|
||||
onRemoveTab(chat.id);
|
||||
}}
|
||||
className="p-0.5 hover:bg-muted rounded opacity-0 group-hover:opacity-60 hover:!opacity-100 shrink-0"
|
||||
className="inline-flex items-center justify-center p-0.5 hover:bg-muted rounded opacity-0 group-hover:opacity-60 hover:!opacity-100 shrink-0 max-md:min-h-[44px] max-md:min-w-[44px] max-md:opacity-100"
|
||||
aria-label="Close tab"
|
||||
>
|
||||
<X size={10} />
|
||||
@@ -142,7 +161,7 @@ export function ChatTabBar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
className="p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="New chat"
|
||||
title="New chat"
|
||||
>
|
||||
@@ -152,7 +171,7 @@ export function ChatTabBar({
|
||||
type="button"
|
||||
onClick={onShowHistory}
|
||||
className={cn(
|
||||
'p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
'inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]',
|
||||
pane.kind === 'empty' && 'text-foreground bg-muted/50'
|
||||
)}
|
||||
aria-label="Session history"
|
||||
@@ -164,7 +183,7 @@ export function ChatTabBar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemovePane}
|
||||
className="p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Close pane"
|
||||
title="Close pane"
|
||||
>
|
||||
|
||||
18
apps/web/src/components/DropOverlay.tsx
Normal file
18
apps/web/src/components/DropOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -266,11 +266,11 @@ function ActionRow({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity max-md:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy()}
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Copy message"
|
||||
title="Copy"
|
||||
>
|
||||
@@ -281,7 +281,7 @@ function ActionRow({
|
||||
type="button"
|
||||
onClick={() => void regenerate()}
|
||||
disabled={!canRegen || regenerating}
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Regenerate message"
|
||||
title="Regenerate"
|
||||
>
|
||||
@@ -292,7 +292,7 @@ function ActionRow({
|
||||
type="button"
|
||||
onClick={() => void fork()}
|
||||
disabled={!canFork || forking}
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Fork from here"
|
||||
title="Fork from here"
|
||||
>
|
||||
@@ -302,7 +302,7 @@ function ActionRow({
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
disabled={!canDelete}
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Delete message"
|
||||
title="Delete message"
|
||||
>
|
||||
@@ -476,7 +476,7 @@ export function MessageBubble({ message, sessionChats }: Props) {
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<div className="group flex flex-col items-end gap-1">
|
||||
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap">
|
||||
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
|
||||
{message.content}
|
||||
</div>
|
||||
<ActionRow message={message} />
|
||||
@@ -495,7 +495,7 @@ export function MessageBubble({ message, sessionChats }: Props) {
|
||||
<ToolCallCard key={tc.id} toolCall={tc} />
|
||||
))}
|
||||
{(hasContent || (!hasToolCalls && isStreaming)) && (
|
||||
<div className="max-w-[90%] text-sm leading-relaxed space-y-2">
|
||||
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
||||
{hasContent ? <MarkdownBody content={message.content} /> : null}
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
import { AddProjectModal } from './AddProjectModal';
|
||||
import { api } from '@/api/client';
|
||||
import { useSidebar } from '@/hooks/useSidebar';
|
||||
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
|
||||
import type { SidebarProject } from '@/api/types';
|
||||
import { giteaUrlFor } from '@/lib/projectUrls';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -195,8 +198,24 @@ export function ProjectSidebar() {
|
||||
const rowCls = (active: boolean) =>
|
||||
active ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'hover:bg-sidebar-accent/60';
|
||||
|
||||
const { open: drawerOpen } = useSidebarDrawer();
|
||||
const { isMobile } = useViewport();
|
||||
const pull = usePullToRefresh(() => retry(), { enabled: isMobile });
|
||||
|
||||
// On mobile the sidebar is a slide-in drawer (fixed, z-40, off-screen by
|
||||
// default). On desktop it sits inline as a normal flex column. The
|
||||
// backdrop is rendered by AppShell; drawer-open state lives in
|
||||
// SidebarDrawerProvider.
|
||||
const asideCls = isMobile
|
||||
? cn(
|
||||
'fixed inset-y-0 left-0 z-40 w-60 border-r bg-sidebar text-sidebar-foreground flex flex-col',
|
||||
'transition-transform duration-200 ease-out',
|
||||
drawerOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
)
|
||||
: 'w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen';
|
||||
|
||||
return (
|
||||
<aside className="w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen">
|
||||
<aside className={asideCls}>
|
||||
<div className="px-4 py-3 border-b flex items-center justify-between">
|
||||
<NavLink to="/" className="font-semibold tracking-tight text-base">
|
||||
BooCode
|
||||
@@ -206,7 +225,30 @@ export function ProjectSidebar() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
{isMobile && (pull.pullDist > 0 || pull.refreshing) && (
|
||||
<div
|
||||
className="flex items-center justify-center text-[10px] uppercase tracking-wide text-muted-foreground border-b overflow-hidden shrink-0"
|
||||
style={{
|
||||
height: pull.refreshing ? 32 : Math.min(pull.pullDist, 80),
|
||||
transition: pull.pullDist === 0 && !pull.refreshing ? 'height 0.2s ease' : undefined,
|
||||
}}
|
||||
aria-live="polite"
|
||||
>
|
||||
{pull.refreshing
|
||||
? 'Refreshing…'
|
||||
: pull.pullDist >= 80
|
||||
? 'Release to refresh'
|
||||
: 'Pull to refresh'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav
|
||||
className="flex-1 overflow-y-auto py-2"
|
||||
onTouchStart={isMobile ? pull.onTouchStart : undefined}
|
||||
onTouchMove={isMobile ? pull.onTouchMove : undefined}
|
||||
onTouchEnd={isMobile ? pull.onTouchEnd : undefined}
|
||||
onTouchCancel={isMobile ? pull.onTouchEnd : undefined}
|
||||
>
|
||||
{loading && data == null && (
|
||||
<div className="space-y-2 px-2">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
|
||||
103
apps/web/src/components/SwipeablePaneTab.tsx
Normal file
103
apps/web/src/components/SwipeablePaneTab.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import type { TouchEvent } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onTap: () => void;
|
||||
onClose: () => void;
|
||||
canClose: boolean;
|
||||
}
|
||||
|
||||
const CLOSE_THRESHOLD = 60;
|
||||
const MAX_TRAVEL = 120;
|
||||
const VERTICAL_BAIL = 30;
|
||||
|
||||
// Pane tab with horizontal swipe-to-close (mobile only). Tracks horizontal
|
||||
// finger movement; if vertical exceeds VERTICAL_BAIL the gesture is cancelled
|
||||
// (so vertical scroll still works). On release past CLOSE_THRESHOLD, the
|
||||
// onClose callback fires. Otherwise the tab snaps back. Hand-rolled per spec.
|
||||
export function SwipeablePaneTab({ label, isActive, onTap, onClose, canClose }: Props) {
|
||||
const [translateX, setTranslateX] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const startRef = useRef<{ x: number; y: number; bailed: boolean } | null>(null);
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (!canClose) return;
|
||||
const t = e.touches[0];
|
||||
if (!t) return;
|
||||
startRef.current = { x: t.clientX, y: t.clientY, bailed: false };
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
const start = startRef.current;
|
||||
if (!start || start.bailed) return;
|
||||
const t = e.touches[0];
|
||||
if (!t) return;
|
||||
const dx = t.clientX - start.x;
|
||||
const dy = t.clientY - start.y;
|
||||
if (Math.abs(dy) > VERTICAL_BAIL) {
|
||||
start.bailed = true;
|
||||
setTranslateX(0);
|
||||
setDragging(false);
|
||||
return;
|
||||
}
|
||||
if (dx < 0) {
|
||||
setTranslateX(Math.max(dx, -MAX_TRAVEL));
|
||||
} else {
|
||||
setTranslateX(0);
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
const start = startRef.current;
|
||||
startRef.current = null;
|
||||
setDragging(false);
|
||||
if (!start || start.bailed) {
|
||||
setTranslateX(0);
|
||||
return;
|
||||
}
|
||||
const tx = translateX;
|
||||
if (tx <= -CLOSE_THRESHOLD) {
|
||||
onClose();
|
||||
// Don't reset translateX; the parent will unmount this tab.
|
||||
} else {
|
||||
setTranslateX(0);
|
||||
}
|
||||
};
|
||||
|
||||
// Opacity fades from 1 -> 0.4 as the tab approaches the close threshold.
|
||||
const opacity =
|
||||
translateX < 0
|
||||
? Math.max(0.4, 1 - (Math.abs(translateX) / CLOSE_THRESHOLD) * 0.6)
|
||||
: 1;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTap}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTouchCancel={onTouchEnd}
|
||||
style={{
|
||||
transform: `translateX(${translateX}px)`,
|
||||
opacity,
|
||||
// Only animate when releasing (snap-back); during drag the transform
|
||||
// tracks the finger 1:1 for a tight feel.
|
||||
transition: dragging ? undefined : 'transform 0.15s ease, opacity 0.15s ease',
|
||||
}}
|
||||
className={cn(
|
||||
'shrink-0 px-3 py-2 text-xs rounded min-h-[44px] min-w-[44px]',
|
||||
isActive
|
||||
? 'bg-background text-foreground border'
|
||||
: 'text-muted-foreground hover:bg-muted/40',
|
||||
)}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
>
|
||||
<span className="truncate max-w-[140px] inline-block">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
|
||||
import type { Chat, WorkspacePane } from '@/api/types';
|
||||
import { useWorkspacePanes, MAX_PANES } from '@/hooks/useWorkspacePanes';
|
||||
import { useSessionChats } from '@/hooks/useSessionChats';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { ChatPane } from '@/components/panes/ChatPane';
|
||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
||||
import { SwipeablePaneTab } from '@/components/SwipeablePaneTab';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -67,12 +70,59 @@ export function Workspace({ sessionId, projectId }: Props) {
|
||||
initializeFirstChatIfEmpty,
|
||||
});
|
||||
|
||||
const { isMobile } = useViewport();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// URL -> state (mobile only). Handles deep-link arrival and Back button
|
||||
// history pops. On a bare URL (no ?pane), reset to first pane so Back
|
||||
// from a ?pane URL returns the user to a sensible default.
|
||||
useEffect(() => {
|
||||
if (!isMobile || panes.length === 0) return;
|
||||
const paneId = searchParams.get('pane');
|
||||
if (!paneId) {
|
||||
if (activePaneIdx !== 0) setActivePaneIdx(0);
|
||||
return;
|
||||
}
|
||||
const idx = panes.findIndex((p) => p.id === paneId);
|
||||
if (idx >= 0 && idx !== activePaneIdx) setActivePaneIdx(idx);
|
||||
}, [isMobile, searchParams, panes, activePaneIdx, setActivePaneIdx]);
|
||||
|
||||
// Switch active pane and push URL (mobile only). User-initiated only;
|
||||
// never called from URL-sync effect.
|
||||
const switchActivePane = useCallback(
|
||||
(idx: number) => {
|
||||
setActivePaneIdx(idx);
|
||||
if (isMobile) {
|
||||
const pane = panes[idx];
|
||||
if (!pane) return;
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set('pane', pane.id);
|
||||
navigate(`${location.pathname}?${params.toString()}`);
|
||||
}
|
||||
},
|
||||
[setActivePaneIdx, isMobile, panes, navigate, location.pathname, location.search],
|
||||
);
|
||||
|
||||
function chatsForPane(pane: WorkspacePane): Chat[] {
|
||||
return pane.chatIds
|
||||
.map((id) => chats.find((c) => c.id === id))
|
||||
.filter((c): c is Chat => c !== undefined);
|
||||
}
|
||||
|
||||
function paneLabel(pane: WorkspacePane): string {
|
||||
const activeChatId = pane.chatId;
|
||||
if (activeChatId) {
|
||||
const chat = chats.find((c) => c.id === activeChatId);
|
||||
if (chat) return chat.name ?? 'New chat';
|
||||
}
|
||||
if (pane.kind === 'chat') return 'Chat';
|
||||
if (pane.kind === 'terminal') return 'Terminal';
|
||||
if (pane.kind === 'agent') return 'Agent';
|
||||
return 'Empty';
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -82,7 +132,7 @@ export function Workspace({ sessionId, projectId }: Props) {
|
||||
type="button"
|
||||
disabled={panes.length >= MAX_PANES}
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
@@ -104,30 +154,51 @@ export function Workspace({ sessionId, projectId }: Props) {
|
||||
</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">
|
||||
{panes.map((pane, idx) => (
|
||||
<SwipeablePaneTab
|
||||
key={pane.id}
|
||||
label={paneLabel(pane)}
|
||||
isActive={idx === activePaneIdx}
|
||||
onTap={() => switchActivePane(idx)}
|
||||
onClose={() => removePane(idx)}
|
||||
canClose={panes.length > 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex-1 grid min-h-0"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))`,
|
||||
}}
|
||||
className={cn('flex-1 min-h-0', isMobile ? 'flex' : 'grid')}
|
||||
style={
|
||||
isMobile
|
||||
? undefined
|
||||
: { gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))` }
|
||||
}
|
||||
>
|
||||
{panes.map((pane, idx) => (
|
||||
{panes.map((pane, idx) => {
|
||||
const visible = !isMobile || idx === activePaneIdx;
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<div
|
||||
key={pane.id}
|
||||
className={cn(
|
||||
'flex flex-col h-full min-h-0 border-r border-border last:border-r-0 relative',
|
||||
idx === activePaneIdx && 'ring-1 ring-inset ring-ring/20',
|
||||
dragOverIdx === idx && draggingIdxRef.current !== idx &&
|
||||
isMobile ? 'flex-1 w-full' : undefined,
|
||||
!isMobile && idx === activePaneIdx && 'ring-1 ring-inset ring-ring/20',
|
||||
!isMobile && dragOverIdx === idx && draggingIdxRef.current !== idx &&
|
||||
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
|
||||
)}
|
||||
onClick={() => setActivePaneIdx(idx)}
|
||||
onDragOver={panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
||||
onDragLeave={panes.length > 1 ? handlePaneDragLeave : undefined}
|
||||
onDrop={panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
||||
onDragOver={!isMobile && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
||||
onDragLeave={!isMobile && panes.length > 1 ? handlePaneDragLeave : undefined}
|
||||
onDrop={!isMobile && panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
||||
>
|
||||
<div
|
||||
draggable={panes.length > 1}
|
||||
onDragStart={panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
||||
onDragEnd={panes.length > 1 ? handlePaneDragEnd : undefined}
|
||||
draggable={!isMobile && panes.length > 1}
|
||||
onDragStart={!isMobile && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
||||
onDragEnd={!isMobile && panes.length > 1 ? handlePaneDragEnd : undefined}
|
||||
>
|
||||
<ChatTabBar
|
||||
pane={pane}
|
||||
@@ -165,7 +236,8 @@ export function Workspace({ sessionId, projectId }: Props) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -120,7 +120,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 hover:bg-muted rounded shrink-0"
|
||||
className="inline-flex items-center justify-center p-0.5 hover:bg-muted rounded shrink-0 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Queued message options"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
@@ -138,7 +138,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeQueued(i)}
|
||||
className="p-0.5 hover:bg-muted rounded shrink-0"
|
||||
className="inline-flex items-center justify-center p-0.5 hover:bg-muted rounded shrink-0 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Cancel queued message"
|
||||
>
|
||||
<X size={12} />
|
||||
@@ -156,7 +156,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleStop()}
|
||||
className="flex items-center gap-1.5 text-xs px-3 py-1 rounded-full border hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
className="flex items-center gap-1.5 text-xs px-3 py-1 rounded-full border hover:bg-muted text-muted-foreground hover:text-foreground max-md:min-h-[44px] max-md:px-5"
|
||||
>
|
||||
<Square size={10} className="fill-current" />
|
||||
Stop generating
|
||||
|
||||
75
apps/web/src/hooks/useLongPress.ts
Normal file
75
apps/web/src/hooks/useLongPress.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type { TouchEvent } from 'react';
|
||||
|
||||
interface LongPressHandlers {
|
||||
onTouchStart: (e: TouchEvent) => void;
|
||||
onTouchMove: (e: TouchEvent) => void;
|
||||
onTouchEnd: (e: TouchEvent) => void;
|
||||
onTouchCancel: (e: TouchEvent) => void;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
ms?: number;
|
||||
// Suppress the synthetic click that follows touchend when long-press fired.
|
||||
suppressClickOnFire?: boolean;
|
||||
}
|
||||
|
||||
// Hand-rolled long-press detector. Starts a timer on touchstart; cancels on
|
||||
// touchmove or early touchend; fires the callback on timer expiry. Caller is
|
||||
// expected to suppress text-selection callout via CSS (-webkit-touch-callout).
|
||||
export function useLongPress(
|
||||
callback: (touch: { clientX: number; clientY: number; target: EventTarget | null }) => void,
|
||||
{ ms = 500, suppressClickOnFire = true }: Options = {},
|
||||
): LongPressHandlers {
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const firedRef = useRef(false);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onTouchStart = useCallback(
|
||||
(e: TouchEvent) => {
|
||||
firedRef.current = false;
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
const x = touch.clientX;
|
||||
const y = touch.clientY;
|
||||
const target = e.target;
|
||||
clear();
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
firedRef.current = true;
|
||||
callback({ clientX: x, clientY: y, target });
|
||||
}, ms);
|
||||
},
|
||||
[callback, ms, clear],
|
||||
);
|
||||
|
||||
const onTouchMove = useCallback(() => {
|
||||
clear();
|
||||
}, [clear]);
|
||||
|
||||
const onTouchEnd = useCallback(
|
||||
(e: TouchEvent) => {
|
||||
clear();
|
||||
if (firedRef.current && suppressClickOnFire) {
|
||||
// Block the synthetic click that follows touchend; the long-press
|
||||
// already handled the gesture.
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
[clear, suppressClickOnFire],
|
||||
);
|
||||
|
||||
const onTouchCancel = useCallback(
|
||||
(_e: TouchEvent) => {
|
||||
clear();
|
||||
},
|
||||
[clear],
|
||||
);
|
||||
|
||||
return { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel };
|
||||
}
|
||||
77
apps/web/src/hooks/usePullToRefresh.ts
Normal file
77
apps/web/src/hooks/usePullToRefresh.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { TouchEvent } from 'react';
|
||||
|
||||
interface Options {
|
||||
threshold?: number;
|
||||
enabled?: boolean;
|
||||
maxPull?: number;
|
||||
}
|
||||
|
||||
interface Handlers {
|
||||
onTouchStart: (e: TouchEvent<HTMLElement>) => void;
|
||||
onTouchMove: (e: TouchEvent<HTMLElement>) => void;
|
||||
onTouchEnd: () => void;
|
||||
pullDist: number;
|
||||
refreshing: boolean;
|
||||
}
|
||||
|
||||
// Hand-rolled pull-to-refresh: records the initial Y on touchstart only if
|
||||
// the target is scrolled to the top, then tracks downward pull on touchmove.
|
||||
// On touchend, fires onRefresh if the pull exceeded the threshold.
|
||||
export function usePullToRefresh(
|
||||
onRefresh: () => void | Promise<void>,
|
||||
{ threshold = 80, enabled = true, maxPull = 120 }: Options = {},
|
||||
): Handlers {
|
||||
const [pullDist, setPullDist] = useState(0);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const startYRef = useRef<number | null>(null);
|
||||
|
||||
const onTouchStart = useCallback(
|
||||
(e: TouchEvent<HTMLElement>) => {
|
||||
if (!enabled || refreshing) return;
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
if (target.scrollTop > 0) return;
|
||||
const t = e.touches[0];
|
||||
if (!t) return;
|
||||
startYRef.current = t.clientY;
|
||||
},
|
||||
[enabled, refreshing],
|
||||
);
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
(e: TouchEvent<HTMLElement>) => {
|
||||
if (!enabled || refreshing || startYRef.current === null) return;
|
||||
const t = e.touches[0];
|
||||
if (!t) return;
|
||||
const delta = t.clientY - startYRef.current;
|
||||
if (delta > 0) {
|
||||
setPullDist(Math.min(delta, maxPull));
|
||||
} else {
|
||||
setPullDist(0);
|
||||
}
|
||||
},
|
||||
[enabled, refreshing, maxPull],
|
||||
);
|
||||
|
||||
const onTouchEnd = useCallback(() => {
|
||||
if (!enabled || refreshing) {
|
||||
startYRef.current = null;
|
||||
setPullDist(0);
|
||||
return;
|
||||
}
|
||||
const fired = pullDist >= threshold && startYRef.current !== null;
|
||||
startYRef.current = null;
|
||||
setPullDist(0);
|
||||
if (fired) {
|
||||
setRefreshing(true);
|
||||
Promise.resolve(onRefresh())
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
// Hold the indicator briefly so the action feels intentional.
|
||||
window.setTimeout(() => setRefreshing(false), 600);
|
||||
});
|
||||
}
|
||||
}, [enabled, refreshing, pullDist, threshold, onRefresh]);
|
||||
|
||||
return { onTouchStart, onTouchMove, onTouchEnd, pullDist, refreshing };
|
||||
}
|
||||
36
apps/web/src/hooks/useSidebarDrawer.tsx
Normal file
36
apps/web/src/hooks/useSidebarDrawer.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
interface SidebarDrawerState {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<SidebarDrawerState | null>(null);
|
||||
|
||||
export function SidebarDrawerProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
// Auto-close on navigation. Effect fires once on mount too (open default
|
||||
// is false, so no observable effect) and on every pathname change after.
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
const toggle = useCallback(() => setOpen((v) => !v), []);
|
||||
|
||||
return <Ctx.Provider value={{ open, setOpen, toggle }}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useSidebarDrawer(): SidebarDrawerState {
|
||||
const ctx = useContext(Ctx);
|
||||
if (!ctx) {
|
||||
// Soft fallback so consumers don't crash if rendered outside a provider.
|
||||
// In practice all top-level routes are inside the provider.
|
||||
return { open: false, setOpen: () => {}, toggle: () => {} };
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
45
apps/web/src/hooks/useViewport.ts
Normal file
45
apps/web/src/hooks/useViewport.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Breakpoints (px): mobile <768, tablet 768-1023, desktop >=1024.
|
||||
const MOBILE_MAX = 767;
|
||||
const TABLET_MAX = 1023;
|
||||
|
||||
export interface ViewportSnapshot {
|
||||
isMobile: boolean;
|
||||
isTablet: boolean;
|
||||
width: number;
|
||||
}
|
||||
|
||||
function snapshot(): ViewportSnapshot {
|
||||
if (typeof window === 'undefined') {
|
||||
return { isMobile: false, isTablet: false, width: 1280 };
|
||||
}
|
||||
const width = window.innerWidth;
|
||||
return {
|
||||
isMobile: width <= MOBILE_MAX,
|
||||
isTablet: width > MOBILE_MAX && width <= TABLET_MAX,
|
||||
width,
|
||||
};
|
||||
}
|
||||
|
||||
// matchMedia-based, no resize polling. We listen to two breakpoint queries
|
||||
// and recompute the snapshot on any change.
|
||||
export function useViewport(): ViewportSnapshot {
|
||||
const [state, setState] = useState<ViewportSnapshot>(snapshot);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mobileMq = window.matchMedia(`(max-width: ${MOBILE_MAX}px)`);
|
||||
const tabletMq = window.matchMedia(`(min-width: ${MOBILE_MAX + 1}px) and (max-width: ${TABLET_MAX}px)`);
|
||||
const update = () => setState(snapshot());
|
||||
mobileMq.addEventListener('change', update);
|
||||
tabletMq.addEventListener('change', update);
|
||||
update();
|
||||
return () => {
|
||||
mobileMq.removeEventListener('change', update);
|
||||
tabletMq.removeEventListener('change', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -8,6 +8,34 @@ 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',
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
|
||||
import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw, Menu } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { Project as ProjectType, Session } from '@/api/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { useSessions } from '@/hooks/useSessions';
|
||||
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
|
||||
export function Project() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -16,6 +18,8 @@ export function Project() {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [archivedSessions, setArchivedSessions] = useState<Session[] | null>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const { setOpen: setDrawerOpen } = useSidebarDrawer();
|
||||
const { isMobile } = useViewport();
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -76,16 +80,31 @@ export function Project() {
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col">
|
||||
<header className="border-b px-6 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight">
|
||||
{project?.name ?? '…'}
|
||||
</h1>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{project?.path}
|
||||
<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))' }}
|
||||
>
|
||||
<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"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold tracking-tight truncate">
|
||||
{project?.name ?? '…'}
|
||||
</h1>
|
||||
<div className="text-xs text-muted-foreground font-mono truncate">
|
||||
{project?.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleNew} disabled={creating}>
|
||||
<Button onClick={handleNew} disabled={creating} className="shrink-0">
|
||||
<Plus />
|
||||
New session
|
||||
</Button>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { ChevronRight, 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 { useViewport } from '@/hooks/useViewport';
|
||||
import { Workspace } from '@/components/Workspace';
|
||||
import { ModelPicker } from '@/components/ModelPicker';
|
||||
|
||||
@@ -16,6 +18,8 @@ export function Session() {
|
||||
const [name, setName] = useState('');
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const active = useActivePane();
|
||||
const { setOpen: setDrawerOpen } = useSidebarDrawer();
|
||||
const { isMobile } = useViewport();
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -83,7 +87,17 @@ 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">
|
||||
<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))' }}>
|
||||
{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"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
<Link to="/" className="text-muted-foreground hover:text-foreground">
|
||||
Projects
|
||||
</Link>
|
||||
|
||||
487
boocode_roadmap.md
Normal file
487
boocode_roadmap.md
Normal file
@@ -0,0 +1,487 @@
|
||||
# BooCode v1.x — Roadmap
|
||||
|
||||
Last updated: 2026-05-16
|
||||
|
||||
## Overview
|
||||
|
||||
BooCode is a standalone code-chat tool at `/opt/boocode/`. Read-only by design in v1.x — pick a project, chat with a local LLM that has file-inspection tools, get streaming responses over WebSocket. Built May 2026 after the in-boolab BooCode mode stalled.
|
||||
|
||||
v1 shipped in a single Claude Code session. v1.1 onwards is a batched build-out. Original Batch 1–10 plan was reordered mid-stream — chats-inside-sessions, archive, and fork/delete work was prioritized over the mobile pass.
|
||||
|
||||
Live at `https://code.indifferentketchup.com` (Caddy → Authelia → Tailscale → `100.114.205.53:9500`).
|
||||
|
||||
-----
|
||||
|
||||
## Version summary
|
||||
|
||||
|Version |Theme |Status |Notes |
|
||||
|----------------|---------------------------------------------------------------------------------------------------------------------------------------|---------------------------------|-----------------------------------------|
|
||||
|v1.0 |Initial scaffold, read-only tools, WS streaming |✅ Done |Shipped in one Claude Code session |
|
||||
|v1.1-batch1 |Markdown, Copy + Regen, tok/s + ctx, AI chat naming |✅ Merged |— |
|
||||
|v1.1-batch2 |Sidebar restructure: projects → sessions, max 5 + “view all” |✅ Merged |— |
|
||||
|v1.1-batch3 |Pane system, FileBrowserPane + Shiki, chat→file click, cross-tab |✅ Merged |— |
|
||||
|v1.1-batch3.5 |Chip infrastructure, `@file` picker, line-select-attach |✅ Merged |— |
|
||||
|v1.2 |Chats inside sessions refactor, right-rail, `/compact`, archive, force-send |✅ Merged |Replaced original “Batch 4 = mobile” plan|
|
||||
|v1.2-project-ux |Project archive UX, sidebar context menu, full-bootstrap, Gitea API |✅ Merged |— |
|
||||
|v1.3 |Tab-close + chat-archive |✅ Merged |— |
|
||||
|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.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 |
|
||||
|v1.10 |Agents (Tier 2): `AGENTS.md`, per-agent model/temp/tools, picker |Planned |Was Batch 9 |
|
||||
|v1.11 |BooTerm: separate container, xterm.js + node-pty + tmux, terminal pane |Planned |Was Batch 10 |
|
||||
|
||||
-----
|
||||
|
||||
## Version details
|
||||
|
||||
### v1.1-batch1 — Message polish ✅
|
||||
|
||||
Markdown (`react-markdown` + `remark-gfm`), Copy + Regenerate, tok/s + context counter, AI session naming.
|
||||
|
||||
**Key decisions:**
|
||||
|
||||
- `sessions.name` (not `title`).
|
||||
- `enable_thinking: false` + `max_tokens: 30` for Qwen3 utility calls.
|
||||
- `messages_deleted` WS frame added for multi-tab regen.
|
||||
- In-app event bus (`sessionEvents.ts`, module-scope `Set<Listener>`).
|
||||
|
||||
**Schema:** `messages.tokens_used`, `messages.ctx_used`, `messages.ctx_max`, `messages.started_at`, `messages.finished_at`.
|
||||
|
||||
-----
|
||||
|
||||
### v1.1-batch2 — Sidebar restructure ✅
|
||||
|
||||
Projects as expandable groups, up to 5 recent sessions per project, “View all (N)”, `GET /api/sidebar`, `useSidebar` singleton hook.
|
||||
|
||||
**Key decisions:**
|
||||
|
||||
- `useSidebar` module-scope singleton.
|
||||
- `localStorage['boocode.sidebar.expanded']`.
|
||||
- `session_renamed` payload `{session_id, name}`.
|
||||
|
||||
-----
|
||||
|
||||
### v1.1-batch3 — Pane system ✅
|
||||
|
||||
`session_panes` table, pane CRUD with transactional position-shift, Workspace + tab strip + drag-to-reorder (native HTML5), ChatPane (extracted), FileBrowserPane (tree + Shiki + filter), chat→file click, PaneTab context menu, `file_ops` + `file_index` shared services, broker user channel + `/ws/user`, `session_updated`, `session_loaded`, idempotent default-Chat-pane backfill.
|
||||
|
||||
**Schema:** `session_panes` (id, session_id, position, kind CHECK, state JSONB).
|
||||
|
||||
-----
|
||||
|
||||
### v1.1-batch3.5 — Chips + @file + line-select ✅
|
||||
|
||||
`Attachment` type + `flattenToMessage` + LANG_MAP, `AttachmentChip`, `AttachmentPreviewModal`, ChatInput chip-row, hand-rolled `@file` mention popover, line-select-attach in FileBrowserPane via local `FileViewer`, FileBrowserPane filter upgrade (empty=tree, non-empty=flat).
|
||||
|
||||
-----
|
||||
|
||||
### v1.2 — Chats inside sessions ✅
|
||||
|
||||
Originally planned as the mobile pass. Reshuffled: structural refactor — chats inside sessions, right-rail, `/compact` (chat’s own model summarizes via `kind='compact'` system message), force-send.
|
||||
|
||||
**Schema:** `chats` table, `sessions.status`, `messages.chat_id`, `messages.kind` (regular | compact).
|
||||
|
||||
-----
|
||||
|
||||
### v1.2-project-ux ✅
|
||||
|
||||
Full new-project bootstrap (mkdir + git init + .gitignore + first commit + Gitea remote + push), sidebar context menu (Rename / Archive / Open in Gitea), project landing page archived-list, Gitea API integration. Option B taken: `BOOTSTRAP_ROOT` env var, `/opt` stays read-only mount, `/opt/projects` writable.
|
||||
|
||||
**Schema:** `projects.status`, `projects.archived_at`.
|
||||
|
||||
**Key decisions:**
|
||||
|
||||
- `execFile` only, no `exec` shell strings.
|
||||
- DB INSERT last in bootstrap sequence.
|
||||
- Soft-fail on Gitea steps.
|
||||
- Project Delete endpoint exists but stays unexposed (re-add INSERTs fresh row → FK cascade nukes history; archive is the safe pattern).
|
||||
|
||||
-----
|
||||
|
||||
### v1.3 — Tab close + chat archive ✅
|
||||
|
||||
Tab close UX cleanup, chat-level archive (separate from session archive).
|
||||
|
||||
-----
|
||||
|
||||
### v1.4 — Fork + delete + header polish ✅
|
||||
|
||||
Was originally planned as Batch 5.
|
||||
|
||||
**Shipped:** `POST /api/sessions/:id/fork` (deep copy messages up to target, new session in same project), `DELETE /api/sessions/:id/messages/:id` (cascading via `messages_deleted` frame), header breadcrumb (Projects → Project → Session), inline-editable session name, file path shown when File Browser pane is active, `useActivePane` hook.
|
||||
|
||||
-----
|
||||
|
||||
### v1.5 — Refactor + tests + security scoping + context tracker ✅
|
||||
|
||||
5-commit sequence:
|
||||
|
||||
1. **Refactor:** FileBrowserPane (865 → split with FileViewer extracted), Workspace, inference split.
|
||||
1. **Vitest harness:** 23 tests covering routes + resolveProjectPath. Pinned to v3 (Vite 5 / vitest 4 incompatibility).
|
||||
1. **Error-log surfacing:** dead-code removal from earlier H1/H2 audit items, structured error logs to client.
|
||||
1. **Mount scoping:** `/opt:/opt:ro` + `BOOTSTRAP_ROOT` writable subdir. Container loses write to `/opt` proper.
|
||||
1. **Persistent context-window tracker:** floating popover above chat input right edge, source = latest `message_complete` frame’s `ctx_used` / `ctx_max`, color-coded (neutral <60%, amber 60–85%, red 85%+), hides when `ctx_max` null.
|
||||
|
||||
**Carried bug:** `resolveProjectPath` whitelist-root bypass — discovered, asserted as “BEHAVIOR GAP” rather than silently patched. Fix landed in v1.6 (H1).
|
||||
|
||||
-----
|
||||
|
||||
### v1.5.1 — Bootstrap hotfix ✅ (`4a9f207`)
|
||||
|
||||
Dockerfile (git installed in container), docker-compose.yml, project_bootstrap.ts (SSH keypair, known_hosts, SSH URL Tailscale rewrite), CreateProjectModal.tsx, .gitignore. /opt/projects label clarified.
|
||||
|
||||
**Known issue carried forward:** dispatch used the in-repo `secrets/boocode_gitea` SSH key because the agent key was rejected. Key exposure flagged. Audit + rotation tracked in v1.6.1 below.
|
||||
|
||||
-----
|
||||
|
||||
### v1.6-mobile-pass 🔄
|
||||
|
||||
**Hand-back received, uncommitted on `v1.6-mobile-pass`.** 5-commit sequence proposed:
|
||||
|
||||
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).
|
||||
|
||||
**Decisions:**
|
||||
|
||||
- H2 (roadmap update) handled in this file rather than by Claude Code.
|
||||
- M5: mobile = button-only send, Enter inserts newline. Desktop unchanged. `isComposing` guard for CJK IME.
|
||||
- M6: kept `max-w-[1000px]` (mobile naturally full-width below cap).
|
||||
- URL state: `?pane=<paneId>`. Bare URL resets activePaneIdx to 0.
|
||||
- Long-press dispatches synthetic `contextmenu` on `[data-tab-id]`, opening Radix ContextMenuTrigger at touch coords. iOS callout suppressed.
|
||||
- `SwipeablePaneTab`: 60px threshold, bails if vertical >30px, opacity 1→0.4.
|
||||
- A2 bundled with M3 in Commit 3 (structural coupling).
|
||||
- Home.tsx no hamburger.
|
||||
|
||||
**Deferred from v1.6 → rolled into v1.6.1-cleanup:**
|
||||
|
||||
- RightRail still renders on mobile (~32px column).
|
||||
- Secrets hygiene audit.
|
||||
- `ProjectSidebar.tsx` and `ChatTabBar.tsx` share content from two commits each — use `git add -p`.
|
||||
|
||||
-----
|
||||
|
||||
### v1.6.1-cleanup — Stale + overengineering audit + secrets hygiene (next)
|
||||
|
||||
**Depends on:** v1.6 committed.
|
||||
|
||||
**Scope:**
|
||||
|
||||
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).
|
||||
|
||||
**No new features. No schema changes.**
|
||||
|
||||
-----
|
||||
|
||||
### v1.7 — Drag-drop + paste (planned, was Batch 6)
|
||||
|
||||
**Depends on:** v1.6.1 merged.
|
||||
|
||||
**Scope (trimmed — chip infra exists from v1.1-batch3.5):**
|
||||
|
||||
- Drag-drop files onto ChatInput → chip via `addAttachment({kind: 'file', source: 'drop'})`.
|
||||
- Paste >8 lines → chip via `addAttachment({kind: 'paste', source: 'paste'})`. ≤8 lines inline.
|
||||
- Drop overlay (dashed border + “Drop to attach”).
|
||||
- Client-side 5 MB cap + binary detection (null-byte check in first 8KB).
|
||||
- Max 10 attachments shared cap.
|
||||
- Folder drop rejected. Image paste rejected. Binary files rejected with toast.
|
||||
|
||||
**Frontend only.**
|
||||
|
||||
-----
|
||||
|
||||
### v1.8 — Settings drawer (planned, was Batch 7)
|
||||
|
||||
**Depends on:** header gear (already in v1.4).
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Right-side drawer (hand-rolled, no shadcn Sheet). Tabbed: Session + Project.
|
||||
- Session tab: system prompt, web search toggle, model picker, session name.
|
||||
- Project tab: default system prompt, default web search, project name, root path (read-only), delete project (consider whether to expose given the cascade concern).
|
||||
- Resolution: `session.system_prompt OR project.default_system_prompt OR ""`.
|
||||
- Project defaults applied at session create (copied), not retroactively.
|
||||
- Web search toggle persistent per session (`sessions.web_search_enabled`).
|
||||
|
||||
**Schema:** `sessions.web_search_enabled`, `projects.default_system_prompt`, `projects.default_web_search`.
|
||||
|
||||
-----
|
||||
|
||||
### v1.9 — Web search backend (planned, was Batch 8)
|
||||
|
||||
**Depends on:** v1.8.
|
||||
|
||||
**Scope:**
|
||||
|
||||
- `web_search` tool → SearXNG at `http://100.114.205.53:8888/search?format=json`, top-N `{title, url, snippet}`.
|
||||
- `web_fetch` tool, regex HTML strip (no cheerio), 50KB cap.
|
||||
- Tools conditionally included based on `session.web_search_enabled`.
|
||||
- `ToolCallCard.tsx` renders results as clickable URL list, web_fetch as text preview.
|
||||
- Env: `SEARXNG_URL`, `WEB_FETCH_TIMEOUT_MS`, `WEB_FETCH_MAX_BYTES`.
|
||||
|
||||
-----
|
||||
|
||||
### v1.10 — Agents (planned, was Batch 9)
|
||||
|
||||
**Depends on:** v1.8.
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Tier 2 agents: system prompt + model + temperature + tools whitelist per agent.
|
||||
- `AGENTS.md` (OpenCode-compatible): `## Agent Name` blocks with YAML frontmatter.
|
||||
- Three builtin defaults (Investigator, Architect, Reviewer) when no `AGENTS.md`.
|
||||
- If `AGENTS.md` exists, only its agents shown.
|
||||
- Agent picker in ChatInput toolbar + SettingsDrawer.
|
||||
- Tools whitelist enforced at inference layer. BooChat agents read-only.
|
||||
- File parsed on demand with mtime cache.
|
||||
- Mid-conversation agent switch allowed; old messages retain their tool history.
|
||||
|
||||
**Schema:** `sessions.agent_id TEXT`.
|
||||
|
||||
-----
|
||||
|
||||
### v1.11 — BooTerm (planned, was Batch 10)
|
||||
|
||||
**Depends on:** v1.1-batch3 (pane system), v1.8 (settings drawer pattern).
|
||||
|
||||
**Scope:**
|
||||
|
||||
- New container `booterm` at `100.114.205.53:9501`. Fastify + node-pty + tmux.
|
||||
- Caddy path-based routing: `/api/term/*` + `/ws/term/*` → booterm.
|
||||
- Shared `boocode_db`.
|
||||
- Per-session tmux (`bc-<session_id>`), per-pane tmux window (`term-<pane_id>`).
|
||||
- xterm.js terminal pane. New `kind = 'terminal'` in `session_panes` CHECK.
|
||||
- PTY over binary WebSocket. Resize via `tmux resize-window`.
|
||||
- Workspace mount: `/opt/repos:/opt/repos:rw`. BooCode chat container keeps `/opt:/opt:ro`.
|
||||
- Send-to-terminal from chat: select text → right-click → “Send to terminal”.
|
||||
- tmux persistence across WS reconnects, page refreshes, container restarts.
|
||||
- No chroot/namespace isolation. Acceptable single-user homelab.
|
||||
|
||||
**New app:** `apps/booterm/`.
|
||||
|
||||
-----
|
||||
|
||||
## Architecture
|
||||
|
||||
### Containers (current + planned)
|
||||
|
||||
|Container |Port |Mount |Purpose |Status |
|
||||
|------------|---------------------|-----------------------------------|----------------------------|---------|
|
||||
|`boocode` |`100.114.205.53:9500`|`/opt:/opt:ro` + `/opt/projects:rw`|Chat + read-only tools + SPA|Live |
|
||||
|`boocode_db`|`127.0.0.1:5500` |`boocode_pgdata` |Postgres 16-alpine (shared) |Live |
|
||||
|`booterm` |`100.114.205.53:9501`|`/opt/repos:/opt/repos:rw` |Terminal sessions |v1.11 |
|
||||
|`boocoder` |TBD |`/opt/repos:/opt/repos:rw` |Write tools |Post-v1.x|
|
||||
|
||||
### URL routing (target state after v1.11)
|
||||
|
||||
```
|
||||
code.indifferentketchup.com
|
||||
├── / → boocode (SPA)
|
||||
├── /api/chat/*, /ws/chat/* → boocode :9500
|
||||
├── /api/term/*, /ws/term/* → booterm :9501
|
||||
├── /api/coder/*, /ws/coder/* → boocoder (future)
|
||||
└── /ws/user → boocode :9500
|
||||
```
|
||||
|
||||
### Database
|
||||
|
||||
Single Postgres `boocode_db`. All containers share. Projects shared. Sessions container-specific.
|
||||
|
||||
Current schema (post v1.5.1):
|
||||
|
||||
```
|
||||
projects
|
||||
├── id UUID PK
|
||||
├── name TEXT
|
||||
├── root_path TEXT
|
||||
├── status TEXT (v1.2-project-ux: active | archived)
|
||||
├── archived_at TIMESTAMPTZ (v1.2-project-ux)
|
||||
├── default_system_prompt TEXT (v1.8)
|
||||
├── default_web_search BOOLEAN (v1.8)
|
||||
└── created_at TIMESTAMPTZ
|
||||
|
||||
sessions
|
||||
├── id UUID PK
|
||||
├── project_id UUID FK → projects
|
||||
├── name TEXT
|
||||
├── model TEXT
|
||||
├── system_prompt TEXT
|
||||
├── status TEXT (v1.2: active | archived)
|
||||
├── web_search_enabled BOOLEAN (v1.8)
|
||||
├── agent_id TEXT (v1.10)
|
||||
├── created_at TIMESTAMPTZ
|
||||
└── updated_at TIMESTAMPTZ
|
||||
|
||||
chats (v1.2)
|
||||
├── id UUID PK
|
||||
├── session_id UUID FK → sessions
|
||||
├── name TEXT
|
||||
├── status TEXT
|
||||
├── created_at TIMESTAMPTZ
|
||||
└── updated_at TIMESTAMPTZ
|
||||
|
||||
messages
|
||||
├── id UUID PK
|
||||
├── session_id UUID FK → sessions
|
||||
├── chat_id UUID FK → chats (v1.2)
|
||||
├── kind TEXT (v1.2: regular | compact)
|
||||
├── role TEXT
|
||||
├── content TEXT
|
||||
├── tool_calls JSONB
|
||||
├── tool_results JSONB
|
||||
├── status TEXT
|
||||
├── last_seq INTEGER
|
||||
├── tokens_used INTEGER (v1.1-batch1)
|
||||
├── ctx_used INTEGER (v1.1-batch1)
|
||||
├── ctx_max INTEGER (v1.1-batch1)
|
||||
├── started_at TIMESTAMPTZ (v1.1-batch1)
|
||||
├── finished_at TIMESTAMPTZ (v1.1-batch1)
|
||||
└── created_at TIMESTAMPTZ
|
||||
|
||||
session_panes (v1.1-batch3)
|
||||
├── id UUID PK
|
||||
├── session_id UUID FK → sessions (CASCADE)
|
||||
├── position INTEGER
|
||||
├── kind TEXT CHECK (chat | file_browser | terminal)
|
||||
├── state JSONB
|
||||
└── created_at TIMESTAMPTZ
|
||||
|
||||
settings
|
||||
├── k TEXT PK
|
||||
└── v TEXT
|
||||
```
|
||||
|
||||
### Reusable patterns
|
||||
|
||||
|Pattern |Where |Used by |
|
||||
|----------------------------|----------------------------------------|---------------------------------------------------------|
|
||||
|In-app event bus |`sessionEvents.ts` |All batches. Module-scope `Set<Listener>`. |
|
||||
|Singleton hooks |`useSidebar.ts` |Module-scope shared state. |
|
||||
|User-channel WS broker |`broker.ts` + `useUserEvents.ts` |Cross-tab lifecycle. One WS per tab. |
|
||||
|`clock_timestamp()` |All INSERT/UPDATE |Never `NOW()` in new code. |
|
||||
|Additive schema only |`schema.sql` |`ADD COLUMN IF NOT EXISTS`, `CREATE TABLE IF NOT EXISTS`.|
|
||||
|Idempotent backfills |`schema.sql` |`INSERT ... WHERE NOT EXISTS`. |
|
||||
|`enable_thinking: false` |`auto_name.ts` |Required for Qwen3 utility calls. |
|
||||
|`pathGuard` |`tools/*`, `file_ops.ts` |Realpath + project root enforcement. |
|
||||
|Shared `file_ops.ts` |`tools.ts`, `routes/projects.ts` |Same core for inference tools and UI. |
|
||||
|File index (`file_index.ts`)|`routes/projects.ts` |`rg --files` + mtime cache. |
|
||||
|`useViewport` |`hooks/useViewport.ts` (v1.6) |matchMedia, SSR-safe. |
|
||||
|`useSidebarDrawer` |`hooks/useSidebarDrawer.tsx` (v1.6) |Context + auto-close on route change. |
|
||||
|Hand-rolled long-press |`hooks/useLongPress.ts` (v1.6) |500ms touchstart timer, dispatches synthetic contextmenu.|
|
||||
|Hand-rolled pull-to-refresh |`hooks/usePullToRefresh.ts` (v1.6) |80px threshold, 600ms min hold. |
|
||||
|Hand-rolled swipe |`components/SwipeablePaneTab.tsx` (v1.6)|60px threshold, vertical bail at 30px. |
|
||||
|
||||
-----
|
||||
|
||||
## Tech stack
|
||||
|
||||
|Layer |Tech |
|
||||
|----------------|--------------------------------------------------------------------------|
|
||||
|Backend |Node 20 + Fastify + `@fastify/websocket` + `@fastify/static` + zod + `pg` |
|
||||
|Frontend |React + Vite + Tailwind v4 + shadcn nova preset |
|
||||
|Inference |llama-swap `http://100.101.41.16:8401` (OpenAI-compatible) |
|
||||
|Search |SearXNG `http://100.114.205.53:8888` (v1.9) |
|
||||
|Syntax |Shiki (`github-dark`) |
|
||||
|Terminal |xterm.js + node-pty + tmux (v1.11) |
|
||||
|Auth |`Remote-User` from Authelia via Caddy `forward_auth` |
|
||||
|Containerization|Docker Compose, Node 20-alpine, multi-stage, ripgrep apk, git apk (v1.5.1)|
|
||||
|DB |Postgres 16-alpine, loopback `127.0.0.1:5500` |
|
||||
|Networking |Tailscale mesh, Caddy (DO droplet), Authelia SSO |
|
||||
|Code hosting |Gitea `git.indifferentketchup.com` |
|
||||
|Tests |vitest v3 (pinned, Vite 5 / vitest 4 incompatible) |
|
||||
|
||||
-----
|
||||
|
||||
## 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.
|
||||
- **Dormant in-boolab BooCode mode.** Reference only.
|
||||
- **BooCoder container.** Post-v1.x.
|
||||
|
||||
-----
|
||||
|
||||
## Dependency graph
|
||||
|
||||
```
|
||||
v1.0 (initial)
|
||||
│
|
||||
▼
|
||||
v1.1-batch1 (markdown)
|
||||
│
|
||||
▼
|
||||
v1.1-batch2 (sidebar)
|
||||
│
|
||||
▼
|
||||
v1.1-batch3 (panes) ────────────────────────┐
|
||||
│ │
|
||||
▼ │
|
||||
v1.1-batch3.5 (chips) ──────┐ │
|
||||
│ │ │
|
||||
▼ │ │
|
||||
v1.2 (chats-in-sessions) │ │
|
||||
│ │ │
|
||||
▼ │ │
|
||||
v1.2-project-ux │ │
|
||||
│ │ │
|
||||
▼ │ │
|
||||
v1.3 (tab-close) │ │
|
||||
│ │ │
|
||||
▼ │ │
|
||||
v1.4 (fork+delete+header) ◄──┼────────────────┘
|
||||
│ │
|
||||
▼ │
|
||||
v1.5 (refactor+tests+ctx) │
|
||||
│ │
|
||||
▼ │
|
||||
v1.5.1 (bootstrap hotfix) │
|
||||
│ │
|
||||
▼ │
|
||||
v1.6-mobile-pass │
|
||||
│ │
|
||||
▼ │
|
||||
v1.6.1-cleanup ◄─────────────┘
|
||||
│
|
||||
▼
|
||||
v1.7 (drag-drop) ◄── v1.1-batch3.5
|
||||
│
|
||||
▼
|
||||
v1.8 (settings)
|
||||
│
|
||||
├──▶ v1.9 (web search)
|
||||
│
|
||||
├──▶ v1.10 (agents)
|
||||
│
|
||||
└──▶ v1.11 (BooTerm) ◄── v1.1-batch3
|
||||
```
|
||||
|
||||
-----
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Verify previous version merged to `main`.
|
||||
1. Dispatch prompt via Paseo (Claude Code runs at `/opt/boocode`).
|
||||
1. Claude Code recon → blocking questions → implement → hand back.
|
||||
1. Review hand-back in separate Claude chat (spec compliance, code quality, drift, stale code).
|
||||
1. Deploy: `docker compose up --build -d`.
|
||||
1. Smoke test per the hand-back’s plan.
|
||||
1. Sam commits manually, pushes to Gitea, merges to `main`.
|
||||
1. Next version.
|
||||
|
||||
Sam reviews all diffs. Sam commits. Never git pull/push/commit on his behalf.
|
||||
Reference in New Issue
Block a user