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:
@@ -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>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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' : ''}`}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user