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

@@ -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<Set<string>>(() => 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'
)}
>
<ChevronRight
className={`size-3.5 transition-transform ${isExpanded ? 'rotate-90' : ''}`}