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 && (
+
+ )}
);
}