diff --git a/apps/web/src/components/MessageBubble.tsx b/apps/web/src/components/MessageBubble.tsx index 019af39..3d15bb3 100644 --- a/apps/web/src/components/MessageBubble.tsx +++ b/apps/web/src/components/MessageBubble.tsx @@ -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 remarkGfm from 'remark-gfm'; import { Copy, RefreshCw, Check } from 'lucide-react'; import { toast } from 'sonner'; import type { Message } from '@/api/types'; import { api } from '@/api/client'; +import { sessionEvents } from '@/hooks/sessionEvents'; import { ToolCallCard } from './ToolCallCard'; 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( + + ); + 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 +// 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 ( + + {linkifyPaths(child, `${keyPrefix}-${i}`)} + + ); + } + 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 { message: Message; sessionId: string; @@ -55,7 +128,10 @@ function MarkdownBody({ content }: { content: string }) { ol: ({ children }) => (
    {children}
), - p: ({ children }) =>

{children}

, + li: ({ children }) =>
  • {linkifyChildren(children)}
  • , + p: ({ children }) => ( +

    {linkifyChildren(children)}

    + ), h1: ({ children }) =>

    {children}

    , h2: ({ children }) =>

    {children}

    , h3: ({ children }) =>

    {children}

    , @@ -73,7 +149,9 @@ function MarkdownBody({ content }: { content: string }) { {children} ), td: ({ children }) => ( - {children} + + {linkifyChildren(children)} + ), }} > diff --git a/apps/web/src/components/ProjectSidebar.tsx b/apps/web/src/components/ProjectSidebar.tsx index 095f61b..f62a743 100644 --- a/apps/web/src/components/ProjectSidebar.tsx +++ b/apps/web/src/components/ProjectSidebar.tsx @@ -14,6 +14,7 @@ import { api } from '@/api/client'; import { sessionEvents } from '@/hooks/sessionEvents'; import { useSidebar } from '@/hooks/useSidebar'; import type { SidebarProject } from '@/api/types'; +import { cn } from '@/lib/utils'; const EXPANDED_KEY = 'boocode.sidebar.expanded'; const MAX_VISIBLE_SESSIONS = 5; @@ -55,13 +56,29 @@ function relTime(iso: string): string { 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\/([^/]+)/); if (pm?.[1]) return pm[1]; const sm = pathname.match(/^\/session\/([^/]+)/); const sid = sm?.[1]; 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 { @@ -70,7 +87,8 @@ function activeSessionId(pathname: string): string | null { } export function ProjectSidebar() { - const { data, error, loading, retry } = useSidebar(); + const { data, error, loading, retry, activeSession: loadedActiveSession } = + useSidebar(); const [addOpen, setAddOpen] = useState(false); const [expanded, setExpanded] = useState>(() => readExpanded()); const navigate = useNavigate(); @@ -87,8 +105,8 @@ export function ProjectSidebar() { const projects = data?.projects ?? []; const activeProject = useMemo( - () => activeProjectId(location.pathname, projects), - [location.pathname, projects] + () => activeProjectId(location.pathname, projects, loadedActiveSession), + [location.pathname, projects, loadedActiveSession] ); const activeSession = useMemo( () => activeSessionId(location.pathname), @@ -173,11 +191,17 @@ export function ProjectSidebar() { type="button" aria-label={isExpanded ? 'Collapse' : 'Expand'} aria-expanded={isExpanded} + disabled={isActiveProject} onClick={(e) => { e.stopPropagation(); + if (isActiveProject) return; 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' + )} > lastIdx) out.push(text.slice(lastIdx, start)); + out.push( + + ); + 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) { const [open, setOpen] = useState(false); const tc = toolCall ?? message?.tool_calls?.[0]; @@ -48,7 +86,11 @@ export function ToolCallCard({ message, toolCall }: Props) { ) : output !== undefined ? (
    -              {typeof output === 'string' ? output : JSON.stringify(output, null, 2)}
    +              {linkifyOutput(
    +                typeof output === 'string'
    +                  ? output
    +                  : JSON.stringify(output, null, 2)
    +              )}
                 
    ) : (
    no result yet
    diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index b8d6ec5..d0b706f 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -1,43 +1,43 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link, useParams } from 'react-router-dom'; import { ChevronLeft } from 'lucide-react'; -import { toast } from 'sonner'; import { api } from '@/api/client'; import type { Session as SessionType } from '@/api/types'; -import { useSessionStream } from '@/hooks/useSessionStream'; import { sessionEvents } from '@/hooks/sessionEvents'; -import { MessageList } from '@/components/MessageList'; -import { ChatInput } from '@/components/ChatInput'; +import { Workspace } from '@/components/Workspace'; import { ModelPicker } from '@/components/ModelPicker'; export function Session() { const { id } = useParams<{ id: string }>(); - const stream = useSessionStream(id); const [session, setSession] = useState(null); const [name, setName] = useState(''); const [editingName, setEditingName] = useState(false); - const lastErrorRef = useRef(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(() => { if (!id) return; setSession(null); + let cancelled = false; api.sessions .get(id) .then((s) => { + if (cancelled) return; setSession(s); 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(() => {}); + return () => { + cancelled = true; + }; }, [id]); useEffect(() => { @@ -68,13 +68,6 @@ export function Session() { 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 (
    @@ -122,14 +115,11 @@ export function Session() { /> )}
    - {!stream.connected && ( - reconnecting… - )} - {id && } - - + {id && session && ( + + )} ); }