feat: improve chat streaming UX and add Mermaid diagram rendering (#438)

* feat: show current branch in empty chat state

* fix: display current git branch for worktrees and update them with branch change

* refactor: improve read tool output parsing with structured data

* feat: add support for message part delta events

* fix(chat): improve streaming rendering, scroll behavior, and assistant action visibility

* feat: Add mermaid diagram support to chat markdown rendering

* fix: update table download functionality to include success notification and remove unused MarkdownRenderer import

* refactor: streamline Streamdown component props for improved readability

* fix: preserve Streamdown code-block markers and use native Tauri cache clearing

* feat: add context overview panel to view conversation details
This commit is contained in:
Bohdan Triapitsyn
2026-02-17 18:25:03 +02:00
committed by GitHub
parent 138772e66e
commit 4d71bb27eb
31 changed files with 1391 additions and 182 deletions
+45
View File
@@ -117,6 +117,8 @@ const MENU_ITEM_REPORT_BUG_ID: &str = "menu_report_bug";
const MENU_ITEM_REQUEST_FEATURE_ID: &str = "menu_request_feature";
#[cfg(target_os = "macos")]
const MENU_ITEM_JOIN_DISCORD_ID: &str = "menu_join_discord";
#[cfg(target_os = "macos")]
const MENU_ITEM_CLEAR_CACHE_ID: &str = "menu_clear_cache";
#[cfg(target_os = "macos")]
const GITHUB_BUG_REPORT_URL: &str =
@@ -268,6 +270,9 @@ fn build_macos_menu<R: tauri::Runtime>(
let join_discord =
MenuItem::with_id(app, MENU_ITEM_JOIN_DISCORD_ID, "Join Discord", true, None::<&str>)?;
let clear_cache =
MenuItem::with_id(app, MENU_ITEM_CLEAR_CACHE_ID, "Clear Cache", true, None::<&str>)?;
let theme_submenu =
Submenu::with_items(app, "Theme", true, &[&theme_light, &theme_dark, &theme_system])?;
@@ -293,6 +298,8 @@ fn build_macos_menu<R: tauri::Runtime>(
&help_dialog,
&download_logs,
&PredefinedMenuItem::separator(app)?,
&clear_cache,
&PredefinedMenuItem::separator(app)?,
&report_bug,
&request_feature,
&PredefinedMenuItem::separator(app)?,
@@ -412,6 +419,35 @@ fn desktop_set_auto_worktree_menu(app: tauri::AppHandle, enabled: bool) -> Resul
Ok(())
}
#[tauri::command]
fn desktop_clear_cache(app: tauri::AppHandle) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let mut failures: Vec<String> = Vec::new();
for (label, window) in app.webview_windows() {
if let Err(err) = window.clear_all_browsing_data() {
failures.push(format!("{label}: {err}"));
}
}
if !failures.is_empty() {
return Err(format!("Failed to clear browsing data for some windows: {}", failures.join("; ")));
}
// Reload all windows after clearing persisted browsing data so in-memory state is reset too.
eval_in_all_windows(&app, "window.location.reload();");
log::info!("[desktop] Cleared all webview browsing data and reloaded windows");
return Ok(());
}
#[cfg(not(target_os = "macos"))]
{
Err("desktop_clear_cache is only supported on macOS".to_string())
}
}
#[tauri::command]
fn desktop_open_path(path: String, app: Option<String>) -> Result<(), String> {
let trimmed = path.trim();
@@ -2455,6 +2491,14 @@ fn main() {
}
if id == MENU_ITEM_DOWNLOAD_LOGS_ID {
dispatch_menu_action(app, "download-logs");
return;
}
if id == MENU_ITEM_CLEAR_CACHE_ID {
let app = app.clone();
tauri::async_runtime::spawn_blocking(move || {
let _ = crate::desktop_clear_cache(app);
});
return;
}
}
})
@@ -2498,6 +2542,7 @@ fn main() {
desktop_new_window,
desktop_new_window_at_url,
desktop_set_auto_worktree_menu,
desktop_clear_cache,
desktop_open_path,
desktop_filter_installed_apps,
desktop_get_installed_apps,
+4 -2
View File
@@ -37,7 +37,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "^1.1.65",
"@opencode-ai/sdk": "^1.2.5",
"@pierre/diffs": "1.1.0-beta.13",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
@@ -50,6 +50,8 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0",
"@streamdown/code": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@types/react-syntax-highlighter": "^15.5.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -67,7 +69,7 @@
"react-syntax-highlighter": "^15.6.6",
"simple-git": "^3.28.0",
"sonner": "^2.0.7",
"streamdown": "^1.6.10",
"streamdown": "^2.2.0",
"strip-json-comments": "^5.0.3",
"tailwind-merge": "^3.3.1",
"yaml": "^2.8.1",
@@ -341,10 +341,10 @@ export const ChatContainer: React.FC = () => {
if (!shouldSkipScroll) {
if (typeof window === 'undefined') {
scrollToBottom();
scrollToBottom({ instant: true });
} else {
window.requestAnimationFrame(() => {
scrollToBottom();
scrollToBottom({ instant: true });
});
}
}
@@ -372,7 +372,7 @@ export const ChatContainer: React.FC = () => {
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
<div className="flex-1 flex items-center justify-center">
<ChatEmptyState />
<ChatEmptyState showDraftContext />
</div>
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
<ChatInput scrollToBottom={scrollToBottom} />
@@ -1,8 +1,12 @@
import React from 'react';
import { RiGitBranchLine } from '@remixicon/react';
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { TextLoop } from '@/components/ui/TextLoop';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useGitStatus, useGitStore } from '@/stores/useGitStore';
const phrases = [
"Fix the failing tests",
@@ -23,15 +27,51 @@ const phrases = [
"Add type definitions",
];
const ChatEmptyState: React.FC = () => {
interface ChatEmptyStateProps {
showDraftContext?: boolean;
}
const ChatEmptyState: React.FC<ChatEmptyStateProps> = ({
showDraftContext = false,
}) => {
const { currentTheme } = useThemeSystem();
const { git } = useRuntimeAPIs();
const effectiveDirectory = useEffectiveDirectory();
const { setActiveDirectory, fetchStatus } = useGitStore();
const gitStatus = useGitStatus(effectiveDirectory ?? null);
// Use theme's muted foreground for secondary text
const textColor = currentTheme?.colors?.surface?.mutedForeground || 'var(--muted-foreground)';
const branchName = typeof gitStatus?.current === 'string' && gitStatus.current.trim().length > 0
? gitStatus.current.trim()
: null;
React.useEffect(() => {
if (!showDraftContext || !effectiveDirectory) {
return;
}
setActiveDirectory(effectiveDirectory);
const state = useGitStore.getState().directories.get(effectiveDirectory);
if (!state?.status && state?.isGitRepo !== false) {
void fetchStatus(effectiveDirectory, git, { silent: true });
}
}, [effectiveDirectory, fetchStatus, git, setActiveDirectory, showDraftContext]);
return (
<div className="flex flex-col items-center justify-center min-h-full w-full gap-6">
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
{showDraftContext && (
<div className="max-w-[calc(100%-2rem)] flex flex-col items-center gap-1">
{branchName && (
<div className="inline-flex items-center gap-1 text-body-md" style={{ color: textColor }}>
<RiGitBranchLine className="h-4 w-4 shrink-0" />
<span className="overflow-hidden whitespace-nowrap" title={branchName}>{branchName}</span>
</div>
)}
</div>
)}
<TextLoop
className="text-body-md"
interval={4}
@@ -1,9 +1,13 @@
import React from 'react';
import { Streamdown } from 'streamdown';
import { code } from '@streamdown/code';
import { mermaid } from '@streamdown/mermaid';
import 'streamdown/styles.css';
import { FadeInOnReveal } from './message/FadeInOnReveal';
import type { Part } from '@opencode-ai/sdk/v2';
import { cn } from '@/lib/utils';
import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
import { toast } from '@/components/ui';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
@@ -116,6 +120,45 @@ const useMarkdownShikiThemes = (): readonly [string | object, string | object] =
return isVSCode ? themes : fallbackThemes;
};
const useStreamdownMermaidOptions = () => {
const themeSystem = useOptionalThemeSystem();
const fallbackLight = getDefaultTheme(false);
const fallbackDark = getDefaultTheme(true);
const currentTheme = themeSystem?.currentTheme
?? (typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
? fallbackDark
: fallbackLight);
const mermaidRenderKey = `${currentTheme.metadata.id}:${currentTheme.metadata.variant}:${themeSystem?.themeMode ?? 'fallback'}`;
const options = React.useMemo(() => {
const isDark = currentTheme.metadata.variant === 'dark';
return {
config: {
theme: isDark ? 'dark' : 'base',
themeVariables: {
primaryColor: currentTheme.colors.surface.elevated,
primaryTextColor: currentTheme.colors.surface.foreground,
primaryBorderColor: currentTheme.colors.interactive.border,
lineColor: currentTheme.colors.interactive.border,
secondaryColor: currentTheme.colors.surface.muted,
tertiaryColor: currentTheme.colors.surface.subtle,
background: currentTheme.colors.surface.background,
mainBkg: currentTheme.colors.surface.elevated,
nodeTextColor: currentTheme.colors.surface.foreground,
edgeLabelBackground: currentTheme.colors.surface.background,
},
},
};
}, [currentTheme]);
return React.useMemo(
() => ({ options, mermaidRenderKey }),
[mermaidRenderKey, options],
);
};
// Table utility functions
const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => {
const headers: string[] = [];
@@ -280,21 +323,22 @@ const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement |
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleDownload = (format: 'csv' | 'markdown') => {
const tableEl = tableRef.current?.querySelector('table');
if (!tableEl) return;
try {
const data = extractTableData(tableEl);
const content = format === 'csv' ? tableToCSV(data) : tableToMarkdown(data);
const filename = format === 'csv' ? 'table.csv' : 'table.md';
const mimeType = format === 'csv' ? 'text/csv' : 'text/markdown';
downloadFile(filename, content, mimeType);
setShowMenu(false);
} catch (err) {
console.error('Failed to download table:', err);
}
};
const handleDownload = (format: 'csv' | 'markdown') => {
const tableEl = tableRef.current?.querySelector('table');
if (!tableEl) return;
try {
const data = extractTableData(tableEl);
const content = format === 'csv' ? tableToCSV(data) : tableToMarkdown(data);
const filename = format === 'csv' ? 'table.csv' : 'table.md';
const mimeType = format === 'csv' ? 'text/csv' : 'text/markdown';
downloadFile(filename, content, mimeType);
setShowMenu(false);
toast.success(`Table downloaded as ${format.toUpperCase()}`);
} catch (err) {
console.error('Failed to download table:', err);
}
};
return (
<div className="relative" ref={menuRef}>
@@ -348,9 +392,35 @@ type CodeBlockWrapperProps = React.HTMLAttributes<HTMLPreElement> & {
children?: React.ReactNode;
};
const getMermaidInfo = (children: React.ReactNode): { isMermaid: boolean; source: string } => {
if (!React.isValidElement(children)) return { isMermaid: false, source: '' };
const props = children.props as Record<string, unknown> | undefined;
const className = typeof props?.className === 'string' ? props.className : '';
if (!className.includes('language-mermaid')) return { isMermaid: false, source: '' };
// Extract raw mermaid source from the code element's children
const codeChildren = props?.children;
let source = '';
if (typeof codeChildren === 'string') {
source = codeChildren;
} else if (React.isValidElement(codeChildren)) {
const innerProps = codeChildren.props as Record<string, unknown> | undefined;
if (typeof innerProps?.children === 'string') source = innerProps.children;
}
return { isMermaid: true, source };
};
const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className, style, ...props }) => {
const [copied, setCopied] = React.useState(false);
const codeRef = React.useRef<HTMLDivElement>(null);
const mermaidInfo = getMermaidInfo(children);
const codeChild = React.useMemo(
() => (
React.isValidElement(children)
? React.cloneElement(children as React.ReactElement<Record<string, unknown>>, { 'data-block': true })
: children
),
[children],
);
const normalizedStyle = React.useMemo<React.CSSProperties | undefined>(() => {
if (!style) return style;
@@ -396,6 +466,11 @@ const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className
return next;
}, [style]);
// Mermaid blocks get their own controls via MermaidWrapper — skip the code copy button.
if (mermaidInfo.isMermaid) {
return <MermaidWrapper source={mermaidInfo.source}>{codeChild}</MermaidWrapper>;
}
const getCodeContent = (): string => {
if (!codeRef.current) return '';
const codeEl = codeRef.current.querySelector('code');
@@ -422,7 +497,7 @@ const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className
className={cn(className)}
style={normalizedStyle}
>
{children}
{codeChild}
</pre>
<div className="absolute top-1 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
@@ -442,6 +517,88 @@ const streamdownComponents = {
table: TableWrapper,
};
const streamdownPlugins = {
code,
mermaid,
};
const streamdownControls = {
code: false,
table: false,
mermaid: {
download: false,
copy: false,
fullscreen: false,
panZoom: false,
},
};
// Mermaid copy button — copies raw mermaid source
const MermaidCopyButton: React.FC<{ source: string }> = ({ source }) => {
const [copied, setCopied] = React.useState(false);
const handleCopy = async () => {
if (!source) return;
try {
await navigator.clipboard.writeText(source);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy diagram:', err);
}
};
return (
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Copy diagram source"
>
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
);
};
// Mermaid download button — downloads rendered SVG
const MermaidDownloadButton: React.FC<{ containerRef: React.RefObject<HTMLDivElement | null> }> = ({ containerRef }) => {
const handleDownload = () => {
const svgEl = containerRef.current?.querySelector('svg');
if (!(svgEl instanceof SVGElement)) return;
const serializer = new XMLSerializer();
let markup = serializer.serializeToString(svgEl);
if (!markup.includes('xmlns=')) {
markup = markup.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
}
downloadFile('diagram.svg', markup, 'image/svg+xml');
toast.success('Diagram downloaded');
};
return (
<button
onClick={handleDownload}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Download diagram"
>
<RiDownloadLine className="size-3.5" />
</button>
);
};
// Mermaid wrapper with custom controls (same pattern as TableWrapper)
const MermaidWrapper: React.FC<{ children: React.ReactNode; source: string }> = ({ children, source }) => {
const containerRef = React.useRef<HTMLDivElement>(null);
return (
<div className="group relative" ref={containerRef}>
{children}
<div className="absolute top-1 right-2 z-20 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<MermaidDownloadButton containerRef={containerRef} />
<MermaidCopyButton source={source} />
</div>
</div>
);
};
export type MarkdownVariant = 'assistant' | 'tool';
interface MarkdownRendererProps {
@@ -464,6 +621,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
variant = 'assistant',
}) => {
const shikiThemes = useMarkdownShikiThemes();
const { options: mermaidOptions, mermaidRenderKey } = useStreamdownMermaidOptions();
const componentKey = React.useMemo(() => {
const signature = part?.id ? `part-${part.id}` : `message-${messageId}`;
return `markdown-${signature}`;
@@ -476,12 +634,15 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
const markdownContent = (
<div className={cn('break-words', className)}>
<Streamdown
mode={isStreaming ? 'streaming' : 'static'}
shikiTheme={shikiThemes}
className={streamdownClassName}
controls={{ code: false, table: false }}
components={streamdownComponents}
>
key={`streamdown-${componentKey}-${mermaidRenderKey}`}
mode={isStreaming ? 'streaming' : 'static'}
shikiTheme={shikiThemes}
className={streamdownClassName}
controls={streamdownControls}
plugins={streamdownPlugins}
mermaid={mermaidOptions}
components={streamdownComponents}
>
{content}
</Streamdown>
</div>
@@ -504,6 +665,7 @@ export const SimpleMarkdownRenderer: React.FC<{
variant?: MarkdownVariant;
}> = ({ content, className, variant = 'assistant' }) => {
const shikiThemes = useMarkdownShikiThemes();
const { options: mermaidOptions, mermaidRenderKey } = useStreamdownMermaidOptions();
const streamdownClassName = variant === 'tool'
? 'streamdown-content streamdown-tool'
@@ -512,10 +674,13 @@ export const SimpleMarkdownRenderer: React.FC<{
return (
<div className={cn('break-words', className)}>
<Streamdown
key={`streamdown-simple-${mermaidRenderKey}`}
mode="static"
shikiTheme={shikiThemes}
className={streamdownClassName}
controls={{ code: false, table: false }}
controls={streamdownControls}
plugins={streamdownPlugins}
mermaid={mermaidOptions}
components={streamdownComponents}
>
{content}
@@ -243,19 +243,25 @@ const isActivityStandaloneTool = (toolName: unknown): boolean => {
};
const extractFinalAssistantText = (turn: Turn): string | undefined => {
for (const assistantMsg of turn.assistantMessages) {
for (let messageIndex = turn.assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const assistantMsg = turn.assistantMessages[messageIndex];
if (!assistantMsg) continue;
const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish;
if (infoFinish === 'stop') {
const textPart = assistantMsg.parts.find(p => p.type === 'text');
if (textPart) {
const textContent = (textPart as { text?: string | null | undefined }).text ??
(textPart as { content?: string | null | undefined }).content;
if (typeof textContent === 'string' && textContent.trim().length > 0) {
return textContent;
}
if (infoFinish !== 'stop') continue;
for (let partIndex = assistantMsg.parts.length - 1; partIndex >= 0; partIndex -= 1) {
const part = assistantMsg.parts[partIndex];
if (!part || part.type !== 'text') continue;
const textContent = (part as { text?: string | null | undefined }).text ??
(part as { content?: string | null | undefined }).content;
if (typeof textContent === 'string' && textContent.trim().length > 0) {
return textContent;
}
}
}
return undefined;
};
@@ -102,21 +102,25 @@ export const detectTurns = (messages: ChatMessageEntry[]): Turn[] => {
};
const extractFinalAssistantText = (turn: Turn): string | undefined => {
for (let messageIndex = turn.assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const assistantMsg = turn.assistantMessages[messageIndex];
if (!assistantMsg) continue;
for (const assistantMsg of turn.assistantMessages) {
const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish;
if (infoFinish !== 'stop') continue;
if (infoFinish === 'stop') {
const textPart = assistantMsg.parts.find(p => p.type === 'text');
if (textPart) {
const textContent = (textPart as { text?: string | null | undefined }).text ??
(textPart as { content?: string | null | undefined }).content;
if (typeof textContent === 'string' && textContent.trim().length > 0) {
return textContent;
}
for (let partIndex = assistantMsg.parts.length - 1; partIndex >= 0; partIndex -= 1) {
const part = assistantMsg.parts[partIndex];
if (!part || part.type !== 'text') continue;
const textContent = (part as { text?: string | null | undefined }).text ??
(part as { content?: string | null | undefined }).content;
if (typeof textContent === 'string' && textContent.trim().length > 0) {
return textContent;
}
}
}
return undefined;
};
@@ -329,7 +329,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
const hasStopFinish = messageFinish === 'stop';
// TTS for message playback
const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS();
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
@@ -16,6 +16,7 @@ import {
renderWebSearchOutput,
formatInputForDisplay,
parseDiffToUnified,
parseReadToolOutput,
type UnifiedDiffHunk,
type SideBySideDiffHunk,
type SideBySideDiffLine,
@@ -740,33 +741,41 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
}
if (tool === 'read') {
const lines = popup.content.split('\n');
const parsedReadOutput = parseReadToolOutput(popup.content);
const inputMeta = popup.metadata?.input;
const inputObj = typeof inputMeta === 'object' && inputMeta !== null ? (inputMeta as Record<string, unknown>) : {};
const offset = typeof inputObj.offset === 'number' ? inputObj.offset : 0;
const limit = typeof inputObj.limit === 'number' ? inputObj.limit : undefined;
const isInfoMessage = (line: string) => line.trim().startsWith('(');
let fallbackLineCursor = offset;
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
return (
<div>
{lines.map((line: string, idx: number) => {
{parsedReadOutput.lines.map((line, idx: number) => {
if (line.lineNumber !== null) {
fallbackLineCursor = line.lineNumber;
}
const isInfo = isInfoMessage(line);
const shouldAssignFallbackLineNumber =
parsedReadOutput.type === 'file'
&& !hasExplicitLineNumbers
&& line.lineNumber === null
&& !line.isInfo;
const lineNumber = offset + idx + 1;
const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallbackLineNumber
? (fallbackLineCursor += 1)
: null);
const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit);
const shouldShowLineNumber = !line.isInfo && effectiveLineNumber !== null;
return (
<div key={idx} className={`typography-code font-mono flex ${isInfo ? 'text-muted-foreground/70 italic' : ''}`}>
<div key={idx} className={`typography-code font-mono flex ${line.isInfo ? 'text-muted-foreground/70 italic' : ''}`}>
<span className="w-12 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}>
{shouldShowLineNumber ? lineNumber : ''}
{shouldShowLineNumber ? effectiveLineNumber : ''}
</span>
<div className="flex-1 min-w-0">
{isInfo ? (
<div className="whitespace-pre-wrap break-words">{line}</div>
{line.isInfo ? (
<div className="whitespace-pre-wrap break-words">{line.text}</div>
) : (
<SyntaxHighlighter
style={syntaxTheme}
@@ -794,7 +803,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
},
}}
>
{line}
{line.text}
</SyntaxHighlighter>
)}
</div>
@@ -24,6 +24,7 @@ import {
formatEditOutput,
detectLanguageFromOutput,
formatInputForDisplay,
parseReadToolOutput,
} from '../toolRenderers';
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
@@ -808,31 +809,40 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
if (hasStringOutput && outputString.trim()) {
if (part.tool === 'read') {
const formattedOutput = formatEditOutput(outputString, part.tool, metadata);
const lines = formattedOutput.split('\n');
const parsedReadOutput = parseReadToolOutput(outputString);
const offset = typeof input?.offset === 'number' ? input.offset : 0;
const limit = typeof input?.limit === 'number' ? input.limit : undefined;
const isInfoMessage = (line: string) => line.trim().startsWith('(');
const contentForLanguage = parsedReadOutput.lines.map((line) => line.text).join('\n');
let fallbackLineCursor = offset;
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
return renderScrollableBlock(
<div className="typography-code w-full min-w-0 space-y-1">
{lines.map((line: string, idx: number) => {
const isInfo = isInfoMessage(line);
const lineNumber = offset + idx + 1;
const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit);
{parsedReadOutput.lines.map((line, idx) => {
if (line.lineNumber !== null) {
fallbackLineCursor = line.lineNumber;
}
const shouldAssignFallbackLineNumber =
parsedReadOutput.type === 'file'
&& !hasExplicitLineNumbers
&& line.lineNumber === null
&& !line.isInfo;
const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallbackLineNumber
? (fallbackLineCursor += 1)
: null);
const shouldShowLineNumber = !line.isInfo && effectiveLineNumber !== null;
return (
<div key={idx} className={cn('typography-code font-mono flex w-full min-w-0', isInfo && 'text-muted-foreground/70 italic')}>
<div key={idx} className={cn('typography-code font-mono flex w-full min-w-0', line.isInfo && 'text-muted-foreground/70 italic')}>
<span className="w-10 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}>
{shouldShowLineNumber ? lineNumber : ''}
{shouldShowLineNumber ? effectiveLineNumber : ''}
</span>
<div className="flex-1 min-w-0">
{isInfo ? (
<div className="whitespace-pre-wrap break-words">{line}</div>
{line.isInfo ? (
<div className="whitespace-pre-wrap break-words">{line.text}</div>
) : (
<SyntaxHighlighter
style={syntaxTheme}
language={detectLanguageFromOutput(formattedOutput, part.tool, input as Record<string, unknown>)}
language={detectLanguageFromOutput(contentForLanguage, part.tool, input as Record<string, unknown>)}
PreTag="div"
wrapLines
wrapLongLines
@@ -855,7 +865,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
},
}}
>
{line}
{line.text}
</SyntaxHighlighter>
)}
</div>
@@ -1073,6 +1083,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
const justificationText = React.useMemo(() => {
if (!showTextJustificationActivity) return null;
if (part.tool === 'apply_patch') return null;
// Get title or description from state - this is the "yapping" text like "Shows system information"
const title = (stateWithData as { title?: string }).title;
if (typeof title === 'string' && title.trim().length > 0) {
@@ -1083,7 +1094,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
return inputDesc;
}
return null;
}, [showTextJustificationActivity, stateWithData, input]);
}, [showTextJustificationActivity, part.tool, stateWithData, input]);
const runtime = React.useContext(RuntimeAPIContext);
@@ -44,6 +44,81 @@ export const formatEditOutput = (output: string, toolName: string, metadata?: Re
return cleaned;
};
export interface ParsedReadOutputLine {
text: string;
lineNumber: number | null;
isInfo: boolean;
}
export interface ParsedReadToolOutput {
type: 'file' | 'directory' | 'unknown';
lines: ParsedReadOutputLine[];
}
export const parseReadToolOutput = (output: string): ParsedReadToolOutput => {
const typeMatch = output.match(/<type>(file|directory)<\/type>/i);
const detectedType = (typeMatch?.[1]?.toLowerCase() ?? 'unknown') as ParsedReadToolOutput['type'];
const contentMatch = output.match(/<content>([\s\S]*?)<\/content>/i);
const rawContent = contentMatch?.[1] ?? output;
const normalizedContent = rawContent.replace(/\r\n/g, '\n');
const rawLines = normalizedContent.split('\n');
const isTruncationInfoLine = (text: string): boolean => {
return /\(\s*File has more lines\..*offset.*\)/i.test(text.trim());
};
const parsedLines = rawLines.map((line): ParsedReadOutputLine => {
const trimmed = line.trim();
const isInfo = (trimmed.startsWith('(') && trimmed.endsWith(')')) || isTruncationInfoLine(trimmed);
if (detectedType !== 'directory') {
const numberedMatch = line.match(/^(\d+):\s?(.*)$/);
if (numberedMatch) {
const numberedText = numberedMatch[2];
const numberedTrimmed = numberedText.trim();
const numberedIsInfo =
(numberedTrimmed.startsWith('(') && numberedTrimmed.endsWith(')'))
|| isTruncationInfoLine(numberedTrimmed);
return {
lineNumber: numberedIsInfo ? null : Number(numberedMatch[1]),
text: numberedText,
isInfo: numberedIsInfo,
};
}
}
return {
lineNumber: null,
text: line,
isInfo,
};
});
const lines = parsedLines.filter((line, index, arr) => {
if (line.text.trim().length > 0) {
return true;
}
const prev = arr[index - 1];
const next = arr[index + 1];
const adjacentToInfo = Boolean(prev?.isInfo || next?.isInfo);
const hasNumber = line.lineNumber !== null;
// Drop numbered blank lines wrapped around helper/info rows.
if (adjacentToInfo && hasNumber) {
return false;
}
return true;
});
return {
type: detectedType,
lines,
};
};
export const renderListOutput = (output: string, options?: { unstyled?: boolean }) => {
try {
const lines = output.trim().split('\n').filter(Boolean);
@@ -7,6 +7,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { cn } from '@/lib/utils';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useUIStore } from '@/stores/useUIStore';
import { ContextPanelContent } from './ContextSidebarTab';
const CONTEXT_PANEL_MIN_WIDTH = 360;
const CONTEXT_PANEL_MAX_WIDTH = 1400;
@@ -144,14 +145,16 @@ export const ContextPanel: React.FC = () => {
const activeFilePath = useFilesViewTabsStore((state) => (directoryKey ? (state.byRoot[directoryKey]?.selectedPath ?? null) : null));
const panelTitle = panelState?.mode === 'diff' ? 'Diff' : panelState?.mode === 'file' ? 'File' : 'Panel';
const effectivePath = panelState?.mode === 'file' ? (activeFilePath ?? panelState?.targetPath ?? null) : (panelState?.targetPath ?? null);
const panelTitle = panelState?.mode === 'diff' ? 'Diff' : panelState?.mode === 'file' ? 'File' : panelState?.mode === 'context' ? 'Context' : 'Panel';
const effectivePath = panelState?.mode === 'file' ? (activeFilePath ?? panelState?.targetPath ?? null) : panelState?.mode === 'context' ? null : (panelState?.targetPath ?? null);
const pathLabel = getRelativePathLabel(effectivePath, effectiveDirectory);
const content = panelState?.mode === 'diff'
? <DiffView hideStackedFileSidebar stackedDefaultCollapsedAll hideFileSelector pinSelectedFileHeaderToTopOnNavigate />
: panelState?.mode === 'file'
? <FilesView mode="editor-only" />
: panelState?.mode === 'context'
? <ContextPanelContent />
: null;
const header = (
@@ -0,0 +1,606 @@
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { deriveMessageRole } from '@/components/chat/message/messageRole';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
type SessionMessage = { info: Message; parts: Part[] };
const EMPTY_SESSION_MESSAGES: SessionMessage[] = [];
type ProviderModelLike = {
id?: string;
name?: string;
limit?: { context?: number };
};
type ProviderLike = {
id?: string;
name?: string;
models?: ProviderModelLike[];
};
type TokenBreakdown = {
input: number;
output: number;
reasoning: number;
cacheRead: number;
cacheWrite: number;
total: number;
};
type ContextBuckets = {
user: number;
assistant: number;
tool: number;
other: number;
};
const EMPTY_BREAKDOWN: TokenBreakdown = {
input: 0,
output: 0,
reasoning: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
};
const EMPTY_BUCKETS: ContextBuckets = {
user: 0,
assistant: 0,
tool: 0,
other: 0,
};
const toNonNegativeNumber = (value: unknown): number => {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
return 0;
}
return value;
};
const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => {
const tokenCandidate = (message.info as { tokens?: unknown }).tokens;
const source =
tokenCandidate !== undefined
? tokenCandidate
: (message.parts.find((part) => (part as { tokens?: unknown }).tokens !== undefined) as { tokens?: unknown } | undefined)?.tokens;
if (typeof source === 'number') {
return {
...EMPTY_BREAKDOWN,
total: toNonNegativeNumber(source),
};
}
if (!source || typeof source !== 'object') {
return EMPTY_BREAKDOWN;
}
const breakdown = source as {
input?: unknown;
output?: unknown;
reasoning?: unknown;
cache?: { read?: unknown; write?: unknown };
};
const input = toNonNegativeNumber(breakdown.input);
const output = toNonNegativeNumber(breakdown.output);
const reasoning = toNonNegativeNumber(breakdown.reasoning);
const cacheRead = toNonNegativeNumber(breakdown.cache?.read);
const cacheWrite = toNonNegativeNumber(breakdown.cache?.write);
return {
input,
output,
reasoning,
cacheRead,
cacheWrite,
total: input + output + reasoning + cacheRead + cacheWrite,
};
};
const pickString = (...values: unknown[]): string => {
for (const value of values) {
if (typeof value === 'string' && value.trim().length > 0) {
return value;
}
}
return '';
};
const estimateTextLength = (value: unknown): number => {
if (typeof value === 'string') {
return value.length;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value).length;
}
if (Array.isArray(value)) {
return value.reduce((sum, item) => sum + estimateTextLength(item), 0);
}
if (value && typeof value === 'object') {
return Object.values(value as Record<string, unknown>).reduce<number>((sum, item) => sum + estimateTextLength(item), 0);
}
return 0;
};
const estimatePartChars = (part: Part, role: 'user' | 'assistant' | 'tool' | 'other'): ContextBuckets => {
const partRecord = part as Record<string, unknown>;
const type = typeof partRecord.type === 'string' ? partRecord.type : '';
if (type === 'reasoning') {
return {
...EMPTY_BUCKETS,
assistant: estimateTextLength(partRecord.text) + estimateTextLength(partRecord.content),
};
}
const directText = pickString(
partRecord.text,
partRecord.content,
partRecord.value,
(partRecord.source as { value?: unknown; text?: { value?: unknown } } | undefined)?.value,
(partRecord.source as { value?: unknown; text?: { value?: unknown } } | undefined)?.text?.value,
);
if (type === 'tool' || role === 'tool') {
const toolInputOutputLength =
estimateTextLength(partRecord.input)
+ estimateTextLength(partRecord.output)
+ estimateTextLength(partRecord.error)
+ estimateTextLength((partRecord.call as { input?: unknown; output?: unknown; error?: unknown } | undefined)?.input)
+ estimateTextLength((partRecord.call as { input?: unknown; output?: unknown; error?: unknown } | undefined)?.output)
+ estimateTextLength((partRecord.call as { input?: unknown; output?: unknown; error?: unknown } | undefined)?.error);
const toolPayloadLength =
toolInputOutputLength
+ estimateTextLength(partRecord.raw)
+ Math.round(estimateTextLength(partRecord.metadata) * 0.25)
+ Math.round(estimateTextLength(partRecord.state) * 0.1);
return { user: 0, assistant: 0, tool: toolPayloadLength, other: 0 };
}
if (role === 'user') {
return { user: directText.length, assistant: 0, tool: 0, other: 0 };
}
if (role === 'assistant') {
return { user: 0, assistant: directText.length, tool: 0, other: 0 };
}
return { user: 0, assistant: 0, tool: 0, other: directText.length };
};
const addBuckets = (target: ContextBuckets, value: ContextBuckets): ContextBuckets => ({
user: target.user + value.user,
assistant: target.assistant + value.assistant,
tool: target.tool + value.tool,
other: target.other + value.other,
});
const deriveRoleBucket = (message: SessionMessage): 'user' | 'assistant' | 'tool' | 'other' => {
const roleInfo = deriveMessageRole(message.info);
if (roleInfo.isUser) return 'user';
if (roleInfo.role === 'assistant') return 'assistant';
if (roleInfo.role === 'tool') return 'tool';
return 'other';
};
const computeContextBreakdown = (
sessionMessages: SessionMessage[],
systemPrompt: string,
): ContextBuckets => {
if (sessionMessages.length === 0) {
return { ...EMPTY_BUCKETS };
}
const totalChars = sessionMessages.reduce<ContextBuckets>((acc, message) => {
const role = deriveRoleBucket(message);
let bucket = { ...EMPTY_BUCKETS };
for (const part of message.parts) {
bucket = addBuckets(bucket, estimatePartChars(part, role));
}
return addBuckets(acc, bucket);
}, { ...EMPTY_BUCKETS });
totalChars.user += systemPrompt.length;
return {
user: Math.ceil(totalChars.user / 4),
assistant: Math.ceil(totalChars.assistant / 4),
tool: Math.ceil(totalChars.tool / 4),
other: Math.ceil(totalChars.other / 4),
};
};
const formatNumber = (value: number): string => value.toLocaleString();
const formatMoney = (value: number): string => {
if (!Number.isFinite(value) || value <= 0) return '$0.00';
if (value < 0.01) return `$${value.toFixed(4)}`;
return `$${value.toFixed(2)}`;
};
const formatDateTime = (timestamp: number | null): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '-';
const value = new Date(timestamp).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
return value.replace(/, (\d{1,2}:\d{2} [AP]M)$/, ' at $1');
};
const formatMessageDateMeta = (timestamp: number | null): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '-';
return new Date(timestamp).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
};
const capitalizeRole = (role: string): string => {
if (!role) return role;
return `${role[0].toUpperCase()}${role.slice(1)}`;
};
const resolveProviderAndModel = (
providers: ProviderLike[],
providerID: string,
modelID: string,
): { providerName: string; modelName: string; contextLimit: number | null } => {
const provider = providers.find((entry) => entry.id === providerID);
const model = provider?.models?.find((entry) => entry.id === modelID);
return {
providerName: provider?.name || providerID || '-',
modelName: model?.name || modelID || '-',
contextLimit: typeof model?.limit?.context === 'number' ? model.limit.context : null,
};
};
export const ContextPanelContent: React.FC = () => {
const { currentTheme } = useThemeSystem();
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const [expandedRawMessages, setExpandedRawMessages] = React.useState<Record<string, boolean>>({});
const [copiedRawMessageId, setCopiedRawMessageId] = React.useState<string | null>(null);
const copyResetTimeoutRef = React.useRef<number | null>(null);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessions = useSessionStore((state) => state.sessions);
const sessionMessages = useSessionStore((state) => {
if (!state.currentSessionId) return EMPTY_SESSION_MESSAGES;
return state.messages.get(state.currentSessionId) ?? EMPTY_SESSION_MESSAGES;
});
const providers = useConfigStore((state) => state.providers);
React.useEffect(() => {
if (copyResetTimeoutRef.current !== null) {
window.clearTimeout(copyResetTimeoutRef.current);
copyResetTimeoutRef.current = null;
}
setExpandedRawMessages((prev) => (Object.keys(prev).length > 0 ? {} : prev));
setCopiedRawMessageId(null);
}, [currentSessionId]);
React.useEffect(() => {
return () => {
if (copyResetTimeoutRef.current !== null) {
window.clearTimeout(copyResetTimeoutRef.current);
copyResetTimeoutRef.current = null;
}
};
}, []);
const handleCopyRawMessage = React.useCallback(async (messageId: string, value: string) => {
try {
await navigator.clipboard.writeText(value);
setCopiedRawMessageId(messageId);
if (copyResetTimeoutRef.current !== null) {
window.clearTimeout(copyResetTimeoutRef.current);
}
copyResetTimeoutRef.current = window.setTimeout(() => {
setCopiedRawMessageId((prev) => (prev === messageId ? null : prev));
copyResetTimeoutRef.current = null;
}, 2000);
} catch {
setCopiedRawMessageId(null);
}
}, []);
const viewModel = React.useMemo(() => {
const currentSession = currentSessionId ? sessions.find((session) => session.id === currentSessionId) ?? null : null;
const assistantMessages = sessionMessages.filter((entry) => deriveMessageRole(entry.info).role === 'assistant');
const userMessages = sessionMessages.filter((entry) => deriveMessageRole(entry.info).isUser);
let contextMessage: SessionMessage | null = null;
for (let i = assistantMessages.length - 1; i >= 0; i -= 1) {
const message = assistantMessages[i];
if (extractTokenBreakdown(message).total > 0) {
contextMessage = message;
break;
}
}
const tokenBreakdown = contextMessage ? extractTokenBreakdown(contextMessage) : EMPTY_BREAKDOWN;
const totalAssistantCost = assistantMessages.reduce((sum, message) => {
const cost = toNonNegativeNumber((message.info as { cost?: unknown }).cost);
return sum + cost;
}, 0);
const latestAssistantInfo = (contextMessage?.info ?? null) as (Message & { providerID?: string; modelID?: string }) | null;
const providerModel = resolveProviderAndModel(
providers as ProviderLike[],
latestAssistantInfo?.providerID || '',
latestAssistantInfo?.modelID || '',
);
const contextLimit = providerModel.contextLimit;
const usagePercent = contextLimit && contextLimit > 0
? Math.min(999, (tokenBreakdown.total / contextLimit) * 100)
: 0;
const systemPrompt = ([...sessionMessages].reverse().find(
(entry) => deriveMessageRole(entry.info).isUser && typeof (entry.info as { system?: unknown }).system === 'string',
)?.info as { system?: string } | undefined)?.system || '';
const computedBreakdown = computeContextBreakdown(sessionMessages, systemPrompt);
const userTokens = computedBreakdown.user;
const assistantTokens = computedBreakdown.assistant;
const toolTokens = computedBreakdown.tool;
const otherTokens = Math.max(0, tokenBreakdown.input - userTokens - assistantTokens - toolTokens);
const breakdownTotal = userTokens + assistantTokens + toolTokens + otherTokens;
const firstMessageTs = sessionMessages[0]?.info?.time?.created;
const lastMessageTs = sessionMessages.length > 0
? sessionMessages[sessionMessages.length - 1]?.info?.time?.created
: null;
return {
sessionTitle: currentSession?.title || 'Untitled Session',
messagesCount: sessionMessages.length,
userMessagesCount: userMessages.length,
assistantMessagesCount: assistantMessages.length,
createdAt: (currentSession?.time?.created ?? firstMessageTs ?? null) as number | null,
lastActivityAt: (lastMessageTs ?? currentSession?.time?.created ?? null) as number | null,
providerModel,
tokenBreakdown,
usagePercent,
totalAssistantCost,
contextLimit,
breakdown: {
user: userTokens,
assistant: assistantTokens,
tool: toolTokens,
other: otherTokens,
},
breakdownTotal,
};
}, [currentSessionId, providers, sessionMessages, sessions]);
if (!currentSessionId) {
return (
<div className="flex h-full items-center justify-center p-6 text-center typography-ui-label text-muted-foreground">
Open a session to inspect context.
</div>
);
}
const segments: Array<{ key: string; label: string; value: number; color: string }> = [
{ key: 'user', label: 'User', value: viewModel.breakdown.user, color: 'var(--status-success)' },
{ key: 'assistant', label: 'Assistant', value: viewModel.breakdown.assistant, color: 'var(--primary-base)' },
{ key: 'tool', label: 'Tool Calls', value: viewModel.breakdown.tool, color: 'var(--status-warning)' },
{ key: 'other', label: 'Other', value: viewModel.breakdown.other, color: 'var(--surface-muted-foreground)' },
];
return (
<div className="h-full overflow-y-auto bg-background">
<div className="mx-auto w-full max-w-[52rem] px-5 py-6">
{/* ── Session header ── */}
<div className="mb-6">
<h2 className="typography-ui-header font-semibold text-foreground truncate">{viewModel.sessionTitle}</h2>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 typography-micro text-muted-foreground/70">
<span>{viewModel.providerModel.providerName} / {viewModel.providerModel.modelName}</span>
{viewModel.createdAt && (
<>
<span>&middot;</span>
<span>{formatDateTime(viewModel.createdAt)}</span>
</>
)}
</div>
</div>
{/* ── Context usage ── */}
<div className="mb-5 rounded-lg bg-[var(--surface-elevated)]/70 px-4 py-3.5">
<div className="flex items-baseline justify-between">
<span className="typography-micro text-muted-foreground">Context</span>
<span className="typography-micro tabular-nums text-muted-foreground/70">
{formatNumber(viewModel.tokenBreakdown.total)}
{viewModel.contextLimit ? ` / ${formatNumber(viewModel.contextLimit)}` : ''}
</span>
</div>
<div className="mt-2.5 flex h-1 w-full overflow-hidden rounded-full bg-[var(--surface-subtle)]">
{viewModel.usagePercent > 0 && (
<div
className="rounded-full transition-all duration-300"
style={{
width: `${Math.max(0.5, viewModel.usagePercent)}%`,
backgroundColor: viewModel.usagePercent > 80 ? 'var(--status-warning)' : 'var(--primary-base)',
}}
/>
)}
</div>
<div className="mt-1.5 typography-micro font-medium tabular-nums text-foreground/80">
{viewModel.usagePercent.toFixed(1)}% used
</div>
</div>
{/* ── Stat grid ── */}
<div className="mb-5 grid grid-cols-2 gap-2">
{([
{ label: 'Messages', value: formatNumber(viewModel.messagesCount) },
{ label: 'User', value: formatNumber(viewModel.userMessagesCount) },
{ label: 'Assistant', value: formatNumber(viewModel.assistantMessagesCount) },
{ label: 'Cost', value: formatMoney(viewModel.totalAssistantCost) },
] as const).map((item) => (
<div key={item.label} className="rounded-lg bg-[var(--surface-elevated)]/70 px-3 py-2.5">
<div className="typography-micro text-muted-foreground/70">{item.label}</div>
<div className="mt-0.5 typography-ui-label tabular-nums text-foreground">{item.value}</div>
</div>
))}
</div>
{/* ── Last turn tokens ── */}
<div className="mb-5 rounded-lg bg-[var(--surface-elevated)]/70 px-4 py-3.5">
<div className="typography-micro text-muted-foreground">Last Assistant Message</div>
<div className="mt-2.5 grid grid-cols-3 gap-x-4 gap-y-2.5">
{([
{ label: 'Input', value: viewModel.tokenBreakdown.input },
{ label: 'Output', value: viewModel.tokenBreakdown.output },
{ label: 'Reasoning', value: viewModel.tokenBreakdown.reasoning },
{ label: 'Cache Read', value: viewModel.tokenBreakdown.cacheRead },
{ label: 'Cache Write', value: viewModel.tokenBreakdown.cacheWrite },
] as const).map((item) => (
<div key={item.label}>
<div className="typography-micro text-muted-foreground/70">{item.label}</div>
<div className="mt-0.5 typography-ui-label tabular-nums text-foreground">{formatNumber(item.value)}</div>
</div>
))}
</div>
</div>
{/* ── Context breakdown ── */}
<div className="mb-6">
<div className="flex h-1 w-full overflow-hidden rounded-full bg-[var(--surface-subtle)]">
{segments.map((segment) => {
if (segment.value <= 0 || viewModel.breakdownTotal <= 0) return null;
return (
<div
key={segment.key}
style={{
width: `${(segment.value / viewModel.breakdownTotal) * 100}%`,
backgroundColor: segment.color,
}}
/>
);
})}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1">
{segments.map((segment) => {
const pct = viewModel.breakdownTotal > 0 ? (segment.value / viewModel.breakdownTotal) * 100 : 0;
return (
<div key={segment.key} className="inline-flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: segment.color }} />
<span className="typography-micro text-muted-foreground/70">
{segment.label} <span className="tabular-nums">{pct.toFixed(0)}%</span>
</span>
</div>
);
})}
</div>
</div>
{/* ── Raw messages ── */}
<div>
<div className="typography-micro text-muted-foreground">Raw Messages</div>
<div className="mt-2.5 space-y-1">
{[...sessionMessages].reverse().map((message) => {
const role = deriveMessageRole(message.info).role;
const isExpanded = expandedRawMessages[message.info.id] === true;
const isCopied = copiedRawMessageId === message.info.id;
const messageCreatedAt = (message.info.time?.created ?? null) as number | null;
const jsonValue = isExpanded
? JSON.stringify({ info: message.info, parts: message.parts }, null, 2)
: '';
return (
<div
key={message.info.id}
className="overflow-hidden rounded-lg bg-[var(--surface-elevated)]/70"
>
<button
type="button"
className="w-full cursor-pointer px-3 py-1.5 text-left hover:bg-[var(--interactive-hover)]"
aria-expanded={isExpanded}
onClick={() => {
setExpandedRawMessages((prev) => ({
...prev,
[message.info.id]: !(prev[message.info.id] === true),
}));
}}
>
<div className="flex items-center justify-between gap-2 whitespace-nowrap overflow-hidden">
<span className="min-w-0 inline-flex items-center gap-1.5">
<span className="typography-ui-label text-foreground shrink-0">{capitalizeRole(role)}</span>
<span className="min-w-0 truncate typography-micro text-muted-foreground">{message.info.id}</span>
</span>
<span className="typography-micro text-muted-foreground shrink-0">{formatMessageDateMeta(messageCreatedAt)}</span>
</div>
</button>
{isExpanded && (
<div className="border-t border-[var(--surface-subtle)] p-0">
<div className="group relative max-h-[26rem] w-full overflow-auto bg-[var(--surface-background)]">
<div className="absolute top-1 right-2 z-10 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-interactive-hover/60 hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
void handleCopyRawMessage(message.info.id, jsonValue);
}}
aria-label={isCopied ? 'Copied' : 'Copy JSON'}
title={isCopied ? 'Copied' : 'Copy'}
>
{isCopied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
</div>
<SyntaxHighlighter
language="json"
style={syntaxTheme}
PreTag="div"
customStyle={{
margin: 0,
padding: '0.75rem',
background: 'transparent',
fontSize: 'var(--text-micro)',
lineHeight: '1.35',
}}
codeTagProps={{
style: {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowWrap: 'break-word',
},
}}
wrapLongLines
>
{jsonValue}
</SyntaxHighlighter>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
);
};
+37 -2
View File
@@ -141,6 +141,9 @@ export const Header: React.FC = () => {
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
const toggleBottomTerminal = useUIStore((state) => state.toggleBottomTerminal);
const toggleRightSidebar = useUIStore((state) => state.toggleRightSidebar);
const openContextOverview = useUIStore((state) => state.openContextOverview);
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
const contextPanelByDirectory = useUIStore((state) => state.contextPanelByDirectory);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const activeMainTab = useUIStore((state) => state.activeMainTab);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
@@ -338,9 +341,15 @@ export const Header: React.FC = () => {
const updateProjectTabsOverflow = React.useCallback(() => {
const el = projectTabsScrollRef.current;
if (!el) return;
setProjectTabsOverflow({
const next = {
left: el.scrollLeft > 2,
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 2,
};
setProjectTabsOverflow((prev) => {
if (prev.left === next.left && prev.right === next.right) {
return prev;
}
return next;
});
}, []);
@@ -884,6 +893,30 @@ export const Header: React.FC = () => {
setSettingsDialogOpen(true);
}, [blurActiveElement, isMobile, setSessionSwitcherOpen, setSettingsDialogOpen]);
const handleOpenContextPanel = React.useCallback(() => {
const directory = normalize(openDirectory || '');
if (!directory) {
return;
}
const panelState = contextPanelByDirectory[directory];
if (panelState?.isOpen && panelState.mode === 'context') {
closeContextPanel(directory);
return;
}
openContextOverview(directory);
}, [closeContextPanel, contextPanelByDirectory, openContextOverview, openDirectory]);
const isContextPanelActive = React.useMemo(() => {
const directory = normalize(openDirectory || '');
if (!directory) {
return false;
}
const panelState = contextPanelByDirectory[directory];
return Boolean(panelState?.isOpen && panelState.mode === 'context');
}, [contextPanelByDirectory, openDirectory]);
const headerIconButtonClass = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 rounded-md typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground hover:bg-interactive-hover transition-colors';
const desktopPaddingClass = React.useMemo(() => {
@@ -1604,9 +1637,11 @@ export const Header: React.FC = () => {
size="compact"
hideIcon
showPercentIcon
onClick={handleOpenContextPanel}
pressed={isContextPanelActive}
className="mr-3.5"
valueClassName="typography-ui-label font-medium leading-none text-foreground"
percentIconClassName="h-5 w-5 text-muted-foreground"
percentIconClassName="h-5 w-5"
/>
)}
<OpenInAppButton directory={openDirectory} className="mr-1" />
@@ -1290,11 +1290,21 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
sortedWorktrees.forEach((meta) => {
const directory = normalizePath(meta.path) ?? meta.path;
const label = meta.label || meta.name || formatDirectoryName(directory, homeDirectory) || directory;
const currentBranch = gitDirectories.get(directory)?.status?.current?.trim() || null;
const metadataBranch = meta.branch?.trim() || null;
const shouldSyncLabelWithBranch = Boolean(
currentBranch
&& metadataBranch
&& meta.label
&& normalizeForBranchComparison(meta.label) === normalizeForBranchComparison(metadataBranch),
);
const label = shouldSyncLabelWithBranch
? currentBranch!
: (meta.label || meta.name || formatDirectoryName(directory, homeDirectory) || directory);
groups.push({
id: `worktree:${directory}`,
label,
branch: meta.branch || null,
branch: currentBranch || metadataBranch,
description: formatPathForDisplay(directory, homeDirectory),
isMain: false,
worktree: meta,
@@ -1309,10 +1319,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
.sort((a, b) => (groupOrder.get(a) ?? 0) - (groupOrder.get(b) ?? 0));
orphanKeys.forEach((directory) => {
const currentBranch = gitDirectories.get(directory)?.status?.current?.trim() || null;
groups.push({
id: `worktree:orphan:${directory}`,
label: formatDirectoryName(directory, homeDirectory) || directory,
branch: null,
branch: currentBranch,
description: formatPathForDisplay(directory, homeDirectory),
isMain: false,
worktree: null,
@@ -1323,7 +1334,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return groups;
},
[homeDirectory, worktreeMetadata, pinnedSessionIds]
[homeDirectory, worktreeMetadata, pinnedSessionIds, gitDirectories]
);
const toggleGroupSessionLimit = React.useCallback((groupId: string) => {
@@ -16,6 +16,8 @@ interface ContextUsageDisplayProps {
className?: string;
valueClassName?: string;
percentIconClassName?: string;
onClick?: () => void;
pressed?: boolean;
}
export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
@@ -30,6 +32,8 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
className,
valueClassName,
percentIconClassName,
onClick,
pressed = false,
}) => {
const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState(false);
@@ -56,16 +60,10 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
`Output limit: ${formatTokens(safeOutputLimit)}`,
];
const contextElement = (
<div
className={cn(
'app-region-no-drag flex items-center gap-1.5 text-muted-foreground/60 select-none',
size === 'compact' ? 'typography-micro' : 'typography-meta',
className,
)}
aria-label="Context usage"
onClick={isMobile ? () => setMobileTooltipOpen(true) : undefined}
>
const isInteractive = !isMobile && typeof onClick === 'function';
const contextContent = (
<>
{!isMobile && !hideIcon && <RiDonutChartLine className="h-4 w-4 flex-shrink-0" />}
<span className={cn('font-medium inline-flex items-center gap-1.5', valueClassName)}>
{showPercentIcon ? (
@@ -82,6 +80,39 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
</>
)}
</span>
</>
);
const sharedClassName = cn(
'app-region-no-drag flex items-center gap-1.5 select-none',
size === 'compact' ? 'typography-micro' : 'typography-meta',
isInteractive
? cn(
'rounded-md px-2 py-1.5 text-foreground transition-colors',
'hover:bg-interactive-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
)
: 'text-muted-foreground/60',
className,
);
const contextElement = isInteractive ? (
<button
type="button"
className={sharedClassName}
aria-label="Context usage"
aria-pressed={pressed}
onClick={onClick}
>
{contextContent}
</button>
) : (
<div
className={sharedClassName}
aria-label="Context usage"
onClick={isMobile ? () => setMobileTooltipOpen(true) : undefined}
>
{contextContent}
</div>
);
@@ -7,6 +7,7 @@ interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
errorInfo?: React.ErrorInfo;
copied?: boolean;
}
interface ErrorBoundaryProps {
@@ -25,17 +26,32 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.setState({ error, errorInfo });
this.setState({ error, errorInfo, copied: false });
if (process.env.NODE_ENV === 'development') {
console.error('Error caught by boundary:', error, errorInfo);
}
console.error('Error caught by boundary:', error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
};
handleCopy = async () => {
const errorText = this.state.error ? String(this.state.error) : 'Unknown error';
const stack = this.state.error?.stack ? `\n\nStack:\n${this.state.error.stack}` : '';
const componentStack = this.state.errorInfo?.componentStack ? `\n\nComponent stack:${this.state.errorInfo.componentStack}` : '';
const payload = `${errorText}${stack}${componentStack}`;
try {
await navigator.clipboard.writeText(payload);
this.setState({ copied: true });
window.setTimeout(() => {
this.setState((prev) => (prev.copied ? { copied: false } : null));
}, 1500);
} catch {
// ignore
}
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
@@ -61,6 +77,7 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
<summary className="cursor-pointer hover:bg-interactive-hover/80">Error details</summary>
<pre className="mt-2 overflow-x-auto">
{this.state.error.toString()}
{this.state.errorInfo?.componentStack ? `\n\nComponent stack:${this.state.errorInfo.componentStack}` : ''}
</pre>
</details>
)}
@@ -70,6 +87,9 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
<RiRestartLine className="h-4 w-4 mr-2" />
Try again
</Button>
<Button onClick={this.handleCopy} variant="outline" className="flex-1">
{this.state.copied ? 'Copied' : 'Copy'}
</Button>
</div>
</CardContent>
</Card>
+23 -14
View File
@@ -66,6 +66,7 @@ const PIN_THRESHOLD_RATIO = 0.10;
export const useChatScrollManager = ({
currentSessionId,
sessionMessages,
streamingMessageId,
updateViewportAnchor,
isSyncing,
isMobile,
@@ -117,6 +118,15 @@ export const useChatScrollManager = ({
scrollEngine.scrollToPosition(Math.max(0, bottom), options);
}, [markProgrammaticScroll, scrollEngine]);
const scrollPinnedToBottom = React.useCallback(() => {
if (streamingMessageId) {
scrollToBottomInternal({ followBottom: true });
return;
}
scrollToBottomInternal({ instant: true });
}, [scrollToBottomInternal, streamingMessageId]);
const updateScrollButtonVisibility = React.useCallback(() => {
const container = scrollRef.current;
if (!container) {
@@ -250,13 +260,12 @@ export const useChatScrollManager = ({
const container = scrollRef.current;
if (!container) return;
// When pinned and content grows, scroll to bottom instantly
// When pinned and content grows, follow bottom with fast smooth scroll
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getPinThreshold()) {
markProgrammaticScroll();
scrollToBottomInternal({ instant: true });
scrollPinnedToBottom();
}
}, [getDistanceFromBottom, getPinThreshold, isSyncing, markProgrammaticScroll, scrollToBottomInternal, sessionMessages]);
}, [getDistanceFromBottom, getPinThreshold, isSyncing, scrollPinnedToBottom, sessionMessages]);
// Use ResizeObserver to detect content changes and maintain pin
React.useEffect(() => {
@@ -266,11 +275,11 @@ export const useChatScrollManager = ({
const observer = new ResizeObserver(() => {
updateScrollButtonVisibility();
// Maintain pin when content grows - always instant for smooth experience
// Maintain pin when content grows - fast smooth follow
if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getPinThreshold()) {
scrollToBottomInternal({ instant: true });
scrollPinnedToBottom();
}
}
});
@@ -282,7 +291,7 @@ export const useChatScrollManager = ({
if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getPinThreshold()) {
scrollToBottomInternal({ instant: true });
scrollPinnedToBottom();
}
}
});
@@ -293,7 +302,7 @@ export const useChatScrollManager = ({
observer.disconnect();
childObserver.disconnect();
};
}, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]);
}, [getDistanceFromBottom, getPinThreshold, scrollPinnedToBottom, updateScrollButtonVisibility]);
React.useEffect(() => {
if (typeof window === 'undefined') {
@@ -315,14 +324,14 @@ export const useChatScrollManager = ({
const handleMessageContentChange = React.useCallback(() => {
updateScrollButtonVisibility();
// Maintain pin when content changes - always instant
// Maintain pin when content changes - fast smooth follow
if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getPinThreshold()) {
scrollToBottomInternal({ instant: true });
scrollPinnedToBottom();
}
}
}, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]);
}, [getDistanceFromBottom, getPinThreshold, scrollPinnedToBottom, updateScrollButtonVisibility]);
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
const existing = animationHandlersRef.current.get(messageId);
@@ -336,7 +345,7 @@ export const useChatScrollManager = ({
if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getPinThreshold()) {
scrollToBottomInternal({ instant: true });
scrollPinnedToBottom();
}
}
},
@@ -350,7 +359,7 @@ export const useChatScrollManager = ({
if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getPinThreshold()) {
scrollToBottomInternal({ instant: true });
scrollPinnedToBottom();
}
}
},
@@ -360,7 +369,7 @@ export const useChatScrollManager = ({
animationHandlersRef.current.set(messageId, handlers);
return handlers;
}, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]);
}, [getDistanceFromBottom, getPinThreshold, scrollPinnedToBottom, updateScrollButtonVisibility]);
return {
scrollRef,
+75 -1
View File
@@ -818,7 +818,6 @@ export const useEventStream = () => {
type: part.type || 'text',
} as Part;
// Fallback: if we see assistant parts but session.status hasn't arrived yet, mark busy.
if (roleInfo === 'assistant') {
const partType = (messagePart as { type?: unknown }).type;
const partTime = (messagePart as { time?: { end?: unknown } }).time;
@@ -863,6 +862,81 @@ export const useEventStream = () => {
break;
}
case 'message.part.delta': {
const sessionId = readStringProp(props, ['sessionID', 'sessionId']);
const messageId = readStringProp(props, ['messageID', 'messageId']);
const partId = readStringProp(props, ['partID', 'partId']);
const field = readStringProp(props, ['field']);
const delta = typeof props.delta === 'string' ? props.delta : null;
if (!sessionId || !messageId || !partId || !field || delta === null) {
if (streamDebugEnabled()) {
console.debug('[useEventStream] Skipping message.part.delta with missing payload', {
sessionID: props.sessionID,
messageID: props.messageID,
partID: props.partID,
field: props.field,
});
}
break;
}
lastMessageEventBySessionRef.current.set(sessionId, Date.now());
const pendingTimer = pendingMessageStallTimersRef.current.get(sessionId);
if (pendingTimer) {
clearTimeout(pendingTimer);
pendingMessageStallTimersRef.current.delete(sessionId);
}
const trimmedHeadMaxId = useSessionStore.getState().sessionMemoryState.get(sessionId)?.trimmedHeadMaxId;
if (trimmedHeadMaxId && !isIdNewer(messageId, trimmedHeadMaxId)) {
if (streamDebugEnabled()) {
console.debug('[useEventStream] Skipping message.part.delta for trimmed message', {
sessionId,
messageId,
trimmedHeadMaxId,
});
}
break;
}
const existingMessage = getMessageFromStore(sessionId, messageId);
const existingPart = existingMessage?.parts?.find((item) => item?.id === partId);
if (!existingPart) {
break;
}
const existingPartRecord = existingPart as Record<string, unknown>;
const existingFieldValue = existingPartRecord[field];
const updatedPart: Part = {
...existingPart,
[field]: `${typeof existingFieldValue === 'string' ? existingFieldValue : ''}${delta}`,
} as Part;
let roleInfo = 'assistant';
const existingRole = (existingMessage?.info as Record<string, unknown> | undefined)?.role;
if (typeof existingRole === 'string') {
roleInfo = existingRole;
}
if (roleInfo === 'assistant' && delta.length > 0) {
const currentStatus = useSessionStore.getState().sessionStatus?.get(sessionId);
const recentlyConfirmedIdle =
currentStatus?.type === 'idle' &&
typeof currentStatus.confirmedAt === 'number' &&
Date.now() - currentStatus.confirmedAt < 1200;
if (!currentStatus || currentStatus.type === 'idle') {
if (!recentlyConfirmedIdle) {
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:message.part.delta');
}
}
}
trackMessage(messageId, 'part_delta_received', { role: roleInfo, field });
addStreamingPart(sessionId, messageId, updatedPart, roleInfo);
break;
}
case 'message.updated': {
const message = (typeof props.info === 'object' && props.info !== null) ? (props.info as Record<string, unknown>) : props;
const messageExt = message as Record<string, unknown>;
+5
View File
@@ -864,6 +864,11 @@ html:not(.dark) .chat-scroll {
contain-intrinsic-size: none !important;
}
/* Mermaid block: position context for controls wrapper. */
[data-streamdown="mermaid-block"] {
position: relative;
}
/* Hide system caret in terminal - targets both Ghostty internal input and our custom touch input */
.terminal-viewport-container textarea,
.terminal-viewport-container input {
+15
View File
@@ -487,3 +487,18 @@ export const fetchDesktopInstalledApps = async (
return { apps: [], success: false, hasCache: false, isCacheStale: false };
}
};
export const clearDesktopCache = async (): Promise<boolean> => {
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
return false;
}
try {
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
await tauri?.core?.invoke?.('desktop_clear_cache');
return true;
} catch (error) {
console.warn('Failed to clear cache', error);
return false;
}
};
+68 -2
View File
@@ -6,7 +6,7 @@ import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files';
export type RightSidebarTab = 'git' | 'files';
export type ContextPanelMode = 'diff' | 'file';
export type ContextPanelMode = 'diff' | 'file' | 'context';
type ContextPanelDirectoryState = {
isOpen: boolean;
@@ -224,6 +224,7 @@ interface UIStore {
setRightSidebarTab: (tab: RightSidebarTab) => void;
openContextDiff: (directory: string, filePath: string) => void;
openContextFile: (directory: string, filePath: string) => void;
openContextOverview: (directory: string) => void;
closeContextPanel: (directory: string) => void;
toggleContextPanelExpanded: (directory: string) => void;
setContextPanelWidth: (directory: string, width: number) => void;
@@ -404,6 +405,18 @@ export const useUIStore = create<UIStore>()(
setSidebarOpen: (open) => {
set((state) => {
if (state.isSidebarOpen === open) {
if (!open) {
return state;
}
if (!state.hasManuallyResizedLeftSidebar && state.sidebarWidth !== LEFT_SIDEBAR_MIN_WIDTH) {
return {
isSidebarOpen: open,
sidebarWidth: LEFT_SIDEBAR_MIN_WIDTH,
};
}
return state;
}
if (open && !state.hasManuallyResizedLeftSidebar) {
return {
isSidebarOpen: open,
@@ -434,6 +447,18 @@ export const useUIStore = create<UIStore>()(
setRightSidebarOpen: (open) => {
set((state) => {
if (state.isRightSidebarOpen === open) {
if (!open) {
return state;
}
if (!state.hasManuallyResizedRightSidebar && state.rightSidebarWidth !== RIGHT_SIDEBAR_MIN_WIDTH) {
return {
isRightSidebarOpen: open,
rightSidebarWidth: RIGHT_SIDEBAR_MIN_WIDTH,
};
}
return state;
}
if (open && !state.hasManuallyResizedRightSidebar) {
return {
isRightSidebarOpen: open,
@@ -501,6 +526,29 @@ export const useUIStore = create<UIStore>()(
});
},
openContextOverview: (directory) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory) {
return;
}
set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory];
const current = touchContextPanelState(prev);
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: {
...current,
isOpen: true,
mode: 'context' as const,
targetPath: null,
},
};
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
});
},
closeContextPanel: (directory) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory) {
@@ -585,7 +633,25 @@ export const useUIStore = create<UIStore>()(
},
setBottomTerminalOpen: (open) => {
set(() => {
set((state) => {
if (state.isBottomTerminalOpen === open) {
if (!open) {
return state;
}
if (!state.hasManuallyResizedBottomTerminal && typeof window !== 'undefined') {
const proportionalHeight = Math.floor(window.innerHeight * 0.32);
if (state.bottomTerminalHeight === proportionalHeight && state.hasManuallyResizedBottomTerminal === false) {
return state;
}
return {
isBottomTerminalOpen: open,
bottomTerminalHeight: proportionalHeight,
hasManuallyResizedBottomTerminal: false,
};
}
return state;
}
if (open && typeof window !== 'undefined') {
const proportionalHeight = Math.floor(window.innerHeight * 0.32);
return {
+15 -1
View File
@@ -4,6 +4,13 @@ declare module 'streamdown' {
export interface StreamdownProps {
children: string;
mode?: 'streaming' | 'static';
isAnimating?: boolean;
animated?: {
animation?: string;
duration?: number;
easing?: string;
sep?: 'word' | 'char';
};
className?: string;
shikiTheme?: readonly [string | object, string | object];
@@ -11,8 +18,15 @@ declare module 'streamdown' {
controls?: boolean | {
code?: boolean;
table?: boolean;
mermaid?: boolean;
mermaid?: boolean | {
download?: boolean;
copy?: boolean;
fullscreen?: boolean;
panZoom?: boolean;
};
};
plugins?: Record<string, unknown>;
mermaid?: Record<string, unknown>;
components?: {
[key: string]: ComponentType<{ children?: ReactNode; className?: string; [key: string]: unknown }>;
};
+1 -1
View File
@@ -229,7 +229,7 @@
},
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.1.65",
"@opencode-ai/sdk": "^1.2.5",
"adm-zip": "^0.5.16",
"jsonc-parser": "^3.3.1",
"react": "^19.1.1",
@@ -430,7 +430,7 @@ const deriveSessionActivity = (payload: Record<string, unknown> | null): Session
}
}
if (type === 'message.part.updated') {
if (type === 'message.part.updated' || type === 'message.part.delta') {
const info = properties?.info as Record<string, unknown> | undefined;
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
const role = info?.role;
+1 -1
View File
@@ -462,7 +462,7 @@ const deriveSessionActivity = (payload: Record<string, unknown> | null): Session
}
}
if (type === 'message.part.updated') {
if (type === 'message.part.updated' || type === 'message.part.delta') {
const info = properties?.info as Record<string, unknown> | undefined;
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
const role = info?.role;
@@ -82,7 +82,7 @@ const deriveSessionActivity = (payload: Record<string, unknown>): SessionActivit
}
}
if (type === 'message.updated' || type === 'message.part.updated') {
if (type === 'message.updated' || type === 'message.part.updated' || type === 'message.part.delta') {
const info = properties?.info as Record<string, unknown> | undefined;
const sessionId = (info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId) as string;
const role = info?.role as string;
+1 -1
View File
@@ -26,7 +26,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.1.65",
"@opencode-ai/sdk": "^1.2.5",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
+1 -2
View File
@@ -3937,7 +3937,7 @@ function deriveSessionActivityTransitions(payload) {
}
}
if (payload.type === 'message.part.updated') {
if (payload.type === 'message.part.updated' || payload.type === 'message.part.delta') {
const info = payload.properties?.info;
const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId;
const role = info?.role;
@@ -11177,7 +11177,6 @@ Context:
res.json({ success: true, killedCount });
});
try {
syncFromHmrState();
if (await isOpenCodeProcessHealthy()) {