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:
committed by
GitHub
parent
138772e66e
commit
4d71bb27eb
@@ -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,
|
||||
|
||||
@@ -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>·</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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 }>;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user