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 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(
<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 {
message: Message;
sessionId: string;
@@ -55,7 +128,10 @@ function MarkdownBody({ content }: { content: string }) {
ol: ({ children }) => (
<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>,
h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>,
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>
),
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>
),
}}
>