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:
committed by
GitHub
parent
7b5fd9e70c
commit
8bfbed0e68
@@ -14,7 +14,7 @@ import { isEmptyTextPart, extractTextContent } from './partUtils';
|
|||||||
import { FadeInOnReveal } from './FadeInOnReveal';
|
import { FadeInOnReveal } from './FadeInOnReveal';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
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 { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
|
|
||||||
@@ -1365,7 +1365,10 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{footerTimestamp ? (
|
{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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1388,7 +1391,10 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{footerTimestamp ? (
|
{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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { formatTimestampForDisplay } from '../timeFormat';
|
import { formatTimestampForDisplay } from '../timeFormat';
|
||||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
|
||||||
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
|
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
|
||||||
|
|
||||||
@@ -98,6 +99,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
time,
|
time,
|
||||||
}) => {
|
}) => {
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
|
|
||||||
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
|
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
|
||||||
const { label, Icon } = variantConfig[variant];
|
const { label, Icon } = variantConfig[variant];
|
||||||
@@ -157,23 +159,35 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
|
|
||||||
{(summary || typeof timeStart === 'number' || endedTimestampText) ? (
|
{(summary || typeof timeStart === 'number' || endedTimestampText) ? (
|
||||||
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta text-muted-foreground/70">
|
<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' ? (
|
{typeof timeStart === 'number' ? (
|
||||||
<span className="text-muted-foreground/80 flex-shrink-0 tabular-nums">
|
<span className="relative flex-shrink-0 tabular-nums text-right">
|
||||||
<LiveDuration
|
<span
|
||||||
start={timeStart}
|
className={cn(
|
||||||
end={timeEnd}
|
'text-muted-foreground/80 transition-opacity duration-150',
|
||||||
active={typeof timeEnd !== 'number'}
|
!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>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{endedTimestampText ? (
|
{typeof timeStart !== 'number' && !isMobile && endedTimestampText ? (
|
||||||
<span
|
<span className="text-muted-foreground/70 flex-shrink-0 tabular-nums">
|
||||||
className={cn(
|
|
||||||
'text-muted-foreground/70 flex-shrink-0 tabular-nums transition-opacity duration-150',
|
|
||||||
'opacity-0 group-hover/tool:opacity-100'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{endedTimestampText}
|
{endedTimestampText}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -157,12 +157,7 @@ const parseDiffStats = (metadata?: Record<string, unknown>): { added: number; re
|
|||||||
return { added, removed };
|
return { added, removed };
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRelativePath = (absolutePath: string, currentDirectory: string, isMobile: boolean): string => {
|
const getRelativePath = (absolutePath: string, currentDirectory: string): string => {
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
return absolutePath.split('/').pop() || absolutePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (absolutePath.startsWith(currentDirectory)) {
|
if (absolutePath.startsWith(currentDirectory)) {
|
||||||
const relativePath = absolutePath.substring(currentDirectory.length);
|
const relativePath = absolutePath.substring(currentDirectory.length);
|
||||||
|
|
||||||
@@ -227,9 +222,9 @@ const parseQuestionOutput = (output: string): Array<{ question: string; answer:
|
|||||||
return pairs.length > 0 ? pairs : null;
|
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) {
|
if (typeof output === 'string' && output.trim().length > 0) {
|
||||||
const maxLength = isMobile ? 50 : 100;
|
const maxLength = 100;
|
||||||
const text = output.trim();
|
const text = output.trim();
|
||||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
||||||
}
|
}
|
||||||
@@ -271,31 +266,64 @@ const formatStructuredOutputDescription = (input: Record<string, unknown> | unde
|
|||||||
return 'Result';
|
return 'Result';
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxLength = isMobile ? 50 : 100;
|
const maxLength = 100;
|
||||||
const truncated = preview.length > maxLength ? `${preview.substring(0, maxLength)}...` : preview;
|
const truncated = preview.length > maxLength ? `${preview.substring(0, maxLength)}...` : preview;
|
||||||
return truncated;
|
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 stateWithData = state as ToolStateWithMetadata;
|
||||||
const metadata = stateWithData.metadata;
|
const metadata = stateWithData.metadata;
|
||||||
const input = stateWithData.input;
|
const input = stateWithData.input;
|
||||||
const tool = part.tool.toLowerCase();
|
const tool = part.tool.toLowerCase();
|
||||||
|
|
||||||
if (tool === 'structuredoutput' || tool === 'structured_output') {
|
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') {
|
if (part.tool === 'apply_patch') {
|
||||||
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
|
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) {
|
if (files.length > 1) {
|
||||||
return `${files.length} files`;
|
return `${files.length} files`;
|
||||||
}
|
}
|
||||||
if (typeof filePath === 'string') {
|
|
||||||
return getRelativePath(filePath, currentDirectory, isMobile);
|
|
||||||
}
|
|
||||||
return 'Patch';
|
return 'Patch';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,27 +333,13 @@ const getToolDescription = (part: ToolPartType, state: ToolStateUnion, isMobile:
|
|||||||
return `Asked ${count} question${count !== 1 ? 's' : ''}`;
|
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') {
|
if (part.tool === 'bash' && input?.command && typeof input.command === 'string') {
|
||||||
const firstLine = input.command.split('\n')[0];
|
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') {
|
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') {
|
if (part.tool === 'skill' && input?.name && typeof input.name === 'string') {
|
||||||
@@ -451,6 +465,32 @@ const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
|
|||||||
return 'tool';
|
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 => {
|
const stripTaskMetadataFromOutput = (output: string): string => {
|
||||||
// Strip only a trailing <task_metadata>...</task_metadata> block.
|
// Strip only a trailing <task_metadata>...</task_metadata> block.
|
||||||
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
|
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<{
|
const TaskToolSummary: React.FC<{
|
||||||
entries: TaskToolSummaryEntry[];
|
entries: TaskToolSummaryEntry[];
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
hasPrevTool: boolean;
|
hasPrevTool: boolean;
|
||||||
hasNextTool: boolean;
|
hasNextTool: boolean;
|
||||||
output?: string;
|
output?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
onShowPopup?: (content: ToolPopupContent) => void;
|
onShowPopup?: (content: ToolPopupContent) => void;
|
||||||
input?: Record<string, unknown>;
|
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 setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
||||||
const displayEntries = React.useMemo(() => {
|
const displayEntries = React.useMemo(() => {
|
||||||
const nonPending = entries.filter((entry) => entry.state?.status !== 'pending');
|
const nonPending = entries.filter((entry) => entry.state?.status !== 'pending');
|
||||||
@@ -611,13 +652,18 @@ const TaskToolSummary: React.FC<{
|
|||||||
const displayName = getToolMetadata(toolName).displayName;
|
const displayName = getToolMetadata(toolName).displayName;
|
||||||
|
|
||||||
return (
|
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="flex-shrink-0 text-foreground/80">{getToolIcon(toolName)}</span>
|
||||||
<span className="typography-meta text-foreground/80 flex-shrink-0">{displayName}</span>
|
<span className="typography-meta text-foreground/80 flex-shrink-0">{displayName}</span>
|
||||||
<span className={cn(
|
{status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? (
|
||||||
'typography-meta truncate',
|
renderPathLikeGitChanges(label)
|
||||||
status === 'error' ? 'text-[var(--status-error)]' : 'text-muted-foreground/70'
|
) : (
|
||||||
)}>{label}</span>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -699,17 +745,25 @@ type DiffPatchEntry = {
|
|||||||
patch: string;
|
patch: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPathLikeGitChanges = (path: string) => {
|
const renderPathLikeGitChanges = (path: string, grow = true) => {
|
||||||
const lastSlash = path.lastIndexOf('/');
|
const lastSlash = path.lastIndexOf('/');
|
||||||
if (lastSlash === -1) {
|
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 dir = path.slice(0, lastSlash);
|
||||||
const name = path.slice(lastSlash + 1);
|
const name = path.slice(lastSlash + 1);
|
||||||
|
|
||||||
return (
|
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' }}>
|
<span className="min-w-0 truncate text-muted-foreground" style={{ direction: 'rtl', textAlign: 'left' }}>
|
||||||
{dir}
|
{dir}
|
||||||
</span>
|
</span>
|
||||||
@@ -725,7 +779,6 @@ const getDiffPatchEntries = (
|
|||||||
metadata: Record<string, unknown> | undefined,
|
metadata: Record<string, unknown> | undefined,
|
||||||
fallbackDiff: string,
|
fallbackDiff: string,
|
||||||
currentDirectory: string,
|
currentDirectory: string,
|
||||||
isMobile: boolean,
|
|
||||||
): DiffPatchEntry[] => {
|
): DiffPatchEntry[] => {
|
||||||
const files = Array.isArray(metadata?.files) ? metadata.files : [];
|
const files = Array.isArray(metadata?.files) ? metadata.files : [];
|
||||||
|
|
||||||
@@ -748,7 +801,7 @@ const getDiffPatchEntries = (
|
|||||||
: `File ${index + 1}`;
|
: `File ${index + 1}`;
|
||||||
|
|
||||||
const title = typeof rawPath === 'string'
|
const title = typeof rawPath === 'string'
|
||||||
? getRelativePath(rawPath, currentDirectory, isMobile)
|
? getRelativePath(rawPath, currentDirectory)
|
||||||
: `File ${index + 1}`;
|
: `File ${index + 1}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -824,8 +877,9 @@ const WriteInputPreview: React.FC<WriteInputPreviewProps> = React.memo(({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full min-w-0">
|
<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)' }}>
|
<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)' }}>
|
||||||
{`${displayPath} (${headerLineLabel})`}
|
{renderPathLikeGitChanges(displayPath)}
|
||||||
|
<span className="typography-meta text-muted-foreground/80 flex-shrink-0">({headerLineLabel})</span>
|
||||||
</div>
|
</div>
|
||||||
<PierreFile
|
<PierreFile
|
||||||
file={{
|
file={{
|
||||||
@@ -853,6 +907,7 @@ interface ReadToolVirtualizedProps {
|
|||||||
input?: Record<string, unknown>;
|
input?: Record<string, unknown>;
|
||||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||||
toolName: string;
|
toolName: string;
|
||||||
|
currentDirectory: string;
|
||||||
pierreTheme: { light: string; dark: string };
|
pierreTheme: { light: string; dark: string };
|
||||||
pierreThemeType: 'light' | 'dark';
|
pierreThemeType: 'light' | 'dark';
|
||||||
renderScrollableBlock: (
|
renderScrollableBlock: (
|
||||||
@@ -866,6 +921,7 @@ const ReadToolVirtualized: React.FC<ReadToolVirtualizedProps> = React.memo(({
|
|||||||
input,
|
input,
|
||||||
syntaxTheme,
|
syntaxTheme,
|
||||||
toolName,
|
toolName,
|
||||||
|
currentDirectory,
|
||||||
pierreTheme,
|
pierreTheme,
|
||||||
pierreThemeType,
|
pierreThemeType,
|
||||||
renderScrollableBlock,
|
renderScrollableBlock,
|
||||||
@@ -877,7 +933,7 @@ const ReadToolVirtualized: React.FC<ReadToolVirtualizedProps> = React.memo(({
|
|||||||
return detectLanguageFromOutput(contentForLanguage, toolName, input as Record<string, unknown>);
|
return detectLanguageFromOutput(contentForLanguage, toolName, input as Record<string, unknown>);
|
||||||
}, [parsedReadOutput, toolName, input]);
|
}, [parsedReadOutput, toolName, input]);
|
||||||
|
|
||||||
const filePath =
|
const rawFilePath =
|
||||||
typeof input?.filePath === 'string'
|
typeof input?.filePath === 'string'
|
||||||
? input.filePath
|
? input.filePath
|
||||||
: typeof input?.file_path === 'string'
|
: typeof input?.file_path === 'string'
|
||||||
@@ -885,6 +941,7 @@ const ReadToolVirtualized: React.FC<ReadToolVirtualizedProps> = React.memo(({
|
|||||||
: typeof input?.path === 'string'
|
: typeof input?.path === 'string'
|
||||||
? input.path
|
? input.path
|
||||||
: 'read-output';
|
: 'read-output';
|
||||||
|
const displayPath = getRelativePath(rawFilePath, currentDirectory);
|
||||||
|
|
||||||
const codeLines: CodeLine[] = React.useMemo(() => parsedReadOutput.lines.map((line) => ({
|
const codeLines: CodeLine[] = React.useMemo(() => parsedReadOutput.lines.map((line) => ({
|
||||||
text: line.text,
|
text: line.text,
|
||||||
@@ -894,21 +951,29 @@ const ReadToolVirtualized: React.FC<ReadToolVirtualizedProps> = React.memo(({
|
|||||||
|
|
||||||
if (parsedReadOutput.type === 'file') {
|
if (parsedReadOutput.type === 'file') {
|
||||||
const fileContent = parsedReadOutput.lines.map((line) => line.text).join('\n');
|
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(
|
return renderScrollableBlock(
|
||||||
<PierreFile
|
<div className="w-full min-w-0">
|
||||||
file={{
|
<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)' }}>
|
||||||
name: filePath,
|
{renderPathLikeGitChanges(displayPath)}
|
||||||
contents: fileContent,
|
<span className="typography-meta text-muted-foreground/80 flex-shrink-0">({headerLineLabel})</span>
|
||||||
lang: language || undefined,
|
</div>
|
||||||
}}
|
<PierreFile
|
||||||
options={{
|
file={{
|
||||||
disableFileHeader: true,
|
name: displayPath,
|
||||||
overflow: 'wrap',
|
contents: fileContent,
|
||||||
theme: pierreTheme,
|
lang: language || undefined,
|
||||||
themeType: pierreThemeType,
|
}}
|
||||||
}}
|
options={{
|
||||||
className="block w-full"
|
disableFileHeader: true,
|
||||||
/>,
|
overflow: 'wrap',
|
||||||
|
theme: pierreTheme,
|
||||||
|
themeType: pierreThemeType,
|
||||||
|
}}
|
||||||
|
className="block w-full"
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
{ className: 'p-1' }
|
{ className: 'p-1' }
|
||||||
) as React.ReactElement;
|
) as React.ReactElement;
|
||||||
}
|
}
|
||||||
@@ -951,8 +1016,8 @@ const ImagePreview: React.FC<ImagePreviewProps> = React.memo(({ content, filePat
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full min-w-0">
|
<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)' }}>
|
<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)' }}>
|
||||||
{displayPath}
|
{renderPathLikeGitChanges(displayPath)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center p-4 bg-muted/10 rounded-lg" style={{ borderWidth: '1px', borderColor: 'var(--tools-border)' }}>
|
<div className="flex justify-center p-4 bg-muted/10 rounded-lg" style={{ borderWidth: '1px', borderColor: 'var(--tools-border)' }}>
|
||||||
<img
|
<img
|
||||||
@@ -1000,8 +1065,8 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
|
|
||||||
const diffContent = typeof metadata?.diff === 'string' ? (metadata.diff as string) : null;
|
const diffContent = typeof metadata?.diff === 'string' ? (metadata.diff as string) : null;
|
||||||
const diffEntries = React.useMemo(
|
const diffEntries = React.useMemo(
|
||||||
() => (diffContent ? getDiffPatchEntries(metadata, diffContent, currentDirectory, isMobile) : []),
|
() => (diffContent ? getDiffPatchEntries(metadata, diffContent, currentDirectory) : []),
|
||||||
[currentDirectory, diffContent, isMobile, metadata]
|
[currentDirectory, diffContent, metadata]
|
||||||
);
|
);
|
||||||
const writeFilePath = part.tool === 'write'
|
const writeFilePath = part.tool === 'write'
|
||||||
? typeof input?.filePath === 'string'
|
? typeof input?.filePath === 'string'
|
||||||
@@ -1022,7 +1087,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
const shouldShowWriteInputPreview = part.tool === 'write' && !!writeInputContent;
|
const shouldShowWriteInputPreview = part.tool === 'write' && !!writeInputContent;
|
||||||
const isWriteImageFile = writeFilePath ? isImageFile(writeFilePath) : false;
|
const isWriteImageFile = writeFilePath ? isImageFile(writeFilePath) : false;
|
||||||
const writeDisplayPath = shouldShowWriteInputPreview
|
const writeDisplayPath = shouldShowWriteInputPreview
|
||||||
? (writeFilePath ? getRelativePath(writeFilePath, currentDirectory, isMobile) : 'New file')
|
? (writeFilePath ? getRelativePath(writeFilePath, currentDirectory) : 'New file')
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const inputTextContent = React.useMemo(() => {
|
const inputTextContent = React.useMemo(() => {
|
||||||
@@ -1224,6 +1289,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
input={input}
|
input={input}
|
||||||
syntaxTheme={syntaxTheme}
|
syntaxTheme={syntaxTheme}
|
||||||
toolName={part.tool}
|
toolName={part.tool}
|
||||||
|
currentDirectory={currentDirectory}
|
||||||
pierreTheme={pierreTheme}
|
pierreTheme={pierreTheme}
|
||||||
pierreThemeType={pierreThemeType}
|
pierreThemeType={pierreThemeType}
|
||||||
renderScrollableBlock={renderScrollableBlock}
|
renderScrollableBlock={renderScrollableBlock}
|
||||||
@@ -1543,7 +1609,8 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
}, [isTaskTool, onContentChange, taskSummaryEntries.length]);
|
}, [isTaskTool, onContentChange, taskSummaryEntries.length]);
|
||||||
|
|
||||||
const diffStats = (part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') ? parseDiffStats(metadata) : null;
|
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;
|
const displayName = getToolMetadata(part.tool).displayName;
|
||||||
|
|
||||||
// Get justification text (tool title/description) when setting is enabled
|
// 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">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{}
|
{}
|
||||||
<button
|
<div
|
||||||
type="button"
|
|
||||||
className="relative h-3.5 w-3.5 flex-shrink-0"
|
className="relative h-3.5 w-3.5 flex-shrink-0"
|
||||||
onClick={(event) => { event.stopPropagation(); onToggle(part.id); }}
|
onClick={(event) => { event.stopPropagation(); onToggle(part.id); }}
|
||||||
aria-label={isExpanded ? 'Collapse tool details' : 'Expand tool details'}
|
|
||||||
>
|
>
|
||||||
{}
|
{}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 transition-opacity',
|
'absolute inset-0 transition-opacity',
|
||||||
isExpanded && 'opacity-0',
|
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)' }}
|
style={!isTaskTool && isError ? { color: 'var(--status-error)' } : { color: 'var(--tools-icon)' }}
|
||||||
>
|
>
|
||||||
@@ -1643,13 +1708,12 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 transition-opacity flex items-center justify-center',
|
'absolute inset-0 transition-opacity flex items-center justify-center',
|
||||||
isExpanded && 'opacity-100',
|
isExpanded && 'opacity-100',
|
||||||
!isExpanded && isMobile && 'opacity-0',
|
!isExpanded && 'opacity-0 group-hover/tool:opacity-100'
|
||||||
!isExpanded && !isMobile && 'opacity-0 group-hover/tool:opacity-100'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isExpanded ? <RiArrowDownSLine className="h-3.5 w-3.5" /> : <RiArrowRightSLine className="h-3.5 w-3.5" />}
|
{isExpanded ? <RiArrowDownSLine className="h-3.5 w-3.5" /> : <RiArrowRightSLine className="h-3.5 w-3.5" />}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
<span
|
<span
|
||||||
className="typography-meta font-medium"
|
className="typography-meta font-medium"
|
||||||
style={!isTaskTool && isError ? { color: 'var(--status-error)' } : { color: 'var(--tools-title)' }}
|
style={!isTaskTool && isError ? { color: 'var(--status-error)' } : { color: 'var(--tools-title)' }}
|
||||||
@@ -1659,39 +1723,57 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta" style={{ color: 'var(--tools-description)' }}>
|
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta" style={{ color: 'var(--tools-description)' }}>
|
||||||
{justificationText && (
|
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||||
<span className={cn("truncate", isMobile && "max-w-[120px]")} style={{ color: 'var(--tools-description)', opacity: 0.8 }}>
|
{justificationText && (
|
||||||
{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>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
{!justificationText && description && (
|
{typeof effectiveTimeStart !== 'number' && !isMobile && endedTimestampText ? (
|
||||||
<span className={cn("truncate", isMobile && "max-w-[120px]")}>
|
<span className="ml-auto text-muted-foreground/70 flex-shrink-0 tabular-nums">
|
||||||
{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'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{endedTimestampText}
|
{endedTimestampText}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1703,6 +1785,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
<TaskToolSummary
|
<TaskToolSummary
|
||||||
entries={taskSummaryEntries}
|
entries={taskSummaryEntries}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
|
isMobile={isMobile}
|
||||||
hasPrevTool={hasPrevTool}
|
hasPrevTool={hasPrevTool}
|
||||||
hasNextTool={hasNextTool}
|
hasNextTool={hasNextTool}
|
||||||
output={taskOutputString}
|
output={taskOutputString}
|
||||||
|
|||||||
@@ -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++) {
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/openchamber/update-check', {
|
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) {
|
if (data && data.available === false) {
|
||||||
return true;
|
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 {
|
} catch {
|
||||||
// Server may be restarting
|
// Server may be restarting
|
||||||
@@ -240,7 +262,7 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
|||||||
|
|
||||||
setWebUpdateState('reconnecting');
|
setWebUpdateState('reconnecting');
|
||||||
|
|
||||||
const applied = await waitForUpdateApplied();
|
const applied = await waitForUpdateApplied(info?.currentVersion);
|
||||||
|
|
||||||
if (applied) {
|
if (applied) {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -248,7 +270,7 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
|||||||
setWebUpdateState('error');
|
setWebUpdateState('error');
|
||||||
setWebError('Update did not apply. Refresh and try again, or run: openchamber update');
|
setWebError('Update did not apply. Refresh and try again, or run: openchamber update');
|
||||||
}
|
}
|
||||||
}, []);
|
}, [info?.currentVersion]);
|
||||||
|
|
||||||
const isWebUpdating = webUpdateState !== 'idle' && webUpdateState !== 'error';
|
const isWebUpdating = webUpdateState !== 'idle' && webUpdateState !== 'error';
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ function getBunBinary() {
|
|||||||
|
|
||||||
const BUN_BIN = getBunBinary();
|
const BUN_BIN = getBunBinary();
|
||||||
|
|
||||||
|
function importFromFilePath(filePath) {
|
||||||
|
return import(pathToFileURL(filePath).href);
|
||||||
|
}
|
||||||
|
|
||||||
function isBunRuntime() {
|
function isBunRuntime() {
|
||||||
return typeof globalThis.Bun !== 'undefined';
|
return typeof globalThis.Bun !== 'undefined';
|
||||||
}
|
}
|
||||||
@@ -625,7 +629,7 @@ const commands = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { startWebUiServer } = await import(pathToFileURL(serverPath).href);
|
const { startWebUiServer } = await importFromFilePath(serverPath);
|
||||||
await startWebUiServer({
|
await startWebUiServer({
|
||||||
port: options.port,
|
port: options.port,
|
||||||
attachSignals: true,
|
attachSignals: true,
|
||||||
@@ -926,7 +930,7 @@ const commands = {
|
|||||||
executeUpdate,
|
executeUpdate,
|
||||||
detectPackageManager,
|
detectPackageManager,
|
||||||
getCurrentVersion,
|
getCurrentVersion,
|
||||||
} = await import(pathToFileURL(packageManagerPath).href);
|
} = await importFromFilePath(packageManagerPath);
|
||||||
|
|
||||||
// Check for running instances before update
|
// Check for running instances before update
|
||||||
let runningInstances = [];
|
let runningInstances = [];
|
||||||
|
|||||||
@@ -3367,27 +3367,30 @@ const ENV_CONFIGURED_OPENCODE_PORT = (() => {
|
|||||||
const ENV_CONFIGURED_OPENCODE_HOST = (() => {
|
const ENV_CONFIGURED_OPENCODE_HOST = (() => {
|
||||||
const raw = process.env.OPENCODE_HOST?.trim();
|
const raw = process.env.OPENCODE_HOST?.trim();
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
|
|
||||||
|
const warnInvalidHost = (reason) => {
|
||||||
|
console.warn(`[config] Ignoring OPENCODE_HOST=${JSON.stringify(raw)}: ${reason}`);
|
||||||
|
};
|
||||||
|
|
||||||
let url;
|
let url;
|
||||||
try {
|
try {
|
||||||
url = new URL(raw);
|
url = new URL(raw);
|
||||||
} catch {
|
} catch {
|
||||||
console.error(`[fatal] OPENCODE_HOST is not a valid URL: ${JSON.stringify(raw)}`);
|
warnInvalidHost('not a valid URL');
|
||||||
process.exit(1);
|
return null;
|
||||||
}
|
}
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
console.error(`[fatal] OPENCODE_HOST must use http or https scheme, got: ${JSON.stringify(url.protocol)}`);
|
warnInvalidHost(`must use http or https scheme (got ${JSON.stringify(url.protocol)})`);
|
||||||
process.exit(1);
|
return null;
|
||||||
}
|
}
|
||||||
const port = parseInt(url.port, 10);
|
const port = parseInt(url.port, 10);
|
||||||
if (!Number.isFinite(port) || port <= 0) {
|
if (!Number.isFinite(port) || port <= 0) {
|
||||||
console.error(`[fatal] OPENCODE_HOST must include an explicit port (e.g. http://hostname:4096), got: ${JSON.stringify(raw)}`);
|
warnInvalidHost('must include an explicit port (example: http://hostname:4096)');
|
||||||
process.exit(1);
|
return null;
|
||||||
}
|
}
|
||||||
if (url.pathname !== '/' || url.search || url.hash) {
|
if (url.pathname !== '/' || url.search || url.hash) {
|
||||||
console.error(
|
warnInvalidHost('must not include path, query, or hash');
|
||||||
`[fatal] OPENCODE_HOST must not include a path, query, or hash; got: ${JSON.stringify(raw)}`
|
return null;
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
return { origin: url.origin, port };
|
return { origin: url.origin, port };
|
||||||
})();
|
})();
|
||||||
@@ -7240,19 +7243,39 @@ async function main(options = {}) {
|
|||||||
|
|
||||||
const isWindows = process.platform === 'win32';
|
const isWindows = process.platform === 'win32';
|
||||||
|
|
||||||
// Build restart command with stored options
|
const quotePosix = (value) => `'${String(value).replace(/'/g, "'\\''")}'`;
|
||||||
let restartCmd = `openchamber serve --port ${storedOptions.port} --daemon`;
|
const quoteCmd = (value) => {
|
||||||
|
const stringValue = String(value);
|
||||||
|
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build restart command using explicit runtime + CLI path.
|
||||||
|
// Avoids relying on `openchamber` being in PATH for service environments.
|
||||||
|
const cliPath = path.resolve(__dirname, '..', 'bin', 'cli.js');
|
||||||
|
const restartParts = [
|
||||||
|
isWindows ? quoteCmd(process.execPath) : quotePosix(process.execPath),
|
||||||
|
isWindows ? quoteCmd(cliPath) : quotePosix(cliPath),
|
||||||
|
'serve',
|
||||||
|
'--port',
|
||||||
|
String(storedOptions.port),
|
||||||
|
'--daemon',
|
||||||
|
];
|
||||||
|
let restartCmdPrimary = restartParts.join(' ');
|
||||||
|
let restartCmdFallback = `openchamber serve --port ${storedOptions.port} --daemon`;
|
||||||
if (storedOptions.uiPassword) {
|
if (storedOptions.uiPassword) {
|
||||||
if (isWindows) {
|
if (isWindows) {
|
||||||
// Escape for cmd.exe quoted argument
|
// Escape for cmd.exe quoted argument
|
||||||
const escapedPw = storedOptions.uiPassword.replace(/"/g, '""');
|
const escapedPw = storedOptions.uiPassword.replace(/"/g, '""');
|
||||||
restartCmd += ` --ui-password "${escapedPw}"`;
|
restartCmdPrimary += ` --ui-password "${escapedPw}"`;
|
||||||
|
restartCmdFallback += ` --ui-password "${escapedPw}"`;
|
||||||
} else {
|
} else {
|
||||||
// Escape for POSIX single-quoted argument
|
// Escape for POSIX single-quoted argument
|
||||||
const escapedPw = storedOptions.uiPassword.replace(/'/g, "'\\''");
|
const escapedPw = storedOptions.uiPassword.replace(/'/g, "'\\''");
|
||||||
restartCmd += ` --ui-password '${escapedPw}'`;
|
restartCmdPrimary += ` --ui-password '${escapedPw}'`;
|
||||||
|
restartCmdFallback += ` --ui-password '${escapedPw}'`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const restartCmd = `(${restartCmdPrimary}) || (${restartCmdFallback})`;
|
||||||
|
|
||||||
// Respond immediately - update will happen after response
|
// Respond immediately - update will happen after response
|
||||||
res.json({
|
res.json({
|
||||||
@@ -7298,14 +7321,32 @@ async function main(options = {}) {
|
|||||||
fi
|
fi
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Spawn detached shell to run update after we exit
|
// Spawn detached shell to run update after we exit.
|
||||||
|
// Capture output to disk so restart failures are diagnosable.
|
||||||
|
const updateLogPath = path.join(OPENCHAMBER_DATA_DIR, 'update-install.log');
|
||||||
|
let logFd = null;
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(updateLogPath), { recursive: true });
|
||||||
|
logFd = fs.openSync(updateLogPath, 'a');
|
||||||
|
} catch (logError) {
|
||||||
|
console.warn('Failed to open update log file, continuing without log capture:', logError);
|
||||||
|
}
|
||||||
|
|
||||||
const child = spawnChild(shell, [shellFlag, script], {
|
const child = spawnChild(shell, [shellFlag, script], {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: 'ignore',
|
stdio: logFd !== null ? ['ignore', logFd, logFd] : 'ignore',
|
||||||
env: process.env,
|
env: process.env,
|
||||||
});
|
});
|
||||||
child.unref();
|
child.unref();
|
||||||
|
|
||||||
|
if (logFd !== null) {
|
||||||
|
try {
|
||||||
|
fs.closeSync(logFd);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Update process spawned, shutting down server...');
|
console.log('Update process spawned, shutting down server...');
|
||||||
|
|
||||||
// Give child process time to start, then exit
|
// Give child process time to start, then exit
|
||||||
|
|||||||
@@ -19,7 +19,21 @@ const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber
|
|||||||
* 4. Fall back to npm
|
* 4. Fall back to npm
|
||||||
*/
|
*/
|
||||||
export function detectPackageManager() {
|
export function detectPackageManager() {
|
||||||
// Strategy 1: Check user agent (most reliable during install)
|
const forcedPm = process.env.OPENCHAMBER_PACKAGE_MANAGER?.trim();
|
||||||
|
if (forcedPm && ['npm', 'pnpm', 'yarn', 'bun'].includes(forcedPm)) {
|
||||||
|
const forcedPmCommand = resolvePackageManagerCommand(forcedPm);
|
||||||
|
if (isCommandAvailable(forcedPmCommand)) {
|
||||||
|
return forcedPm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 1: Detect from runtime executable path (reliable for server-side updates)
|
||||||
|
const runtimePm = detectPackageManagerFromRuntimePath(process.execPath);
|
||||||
|
if (runtimePm && isCommandAvailable(resolvePackageManagerCommand(runtimePm))) {
|
||||||
|
return runtimePm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: Check user agent (most reliable during install)
|
||||||
const userAgent = process.env.npm_config_user_agent || '';
|
const userAgent = process.env.npm_config_user_agent || '';
|
||||||
let hintedPm = null;
|
let hintedPm = null;
|
||||||
if (userAgent.startsWith('pnpm')) hintedPm = 'pnpm';
|
if (userAgent.startsWith('pnpm')) hintedPm = 'pnpm';
|
||||||
@@ -27,7 +41,7 @@ export function detectPackageManager() {
|
|||||||
else if (userAgent.startsWith('bun')) hintedPm = 'bun';
|
else if (userAgent.startsWith('bun')) hintedPm = 'bun';
|
||||||
else if (userAgent.startsWith('npm')) hintedPm = 'npm';
|
else if (userAgent.startsWith('npm')) hintedPm = 'npm';
|
||||||
|
|
||||||
// Strategy 2: Check execpath
|
// Strategy 3: Check execpath
|
||||||
const execPath = process.env.npm_execpath || '';
|
const execPath = process.env.npm_execpath || '';
|
||||||
if (!hintedPm) {
|
if (!hintedPm) {
|
||||||
if (execPath.includes('pnpm')) hintedPm = 'pnpm';
|
if (execPath.includes('pnpm')) hintedPm = 'pnpm';
|
||||||
@@ -36,34 +50,41 @@ export function detectPackageManager() {
|
|||||||
else if (execPath.includes('npm')) hintedPm = 'npm';
|
else if (execPath.includes('npm')) hintedPm = 'npm';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Analyze package location for PM-specific patterns
|
// Strategy 4: Detect from invoked binary path (works for bun global symlink installs)
|
||||||
|
const invokedPm = detectPackageManagerFromInvocationPath(process.argv?.[1]);
|
||||||
|
if (invokedPm && isCommandAvailable(resolvePackageManagerCommand(invokedPm))) {
|
||||||
|
return invokedPm;
|
||||||
|
}
|
||||||
if (!hintedPm) {
|
if (!hintedPm) {
|
||||||
try {
|
hintedPm = invokedPm;
|
||||||
const pkgPath = path.resolve(__dirname, '..', '..');
|
}
|
||||||
if (pkgPath.includes('.pnpm')) hintedPm = 'pnpm';
|
|
||||||
else if (pkgPath.includes('/.yarn/') || pkgPath.includes('\\.yarn\\')) hintedPm = 'yarn';
|
// Strategy 5: Analyze package location for PM-specific patterns
|
||||||
else if (pkgPath.includes('/.bun/') || pkgPath.includes('\\.bun\\')) hintedPm = 'bun';
|
try {
|
||||||
} catch {
|
const pkgPath = path.resolve(__dirname, '..', '..');
|
||||||
// Ignore path resolution errors
|
const pmFromPath = detectPackageManagerFromInstallPath(pkgPath);
|
||||||
|
if (pmFromPath && isCommandAvailable(resolvePackageManagerCommand(pmFromPath))) {
|
||||||
|
return pmFromPath;
|
||||||
}
|
}
|
||||||
|
if (!hintedPm) {
|
||||||
|
hintedPm = pmFromPath;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore path resolution errors
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the hinted PM actually owns the global install.
|
// Validate the hinted PM actually owns the global install.
|
||||||
// This avoids false positives (for example running via bunx while installed with npm).
|
// This avoids false positives (for example running via bunx while installed with npm).
|
||||||
if (hintedPm && isCommandAvailable(hintedPm) && isPackageInstalledWith(hintedPm)) {
|
if (hintedPm && isCommandAvailable(resolvePackageManagerCommand(hintedPm)) && isPackageInstalledWith(hintedPm)) {
|
||||||
return hintedPm;
|
return hintedPm;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCommandAvailable('npm') && isPackageInstalledWith('npm')) {
|
// Strategy 6: Check which PM binaries are available and preferred
|
||||||
return 'npm';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 4: Check which PM binaries are available and preferred
|
|
||||||
const pmChecks = [
|
const pmChecks = [
|
||||||
{ name: 'npm', check: () => isCommandAvailable('npm') },
|
{ name: 'pnpm', check: () => isCommandAvailable(resolvePackageManagerCommand('pnpm')) },
|
||||||
{ name: 'pnpm', check: () => isCommandAvailable('pnpm') },
|
{ name: 'yarn', check: () => isCommandAvailable(resolvePackageManagerCommand('yarn')) },
|
||||||
{ name: 'yarn', check: () => isCommandAvailable('yarn') },
|
{ name: 'bun', check: () => isCommandAvailable(resolvePackageManagerCommand('bun')) },
|
||||||
{ name: 'bun', check: () => isCommandAvailable('bun') },
|
{ name: 'npm', check: () => isCommandAvailable(resolvePackageManagerCommand('npm')) },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const { name, check } of pmChecks) {
|
for (const { name, check } of pmChecks) {
|
||||||
@@ -78,6 +99,74 @@ export function detectPackageManager() {
|
|||||||
return 'npm';
|
return 'npm';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectPackageManagerFromInstallPath(pkgPath) {
|
||||||
|
if (!pkgPath) return null;
|
||||||
|
const normalized = pkgPath.replace(/\\/g, '/').toLowerCase();
|
||||||
|
if (normalized.includes('/.pnpm/') || normalized.includes('/pnpm/')) return 'pnpm';
|
||||||
|
if (normalized.includes('/.yarn/')) return 'yarn';
|
||||||
|
if (normalized.includes('/.bun/') || normalized.includes('/bun/install/')) return 'bun';
|
||||||
|
if (normalized.includes('/node_modules/')) return 'npm';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPackageManagerFromRuntimePath(runtimePath) {
|
||||||
|
if (!runtimePath || typeof runtimePath !== 'string') return null;
|
||||||
|
const normalized = runtimePath.replace(/\\/g, '/').toLowerCase();
|
||||||
|
if (normalized.includes('/.bun/bin/bun') || normalized.endsWith('/bun') || normalized.endsWith('/bun.exe')) {
|
||||||
|
return 'bun';
|
||||||
|
}
|
||||||
|
if (normalized.includes('/pnpm/')) return 'pnpm';
|
||||||
|
if (normalized.includes('/yarn/')) return 'yarn';
|
||||||
|
if (normalized.includes('/node') || normalized.endsWith('/node.exe')) return 'npm';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPackageManagerFromInvocationPath(invokedPath) {
|
||||||
|
if (!invokedPath || typeof invokedPath !== 'string') return null;
|
||||||
|
const normalized = invokedPath.replace(/\\/g, '/').toLowerCase();
|
||||||
|
if (normalized.includes('/.bun/bin/')) return 'bun';
|
||||||
|
if (normalized.includes('/.pnpm/')) return 'pnpm';
|
||||||
|
if (normalized.includes('/.yarn/')) return 'yarn';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPackageManagerCommandCandidates(pm) {
|
||||||
|
const candidates = [];
|
||||||
|
if (pm === 'bun') {
|
||||||
|
const bunExecutable = process.platform === 'win32' ? 'bun.exe' : 'bun';
|
||||||
|
if (process.env.BUN_INSTALL) {
|
||||||
|
candidates.push(path.join(process.env.BUN_INSTALL, 'bin', bunExecutable));
|
||||||
|
}
|
||||||
|
if (process.env.HOME) {
|
||||||
|
candidates.push(path.join(process.env.HOME, '.bun', 'bin', bunExecutable));
|
||||||
|
}
|
||||||
|
if (process.env.USERPROFILE) {
|
||||||
|
candidates.push(path.join(process.env.USERPROFILE, '.bun', 'bin', bunExecutable));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.push(pm);
|
||||||
|
return [...new Set(candidates.filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePackageManagerCommand(pm) {
|
||||||
|
const candidates = getPackageManagerCommandCandidates(pm);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (isCommandAvailable(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pm;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteCommand(command) {
|
||||||
|
if (!command) return command;
|
||||||
|
if (!/\s/.test(command)) return command;
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return `"${command.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return `'${command.replace(/'/g, "'\\''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
function isCommandAvailable(command) {
|
function isCommandAvailable(command) {
|
||||||
try {
|
try {
|
||||||
const result = spawnSync(command, ['--version'], {
|
const result = spawnSync(command, ['--version'], {
|
||||||
@@ -93,6 +182,7 @@ function isCommandAvailable(command) {
|
|||||||
|
|
||||||
function isPackageInstalledWith(pm) {
|
function isPackageInstalledWith(pm) {
|
||||||
try {
|
try {
|
||||||
|
const pmCommand = resolvePackageManagerCommand(pm);
|
||||||
let args;
|
let args;
|
||||||
switch (pm) {
|
switch (pm) {
|
||||||
case 'pnpm':
|
case 'pnpm':
|
||||||
@@ -108,7 +198,7 @@ function isPackageInstalledWith(pm) {
|
|||||||
args = ['list', '-g', '--depth=0', PACKAGE_NAME];
|
args = ['list', '-g', '--depth=0', PACKAGE_NAME];
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = spawnSync(pm, args, {
|
const result = spawnSync(pmCommand, args, {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
@@ -125,15 +215,16 @@ function isPackageInstalledWith(pm) {
|
|||||||
* Get the update command for the detected package manager
|
* Get the update command for the detected package manager
|
||||||
*/
|
*/
|
||||||
export function getUpdateCommand(pm = detectPackageManager()) {
|
export function getUpdateCommand(pm = detectPackageManager()) {
|
||||||
|
const pmCommand = quoteCommand(resolvePackageManagerCommand(pm));
|
||||||
switch (pm) {
|
switch (pm) {
|
||||||
case 'pnpm':
|
case 'pnpm':
|
||||||
return `pnpm add -g ${PACKAGE_NAME}@latest`;
|
return `${pmCommand} add -g ${PACKAGE_NAME}@latest`;
|
||||||
case 'yarn':
|
case 'yarn':
|
||||||
return `yarn global add ${PACKAGE_NAME}@latest`;
|
return `${pmCommand} global add ${PACKAGE_NAME}@latest`;
|
||||||
case 'bun':
|
case 'bun':
|
||||||
return `bun add -g ${PACKAGE_NAME}@latest`;
|
return `${pmCommand} add -g ${PACKAGE_NAME}@latest`;
|
||||||
default:
|
default:
|
||||||
return `npm install -g ${PACKAGE_NAME}@latest`;
|
return `${pmCommand} install -g ${PACKAGE_NAME}@latest`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,8 +350,7 @@ export function executeUpdate(pm = detectPackageManager()) {
|
|||||||
console.log(`Updating ${PACKAGE_NAME} using ${pm}...`);
|
console.log(`Updating ${PACKAGE_NAME} using ${pm}...`);
|
||||||
console.log(`Running: ${command}`);
|
console.log(`Running: ${command}`);
|
||||||
|
|
||||||
const [cmd, ...args] = command.split(' ');
|
const result = spawnSync(command, {
|
||||||
const result = spawnSync(cmd, args, {
|
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
shell: true,
|
shell: true,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user