fix: harden self-update flow and improve chat message readability (#562)

* fix: resolve Windows CLI module loading on absolute paths

* fix: improve chat tool rows layout and timestamp readability

* fix: make web update restart more reliable

* fix: make web self-update detect package manager correctly
This commit is contained in:
Bohdan Triapitsyn
2026-03-01 01:21:01 +02:00
committed by GitHub
parent 7b5fd9e70c
commit 8bfbed0e68
7 changed files with 430 additions and 170 deletions
@@ -14,7 +14,7 @@ import { isEmptyTextPart, extractTextContent } from './partUtils';
import { FadeInOnReveal } from './FadeInOnReveal';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiVolumeUpLine, RiStopLine, RiShare2Line, RiLoader4Line } from '@remixicon/react';
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiTimeLine, RiVolumeUpLine, RiStopLine, RiShare2Line, RiLoader4Line } from '@remixicon/react';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
@@ -1365,7 +1365,10 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
</span>
) : null}
{footerTimestamp ? (
<span className="text-sm text-muted-foreground/60 tabular-nums">{footerTimestamp}</span>
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
<RiTimeLine className="h-3.5 w-3.5" />
{footerTimestamp}
</span>
) : null}
</div>
</div>
@@ -1388,7 +1391,10 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
</span>
) : null}
{footerTimestamp ? (
<span className="text-sm text-muted-foreground/60 tabular-nums">{footerTimestamp}</span>
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
<RiTimeLine className="h-3.5 w-3.5" />
{footerTimestamp}
</span>
) : null}
</div>
</div>
@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
import { formatTimestampForDisplay } from '../timeFormat';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useUIStore } from '@/stores/useUIStore';
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
@@ -98,6 +99,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
time,
}) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const isMobile = useUIStore((state) => state.isMobile);
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
const { label, Icon } = variantConfig[variant];
@@ -157,23 +159,35 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
{(summary || typeof timeStart === 'number' || endedTimestampText) ? (
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta text-muted-foreground/70">
{summary ? <span className="truncate italic">{summary}</span> : null}
{summary ? <span className="flex-1 min-w-0 truncate italic">{summary}</span> : null}
{typeof timeStart === 'number' ? (
<span className="text-muted-foreground/80 flex-shrink-0 tabular-nums">
<LiveDuration
start={timeStart}
end={timeEnd}
active={typeof timeEnd !== 'number'}
/>
<span className="relative flex-shrink-0 tabular-nums text-right">
<span
className={cn(
'text-muted-foreground/80 transition-opacity duration-150',
!isMobile && endedTimestampText && 'group-hover/tool:opacity-0'
)}
>
<LiveDuration
start={timeStart}
end={timeEnd}
active={typeof timeEnd !== 'number'}
/>
</span>
{!isMobile && endedTimestampText ? (
<span
className={cn(
'pointer-events-none absolute right-0 top-0 whitespace-nowrap text-muted-foreground/70 transition-opacity duration-150',
'opacity-0 group-hover/tool:opacity-100'
)}
>
{endedTimestampText}
</span>
) : null}
</span>
) : null}
{endedTimestampText ? (
<span
className={cn(
'text-muted-foreground/70 flex-shrink-0 tabular-nums transition-opacity duration-150',
'opacity-0 group-hover/tool:opacity-100'
)}
>
{typeof timeStart !== 'number' && !isMobile && endedTimestampText ? (
<span className="text-muted-foreground/70 flex-shrink-0 tabular-nums">
{endedTimestampText}
</span>
) : null}
@@ -157,12 +157,7 @@ const parseDiffStats = (metadata?: Record<string, unknown>): { added: number; re
return { added, removed };
};
const getRelativePath = (absolutePath: string, currentDirectory: string, isMobile: boolean): string => {
if (isMobile) {
return absolutePath.split('/').pop() || absolutePath;
}
const getRelativePath = (absolutePath: string, currentDirectory: string): string => {
if (absolutePath.startsWith(currentDirectory)) {
const relativePath = absolutePath.substring(currentDirectory.length);
@@ -227,9 +222,9 @@ const parseQuestionOutput = (output: string): Array<{ question: string; answer:
return pairs.length > 0 ? pairs : null;
};
const formatStructuredOutputDescription = (input: Record<string, unknown> | undefined, output: unknown, isMobile: boolean): string => {
const formatStructuredOutputDescription = (input: Record<string, unknown> | undefined, output: unknown): string => {
if (typeof output === 'string' && output.trim().length > 0) {
const maxLength = isMobile ? 50 : 100;
const maxLength = 100;
const text = output.trim();
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
}
@@ -271,31 +266,64 @@ const formatStructuredOutputDescription = (input: Record<string, unknown> | unde
return 'Result';
}
const maxLength = isMobile ? 50 : 100;
const maxLength = 100;
const truncated = preview.length > maxLength ? `${preview.substring(0, maxLength)}...` : preview;
return truncated;
};
const getToolDescription = (part: ToolPartType, state: ToolStateUnion, isMobile: boolean, currentDirectory: string): string => {
const getToolDescriptionPath = (part: ToolPartType, state: ToolStateUnion, currentDirectory: string): string | null => {
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const input = stateWithData.input;
if (part.tool === 'apply_patch') {
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined;
const filePath = firstFile?.relativePath || firstFile?.filePath;
if (files.length > 1) return null;
if (typeof filePath === 'string') {
return getRelativePath(filePath, currentDirectory);
}
return null;
}
if ((part.tool === 'edit' || part.tool === 'multiedit') && input) {
const filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
if (typeof filePath === 'string') {
return getRelativePath(filePath, currentDirectory);
}
}
if (['write', 'create', 'file_write', 'read', 'view', 'file_read', 'cat'].includes(part.tool) && input) {
const filePath = input?.filePath || input?.file_path || input?.path;
if (typeof filePath === 'string') {
return getRelativePath(filePath, currentDirectory);
}
}
return null;
};
const getToolDescription = (part: ToolPartType, state: ToolStateUnion, currentDirectory: string): string => {
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const input = stateWithData.input;
const tool = part.tool.toLowerCase();
if (tool === 'structuredoutput' || tool === 'structured_output') {
return formatStructuredOutputDescription(input, stateWithData.output, isMobile);
return formatStructuredOutputDescription(input, stateWithData.output);
}
const filePathLabel = getToolDescriptionPath(part, state, currentDirectory);
if (filePathLabel) {
return filePathLabel;
}
if (part.tool === 'apply_patch') {
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined;
const filePath = firstFile?.relativePath || firstFile?.filePath;
if (files.length > 1) {
return `${files.length} files`;
}
if (typeof filePath === 'string') {
return getRelativePath(filePath, currentDirectory, isMobile);
}
return 'Patch';
}
@@ -305,27 +333,13 @@ const getToolDescription = (part: ToolPartType, state: ToolStateUnion, isMobile:
return `Asked ${count} question${count !== 1 ? 's' : ''}`;
}
if ((part.tool === 'edit' || part.tool === 'multiedit') && input) {
const filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
if (typeof filePath === 'string') {
return getRelativePath(filePath, currentDirectory, isMobile);
}
}
if ((part.tool === 'read' || part.tool === 'write') && input) {
const filePath = input?.filePath || input?.file_path || input?.path;
if (typeof filePath === 'string') {
return getRelativePath(filePath, currentDirectory, isMobile);
}
}
if (part.tool === 'bash' && input?.command && typeof input.command === 'string') {
const firstLine = input.command.split('\n')[0];
return isMobile ? firstLine.substring(0, 50) : firstLine.substring(0, 100);
return firstLine.substring(0, 100);
}
if (part.tool === 'task' && input?.description && typeof input.description === 'string') {
return isMobile ? input.description.substring(0, 40) : input.description.substring(0, 80);
return input.description.substring(0, 80);
}
if (part.tool === 'skill' && input?.name && typeof input.name === 'string') {
@@ -451,6 +465,32 @@ const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
return 'tool';
};
const FILE_PATH_LABEL_TOOLS = new Set([
'read',
'view',
'file_read',
'cat',
'write',
'create',
'file_write',
'edit',
'multiedit',
'apply_patch',
]);
const shouldRenderGitPathLabel = (toolName: string, label: string): boolean => {
if (!FILE_PATH_LABEL_TOOLS.has(toolName.toLowerCase())) {
return false;
}
const trimmed = label.trim();
if (!trimmed || trimmed === 'Patch' || /^\d+\s+files$/.test(trimmed)) {
return false;
}
return trimmed.includes('/') || trimmed.includes('\\');
};
const stripTaskMetadataFromOutput = (output: string): string => {
// Strip only a trailing <task_metadata>...</task_metadata> block.
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
@@ -550,13 +590,14 @@ const parseTaskMetadataBlock = (output: string | undefined): {
const TaskToolSummary: React.FC<{
entries: TaskToolSummaryEntry[];
isExpanded: boolean;
isMobile: boolean;
hasPrevTool: boolean;
hasNextTool: boolean;
output?: string;
sessionId?: string;
onShowPopup?: (content: ToolPopupContent) => void;
input?: Record<string, unknown>;
}> = ({ entries, isExpanded, hasPrevTool, hasNextTool, output, sessionId, onShowPopup, input }) => {
}> = ({ entries, isExpanded, isMobile, hasPrevTool, hasNextTool, output, sessionId, onShowPopup, input }) => {
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
const displayEntries = React.useMemo(() => {
const nonPending = entries.filter((entry) => entry.state?.status !== 'pending');
@@ -611,13 +652,18 @@ const TaskToolSummary: React.FC<{
const displayName = getToolMetadata(toolName).displayName;
return (
<div key={entry.id ?? `${toolName}-${idx}`} className="flex items-center gap-2 min-w-0">
<div key={entry.id ?? `${toolName}-${idx}`} className={cn("flex gap-2 min-w-0 w-full", isMobile ? 'items-start' : 'items-center')}>
<span className="flex-shrink-0 text-foreground/80">{getToolIcon(toolName)}</span>
<span className="typography-meta text-foreground/80 flex-shrink-0">{displayName}</span>
<span className={cn(
'typography-meta truncate',
status === 'error' ? 'text-[var(--status-error)]' : 'text-muted-foreground/70'
)}>{label}</span>
{status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? (
renderPathLikeGitChanges(label)
) : (
<span className={cn(
'typography-meta flex-1 min-w-0',
isMobile ? 'whitespace-normal break-words' : 'truncate',
status === 'error' ? 'text-[var(--status-error)]' : 'text-muted-foreground/70'
)}>{label}</span>
)}
</div>
);
})}
@@ -699,17 +745,25 @@ type DiffPatchEntry = {
patch: string;
};
const renderPathLikeGitChanges = (path: string) => {
const renderPathLikeGitChanges = (path: string, grow = true) => {
const lastSlash = path.lastIndexOf('/');
if (lastSlash === -1) {
return <span className="truncate text-foreground">{path}</span>;
return (
<span
className={cn('min-w-0 truncate typography-ui-label text-foreground', grow && 'flex-1')}
style={{ direction: 'rtl', textAlign: 'left' }}
title={path}
>
{path}
</span>
);
}
const dir = path.slice(0, lastSlash);
const name = path.slice(lastSlash + 1);
return (
<span className="flex min-w-0 items-baseline overflow-hidden" title={path}>
<span className={cn('min-w-0 flex items-baseline overflow-hidden typography-ui-label', grow && 'flex-1')} title={path}>
<span className="min-w-0 truncate text-muted-foreground" style={{ direction: 'rtl', textAlign: 'left' }}>
{dir}
</span>
@@ -725,7 +779,6 @@ const getDiffPatchEntries = (
metadata: Record<string, unknown> | undefined,
fallbackDiff: string,
currentDirectory: string,
isMobile: boolean,
): DiffPatchEntry[] => {
const files = Array.isArray(metadata?.files) ? metadata.files : [];
@@ -748,7 +801,7 @@ const getDiffPatchEntries = (
: `File ${index + 1}`;
const title = typeof rawPath === 'string'
? getRelativePath(rawPath, currentDirectory, isMobile)
? getRelativePath(rawPath, currentDirectory)
: `File ${index + 1}`;
return {
@@ -824,8 +877,9 @@ const WriteInputPreview: React.FC<WriteInputPreviewProps> = React.memo(({
return (
<div className="w-full min-w-0">
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground rounded-lg mb-1" style={{ borderWidth: '1px', borderColor: 'var(--tools-border)' }}>
{`${displayPath} (${headerLineLabel})`}
<div className="bg-muted/20 px-2 py-1 rounded-lg mb-1 flex items-center gap-2 min-w-0" style={{ borderWidth: '1px', borderColor: 'var(--tools-border)' }}>
{renderPathLikeGitChanges(displayPath)}
<span className="typography-meta text-muted-foreground/80 flex-shrink-0">({headerLineLabel})</span>
</div>
<PierreFile
file={{
@@ -853,6 +907,7 @@ interface ReadToolVirtualizedProps {
input?: Record<string, unknown>;
syntaxTheme: { [key: string]: React.CSSProperties };
toolName: string;
currentDirectory: string;
pierreTheme: { light: string; dark: string };
pierreThemeType: 'light' | 'dark';
renderScrollableBlock: (
@@ -866,6 +921,7 @@ const ReadToolVirtualized: React.FC<ReadToolVirtualizedProps> = React.memo(({
input,
syntaxTheme,
toolName,
currentDirectory,
pierreTheme,
pierreThemeType,
renderScrollableBlock,
@@ -877,7 +933,7 @@ const ReadToolVirtualized: React.FC<ReadToolVirtualizedProps> = React.memo(({
return detectLanguageFromOutput(contentForLanguage, toolName, input as Record<string, unknown>);
}, [parsedReadOutput, toolName, input]);
const filePath =
const rawFilePath =
typeof input?.filePath === 'string'
? input.filePath
: typeof input?.file_path === 'string'
@@ -885,6 +941,7 @@ const ReadToolVirtualized: React.FC<ReadToolVirtualizedProps> = React.memo(({
: typeof input?.path === 'string'
? input.path
: 'read-output';
const displayPath = getRelativePath(rawFilePath, currentDirectory);
const codeLines: CodeLine[] = React.useMemo(() => parsedReadOutput.lines.map((line) => ({
text: line.text,
@@ -894,21 +951,29 @@ const ReadToolVirtualized: React.FC<ReadToolVirtualizedProps> = React.memo(({
if (parsedReadOutput.type === 'file') {
const fileContent = parsedReadOutput.lines.map((line) => line.text).join('\n');
const lineCount = Math.max(parsedReadOutput.lines.length, 1);
const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`;
return renderScrollableBlock(
<PierreFile
file={{
name: filePath,
contents: fileContent,
lang: language || undefined,
}}
options={{
disableFileHeader: true,
overflow: 'wrap',
theme: pierreTheme,
themeType: pierreThemeType,
}}
className="block w-full"
/>,
<div className="w-full min-w-0">
<div className="bg-muted/20 px-2 py-1 rounded-lg mb-1 flex items-center gap-2 min-w-0" style={{ borderWidth: '1px', borderColor: 'var(--tools-border)' }}>
{renderPathLikeGitChanges(displayPath)}
<span className="typography-meta text-muted-foreground/80 flex-shrink-0">({headerLineLabel})</span>
</div>
<PierreFile
file={{
name: displayPath,
contents: fileContent,
lang: language || undefined,
}}
options={{
disableFileHeader: true,
overflow: 'wrap',
theme: pierreTheme,
themeType: pierreThemeType,
}}
className="block w-full"
/>
</div>,
{ className: 'p-1' }
) as React.ReactElement;
}
@@ -951,8 +1016,8 @@ const ImagePreview: React.FC<ImagePreviewProps> = React.memo(({ content, filePat
return (
<div className="w-full min-w-0">
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground rounded-lg mb-2" style={{ borderWidth: '1px', borderColor: 'var(--tools-border)' }}>
{displayPath}
<div className="bg-muted/20 px-2 py-1 rounded-lg mb-2 flex items-center min-w-0" style={{ borderWidth: '1px', borderColor: 'var(--tools-border)' }}>
{renderPathLikeGitChanges(displayPath)}
</div>
<div className="flex justify-center p-4 bg-muted/10 rounded-lg" style={{ borderWidth: '1px', borderColor: 'var(--tools-border)' }}>
<img
@@ -1000,8 +1065,8 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
const diffContent = typeof metadata?.diff === 'string' ? (metadata.diff as string) : null;
const diffEntries = React.useMemo(
() => (diffContent ? getDiffPatchEntries(metadata, diffContent, currentDirectory, isMobile) : []),
[currentDirectory, diffContent, isMobile, metadata]
() => (diffContent ? getDiffPatchEntries(metadata, diffContent, currentDirectory) : []),
[currentDirectory, diffContent, metadata]
);
const writeFilePath = part.tool === 'write'
? typeof input?.filePath === 'string'
@@ -1022,7 +1087,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
const shouldShowWriteInputPreview = part.tool === 'write' && !!writeInputContent;
const isWriteImageFile = writeFilePath ? isImageFile(writeFilePath) : false;
const writeDisplayPath = shouldShowWriteInputPreview
? (writeFilePath ? getRelativePath(writeFilePath, currentDirectory, isMobile) : 'New file')
? (writeFilePath ? getRelativePath(writeFilePath, currentDirectory) : 'New file')
: null;
const inputTextContent = React.useMemo(() => {
@@ -1224,6 +1289,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
input={input}
syntaxTheme={syntaxTheme}
toolName={part.tool}
currentDirectory={currentDirectory}
pierreTheme={pierreTheme}
pierreThemeType={pierreThemeType}
renderScrollableBlock={renderScrollableBlock}
@@ -1543,7 +1609,8 @@ const ToolPart: React.FC<ToolPartProps> = ({
}, [isTaskTool, onContentChange, taskSummaryEntries.length]);
const diffStats = (part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') ? parseDiffStats(metadata) : null;
const description = getToolDescription(part, state, isMobile, currentDirectory);
const descriptionPath = getToolDescriptionPath(part, state, currentDirectory);
const description = getToolDescription(part, state, currentDirectory);
const displayName = getToolMetadata(part.tool).displayName;
// Get justification text (tool title/description) when setting is enabled
@@ -1621,18 +1688,16 @@ const ToolPart: React.FC<ToolPartProps> = ({
>
<div className="flex items-center gap-2 flex-shrink-0">
{}
<button
type="button"
<div
className="relative h-3.5 w-3.5 flex-shrink-0"
onClick={(event) => { event.stopPropagation(); onToggle(part.id); }}
aria-label={isExpanded ? 'Collapse tool details' : 'Expand tool details'}
>
{}
<div
className={cn(
'absolute inset-0 transition-opacity',
isExpanded && 'opacity-0',
!isExpanded && !isMobile && 'group-hover/tool:opacity-0'
!isExpanded && 'group-hover/tool:opacity-0'
)}
style={!isTaskTool && isError ? { color: 'var(--status-error)' } : { color: 'var(--tools-icon)' }}
>
@@ -1643,13 +1708,12 @@ const ToolPart: React.FC<ToolPartProps> = ({
className={cn(
'absolute inset-0 transition-opacity flex items-center justify-center',
isExpanded && 'opacity-100',
!isExpanded && isMobile && 'opacity-0',
!isExpanded && !isMobile && 'opacity-0 group-hover/tool:opacity-100'
!isExpanded && 'opacity-0 group-hover/tool:opacity-100'
)}
>
{isExpanded ? <RiArrowDownSLine className="h-3.5 w-3.5" /> : <RiArrowRightSLine className="h-3.5 w-3.5" />}
</div>
</button>
</div>
<span
className="typography-meta font-medium"
style={!isTaskTool && isError ? { color: 'var(--status-error)' } : { color: 'var(--tools-title)' }}
@@ -1659,39 +1723,57 @@ const ToolPart: React.FC<ToolPartProps> = ({
</div>
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta" style={{ color: 'var(--tools-description)' }}>
{justificationText && (
<span className={cn("truncate", isMobile && "max-w-[120px]")} style={{ color: 'var(--tools-description)', opacity: 0.8 }}>
{justificationText}
<div className="flex items-center gap-1 flex-1 min-w-0">
{justificationText && (
<span className="min-w-0 truncate" style={{ color: 'var(--tools-description)', opacity: 0.8 }}>
{justificationText}
</span>
)}
{!justificationText && description && (
descriptionPath && description === descriptionPath ? (
renderPathLikeGitChanges(descriptionPath, false)
) : (
<span className="min-w-0 truncate">
{description}
</span>
)
)}
{diffStats && (
<span className="text-muted-foreground/60 flex-shrink-0">
<span style={{ color: 'var(--status-success)' }}>+{diffStats.added}</span>
{' '}
<span style={{ color: 'var(--status-error)' }}>-{diffStats.removed}</span>
</span>
)}
</div>
{typeof effectiveTimeStart === 'number' ? (
<span className="ml-auto relative flex-shrink-0 tabular-nums text-right">
<span
className={cn(
'text-muted-foreground/80 transition-opacity duration-150',
!isMobile && endedTimestampText && 'group-hover/tool:opacity-0'
)}
>
<LiveDuration
start={effectiveTimeStart}
end={typeof effectiveTimeEnd === 'number' ? effectiveTimeEnd : undefined}
active={Boolean(isTaskTool && isActive && typeof effectiveTimeEnd !== 'number')}
/>
</span>
{!isMobile && endedTimestampText ? (
<span
className={cn(
'pointer-events-none absolute right-0 top-0 whitespace-nowrap text-muted-foreground/70 transition-opacity duration-150',
'opacity-0 group-hover/tool:opacity-100'
)}
>
{endedTimestampText}
</span>
) : null}
</span>
)}
{!justificationText && description && (
<span className={cn("truncate", isMobile && "max-w-[120px]")}>
{description}
</span>
)}
{diffStats && (
<span className="text-muted-foreground/60 flex-shrink-0">
<span style={{ color: 'var(--status-success)' }}>+{diffStats.added}</span>
{' '}
<span style={{ color: 'var(--status-error)' }}>-{diffStats.removed}</span>
</span>
)}
{typeof effectiveTimeStart === 'number' && (
<span className="text-muted-foreground/80 flex-shrink-0 tabular-nums">
<LiveDuration
start={effectiveTimeStart}
end={typeof effectiveTimeEnd === 'number' ? effectiveTimeEnd : undefined}
active={Boolean(isTaskTool && isActive && typeof effectiveTimeEnd !== 'number')}
/>
</span>
)}
{endedTimestampText ? (
<span
className={cn(
'text-muted-foreground/70 flex-shrink-0 tabular-nums transition-opacity duration-150',
'opacity-0 group-hover/tool:opacity-100'
)}
>
) : null}
{typeof effectiveTimeStart !== 'number' && !isMobile && endedTimestampText ? (
<span className="ml-auto text-muted-foreground/70 flex-shrink-0 tabular-nums">
{endedTimestampText}
</span>
) : null}
@@ -1703,6 +1785,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
<TaskToolSummary
entries={taskSummaryEntries}
isExpanded={isExpanded}
isMobile={isMobile}
hasPrevTool={hasPrevTool}
hasNextTool={hasNextTool}
output={taskOutputString}
+25 -3
View File
@@ -136,7 +136,19 @@ async function installWebUpdate(): Promise<InstallWebUpdateResult> {
}
}
async function waitForUpdateApplied(maxAttempts = 40, intervalMs = 2000): Promise<boolean> {
async function isServerReachable(): Promise<boolean> {
try {
const response = await fetch('/health', {
method: 'GET',
headers: { Accept: 'application/json' },
});
return response.ok;
} catch {
return false;
}
}
async function waitForUpdateApplied(previousVersion?: string, maxAttempts = 40, intervalMs = 2000): Promise<boolean> {
for (let i = 0; i < maxAttempts; i++) {
try {
const response = await fetch('/api/openchamber/update-check', {
@@ -148,6 +160,16 @@ async function waitForUpdateApplied(maxAttempts = 40, intervalMs = 2000): Promis
if (data && data.available === false) {
return true;
}
if (
data &&
typeof data.currentVersion === 'string' &&
typeof previousVersion === 'string' &&
data.currentVersion !== previousVersion
) {
return true;
}
} else if ((response.status === 401 || response.status === 403) && await isServerReachable()) {
return true;
}
} catch {
// Server may be restarting
@@ -240,7 +262,7 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
setWebUpdateState('reconnecting');
const applied = await waitForUpdateApplied();
const applied = await waitForUpdateApplied(info?.currentVersion);
if (applied) {
window.location.reload();
@@ -248,7 +270,7 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
setWebUpdateState('error');
setWebError('Update did not apply. Refresh and try again, or run: openchamber update');
}
}, []);
}, [info?.currentVersion]);
const isWebUpdating = webUpdateState !== 'idle' && webUpdateState !== 'error';