batch3 T8: chat->file click, Session.tsx rewires to Workspace, sidebar polish

- MessageBubble & ToolCallCard: detect path-like strings in rendered text
  via regex requiring slash+extension; clicks dispatch open_file_in_browser
- Session.tsx: now renders <Workspace sessionId projectId>; on mount,
  emits session_loaded so sidebar can highlight even deep-linked sessions
  not in the recent_sessions cache
- ProjectSidebar: active project's chevron visually disabled (50% opacity,
  cursor-not-allowed) and click no-op; activeSession from useSidebar used
  as fallback when active session isn't in cache

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 15:55:52 +00:00
parent 60a0036850
commit eca4aa8382
4 changed files with 174 additions and 40 deletions

View File

@@ -1,13 +1,86 @@
import { useState } from 'react'; import { Children, cloneElement, isValidElement, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown'; import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { Copy, RefreshCw, Check } from 'lucide-react'; import { Copy, RefreshCw, Check } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Message } from '@/api/types'; import type { Message } from '@/api/types';
import { api } from '@/api/client'; import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { ToolCallCard } from './ToolCallCard'; import { ToolCallCard } from './ToolCallCard';
import { CodeBlock } from './CodeBlock'; import { CodeBlock } from './CodeBlock';
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
// match, but `src/foo.ts` will). False positives at the edges are accepted
// per Sam's design decision (2026-05-14).
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
function isPathLike(s: string): boolean {
return s.includes('/');
}
function emitOpenFile(path: string): void {
sessionEvents.emit({ type: 'open_file_in_browser', path });
}
// Split a plain string into a flat array of strings and clickable button
// nodes for path-shaped substrings. If no matches, returns the original
// string verbatim (no array wrapping).
function linkifyPaths(text: string, keyPrefix: string): ReactNode {
const out: ReactNode[] = [];
let lastIdx = 0;
let idx = 0;
for (const match of text.matchAll(PATH_REGEX)) {
const matchedText = match[0];
const start = match.index ?? 0;
if (!isPathLike(matchedText)) continue;
if (start > lastIdx) out.push(text.slice(lastIdx, start));
out.push(
<button
key={`${keyPrefix}-${idx}`}
type="button"
onClick={() => emitOpenFile(matchedText)}
className="text-primary underline cursor-pointer hover:text-primary/80"
>
{matchedText}
</button>
);
lastIdx = start + matchedText.length;
idx += 1;
}
if (out.length === 0) return text;
if (lastIdx < text.length) out.push(text.slice(lastIdx));
return out;
}
// Walk react-markdown children, linkifying string text nodes. Children of
// <code> nodes (CodeBlock and inline code) are left untouched — the regex
// shouldn't run inside code spans.
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
const arr = Children.toArray(children);
return arr.map((child, i) => {
if (typeof child === 'string') {
return (
<span key={`${keyPrefix}-${i}`}>
{linkifyPaths(child, `${keyPrefix}-${i}`)}
</span>
);
}
if (isValidElement(child)) {
const el = child as ReactElement<{ children?: ReactNode }>;
// Skip inline/block code — paths in code spans aren't link targets.
if (el.type === 'code' || el.type === CodeBlock) return child;
const grandchildren = el.props.children;
if (grandchildren === undefined) return child;
return cloneElement(el, {
children: linkifyChildren(grandchildren, `${keyPrefix}-${i}`),
});
}
return child;
});
}
interface Props { interface Props {
message: Message; message: Message;
sessionId: string; sessionId: string;
@@ -55,7 +128,10 @@ function MarkdownBody({ content }: { content: string }) {
ol: ({ children }) => ( ol: ({ children }) => (
<ol className="list-decimal pl-5 space-y-1">{children}</ol> <ol className="list-decimal pl-5 space-y-1">{children}</ol>
), ),
p: ({ children }) => <p className="leading-relaxed">{children}</p>, li: ({ children }) => <li>{linkifyChildren(children)}</li>,
p: ({ children }) => (
<p className="leading-relaxed">{linkifyChildren(children)}</p>
),
h1: ({ children }) => <h1 className="text-base font-semibold mt-2">{children}</h1>, h1: ({ children }) => <h1 className="text-base font-semibold mt-2">{children}</h1>,
h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>, h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>,
h3: ({ children }) => <h3 className="text-sm font-semibold mt-1">{children}</h3>, h3: ({ children }) => <h3 className="text-sm font-semibold mt-1">{children}</h3>,
@@ -73,7 +149,9 @@ function MarkdownBody({ content }: { content: string }) {
<th className="border border-border px-2 py-1 text-left font-medium">{children}</th> <th className="border border-border px-2 py-1 text-left font-medium">{children}</th>
), ),
td: ({ children }) => ( td: ({ children }) => (
<td className="border border-border px-2 py-1">{children}</td> <td className="border border-border px-2 py-1">
{linkifyChildren(children)}
</td>
), ),
}} }}
> >

View File

@@ -14,6 +14,7 @@ import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { useSidebar } from '@/hooks/useSidebar'; import { useSidebar } from '@/hooks/useSidebar';
import type { SidebarProject } from '@/api/types'; import type { SidebarProject } from '@/api/types';
import { cn } from '@/lib/utils';
const EXPANDED_KEY = 'boocode.sidebar.expanded'; const EXPANDED_KEY = 'boocode.sidebar.expanded';
const MAX_VISIBLE_SESSIONS = 5; const MAX_VISIBLE_SESSIONS = 5;
@@ -55,13 +56,29 @@ function relTime(iso: string): string {
return `${Math.floor(mo / 12)}y`; return `${Math.floor(mo / 12)}y`;
} }
function activeProjectId(pathname: string, projects: SidebarProject[]): string | null { function activeProjectId(
pathname: string,
projects: SidebarProject[],
activeSession: { session_id: string; project_id: string } | null
): string | null {
const pm = pathname.match(/^\/project\/([^/]+)/); const pm = pathname.match(/^\/project\/([^/]+)/);
if (pm?.[1]) return pm[1]; if (pm?.[1]) return pm[1];
const sm = pathname.match(/^\/session\/([^/]+)/); const sm = pathname.match(/^\/session\/([^/]+)/);
const sid = sm?.[1]; const sid = sm?.[1];
if (!sid) return null; if (!sid) return null;
return projects.find((p) => p.recent_sessions.some((s) => s.id === sid))?.id ?? null; // Prefer the cache lookup so we resolve correctly even when an older
// activeSession (from a prior route) hasn't been cleared yet.
const fromCache = projects.find((p) =>
p.recent_sessions.some((s) => s.id === sid)
)?.id;
if (fromCache) return fromCache;
// Fallback: the session was loaded via deep link (not in cache) and
// emitted session_loaded — use that. Guard against stale values by
// matching the current URL sid.
if (activeSession && activeSession.session_id === sid) {
return activeSession.project_id;
}
return null;
} }
function activeSessionId(pathname: string): string | null { function activeSessionId(pathname: string): string | null {
@@ -70,7 +87,8 @@ function activeSessionId(pathname: string): string | null {
} }
export function ProjectSidebar() { export function ProjectSidebar() {
const { data, error, loading, retry } = useSidebar(); const { data, error, loading, retry, activeSession: loadedActiveSession } =
useSidebar();
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [expanded, setExpanded] = useState<Set<string>>(() => readExpanded()); const [expanded, setExpanded] = useState<Set<string>>(() => readExpanded());
const navigate = useNavigate(); const navigate = useNavigate();
@@ -87,8 +105,8 @@ export function ProjectSidebar() {
const projects = data?.projects ?? []; const projects = data?.projects ?? [];
const activeProject = useMemo( const activeProject = useMemo(
() => activeProjectId(location.pathname, projects), () => activeProjectId(location.pathname, projects, loadedActiveSession),
[location.pathname, projects] [location.pathname, projects, loadedActiveSession]
); );
const activeSession = useMemo( const activeSession = useMemo(
() => activeSessionId(location.pathname), () => activeSessionId(location.pathname),
@@ -173,11 +191,17 @@ export function ProjectSidebar() {
type="button" type="button"
aria-label={isExpanded ? 'Collapse' : 'Expand'} aria-label={isExpanded ? 'Collapse' : 'Expand'}
aria-expanded={isExpanded} aria-expanded={isExpanded}
disabled={isActiveProject}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (isActiveProject) return;
toggle(p.id); toggle(p.id);
}} }}
className="flex items-center justify-center size-4 shrink-0 opacity-70 hover:opacity-100" className={cn(
'flex items-center justify-center size-4 shrink-0 opacity-70 hover:opacity-100',
isActiveProject &&
'opacity-50 cursor-not-allowed hover:opacity-50'
)}
> >
<ChevronRight <ChevronRight
className={`size-3.5 transition-transform ${isExpanded ? 'rotate-90' : ''}`} className={`size-3.5 transition-transform ${isExpanded ? 'rotate-90' : ''}`}

View File

@@ -1,12 +1,50 @@
import { useState } from 'react'; import { useState } from 'react';
import type { ReactNode } from 'react';
import { ChevronRight, Wrench } from 'lucide-react'; import { ChevronRight, Wrench } from 'lucide-react';
import type { Message, ToolCall } from '@/api/types'; import type { Message, ToolCall } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
interface Props { interface Props {
message?: Message; message?: Message;
toolCall?: ToolCall; toolCall?: ToolCall;
} }
// Same regex/heuristic as MessageBubble: paths ending in `.ext` with at
// least one `/`. Linkifies file paths emitted by tools like grep / find_files
// so they're clickable.
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
function linkifyOutput(text: string): ReactNode[] {
const out: ReactNode[] = [];
let lastIdx = 0;
let idx = 0;
for (const match of text.matchAll(PATH_REGEX)) {
const matchedText = match[0];
const start = match.index ?? 0;
if (!matchedText.includes('/')) continue;
if (start > lastIdx) out.push(text.slice(lastIdx, start));
out.push(
<button
key={idx}
type="button"
onClick={() =>
sessionEvents.emit({
type: 'open_file_in_browser',
path: matchedText,
})
}
className="text-primary underline cursor-pointer hover:text-primary/80"
>
{matchedText}
</button>
);
lastIdx = start + matchedText.length;
idx += 1;
}
if (lastIdx < text.length) out.push(text.slice(lastIdx));
return out.length > 0 ? out : [text];
}
export function ToolCallCard({ message, toolCall }: Props) { export function ToolCallCard({ message, toolCall }: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const tc = toolCall ?? message?.tool_calls?.[0]; const tc = toolCall ?? message?.tool_calls?.[0];
@@ -48,7 +86,11 @@ export function ToolCallCard({ message, toolCall }: Props) {
</pre> </pre>
) : output !== undefined ? ( ) : output !== undefined ? (
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto max-h-72 overflow-y-auto"> <pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto max-h-72 overflow-y-auto">
{typeof output === 'string' ? output : JSON.stringify(output, null, 2)} {linkifyOutput(
typeof output === 'string'
? output
: JSON.stringify(output, null, 2)
)}
</pre> </pre>
) : ( ) : (
<div className="text-xs text-muted-foreground">no result yet</div> <div className="text-xs text-muted-foreground">no result yet</div>

View File

@@ -1,43 +1,43 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { Session as SessionType } from '@/api/types'; import type { Session as SessionType } from '@/api/types';
import { useSessionStream } from '@/hooks/useSessionStream';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { MessageList } from '@/components/MessageList'; import { Workspace } from '@/components/Workspace';
import { ChatInput } from '@/components/ChatInput';
import { ModelPicker } from '@/components/ModelPicker'; import { ModelPicker } from '@/components/ModelPicker';
export function Session() { export function Session() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const stream = useSessionStream(id);
const [session, setSession] = useState<SessionType | null>(null); const [session, setSession] = useState<SessionType | null>(null);
const [name, setName] = useState(''); const [name, setName] = useState('');
const [editingName, setEditingName] = useState(false); const [editingName, setEditingName] = useState(false);
const lastErrorRef = useRef<string | null>(null);
useEffect(() => {
if (stream.error && stream.error !== lastErrorRef.current) {
lastErrorRef.current = stream.error;
toast.error(stream.error);
}
if (!stream.error) {
lastErrorRef.current = null;
}
}, [stream.error]);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
setSession(null); setSession(null);
let cancelled = false;
api.sessions api.sessions
.get(id) .get(id)
.then((s) => { .then((s) => {
if (cancelled) return;
setSession(s); setSession(s);
setName(s.name); setName(s.name);
// Emit unconditionally — the sidebar's session_loaded handler
// updates activeSession; redundant when the session is already in
// the recent_sessions cache but harmless. This lets the sidebar
// highlight the parent project for deep-linked sessions that
// aren't in the cache.
sessionEvents.emit({
type: 'session_loaded',
session_id: id,
project_id: s.project_id,
});
}) })
.catch(() => {}); .catch(() => {});
return () => {
cancelled = true;
};
}, [id]); }, [id]);
useEffect(() => { useEffect(() => {
@@ -68,13 +68,6 @@ export function Session() {
setEditingName(false); setEditingName(false);
} }
async function handleSend(content: string) {
if (!id) return;
await api.messages.send(id, content);
}
const streaming = stream.messages.some((m) => m.status === 'streaming');
return ( return (
<div className="flex-1 flex flex-col min-h-0"> <div className="flex-1 flex flex-col min-h-0">
<header className="border-b px-4 py-2 flex items-center gap-2 shrink-0"> <header className="border-b px-4 py-2 flex items-center gap-2 shrink-0">
@@ -122,14 +115,11 @@ export function Session() {
/> />
)} )}
</div> </div>
{!stream.connected && (
<span className="text-xs text-muted-foreground">reconnecting</span>
)}
</header> </header>
{id && <MessageList messages={stream.messages} sessionId={id} />} {id && session && (
<Workspace sessionId={id} projectId={session.project_id} />
<ChatInput disabled={streaming} onSend={handleSend} /> )}
</div> </div>
); );
} }