Chat input updates with Stop button and update dialog changelog UI (#241)

* feat: re-organizaed input footer UI in chat

Add StopIcon component and use it for aborting generation in chat controls
Introduce ProviderLogoOrFallback to show provider logo or fallback icon in model controls
Remove mobile-only abort button in StatusRow and simplify abort handling

* feat: improved render of changelog in UpdateDialog
This commit is contained in:
Bohdan Triapitsyn
2026-01-29 18:23:43 +02:00
committed by GitHub
parent 0bd4dc12e4
commit c5158ff52c
6 changed files with 251 additions and 116 deletions
+1 -1
View File
@@ -2994,7 +2994,7 @@ dependencies = [
[[package]] [[package]]
name = "openchamber-desktop" name = "openchamber-desktop"
version = "1.5.9" version = "1.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
+21 -44
View File
@@ -4,7 +4,6 @@ import {
RiAddCircleLine, RiAddCircleLine,
RiAiAgentLine, RiAiAgentLine,
RiAttachment2, RiAttachment2,
RiCloseCircleLine,
RiFileUploadLine, RiFileUploadLine,
RiSendPlane2Line, RiSendPlane2Line,
} from '@remixicon/react'; } from '@remixicon/react';
@@ -31,6 +30,7 @@ import { toast } from '@/components/ui';
import { useFileStore } from '@/stores/fileStore'; import { useFileStore } from '@/stores/fileStore';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
import { isIMECompositionEvent } from '@/lib/ime'; import { isIMECompositionEvent } from '@/lib/ime';
import { StopIcon } from '@/components/icons/StopIcon';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -121,7 +121,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const abortCurrentOperation = useSessionStore((state) => state.abortCurrentOperation); const abortCurrentOperation = useSessionStore((state) => state.abortCurrentOperation);
const acknowledgeSessionAbort = useSessionStore((state) => state.acknowledgeSessionAbort); const acknowledgeSessionAbort = useSessionStore((state) => state.acknowledgeSessionAbort);
const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId); const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId);
const abortPromptExpiresAt = useSessionStore((state) => state.abortPromptExpiresAt);
const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt); const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt);
const sessionAbortFlags = useSessionStore((state) => state.sessionAbortFlags); const sessionAbortFlags = useSessionStore((state) => state.sessionAbortFlags);
const attachedFiles = useSessionStore((state) => state.attachedFiles); const attachedFiles = useSessionStore((state) => state.attachedFiles);
@@ -297,11 +296,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const canAbort = working.isWorking; const canAbort = working.isWorking;
const isAbortPromptActive = React.useMemo(() => {
if (!currentSessionId) return false;
return abortPromptSessionId === currentSessionId && Boolean(abortPromptExpiresAt);
}, [abortPromptSessionId, abortPromptExpiresAt, currentSessionId]);
// Add message to queue instead of sending // Add message to queue instead of sending
const handleQueueMessage = React.useCallback(() => { const handleQueueMessage = React.useCallback(() => {
if (!hasContent || !currentSessionId) return; if (!hasContent || !currentSessionId) return;
@@ -1138,7 +1132,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
event.target.value = ''; event.target.value = '';
}, [attachFiles]); }, [attachFiles]);
const footerGapClass = 'gap-x-1.5 gap-y-0'; const footerGapClass = isMobile ? 'gap-x-0.5 gap-y-0' : 'gap-x-1.5 gap-y-0';
const isVSCode = isVSCodeRuntime(); const isVSCode = isVSCodeRuntime();
const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : (isVSCode ? 'px-1.5 py-1' : 'px-2.5 py-1.5'); const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : (isVSCode ? 'px-1.5 py-1' : 'px-2.5 py-1.5');
const footerHeightClass = isMobile ? 'h-9 w-9' : (isVSCode ? 'h-[22px] w-[22px]' : 'h-7 w-7'); const footerHeightClass = isMobile ? 'h-9 w-9' : (isVSCode ? 'h-[22px] w-[22px]' : 'h-7 w-7');
@@ -1149,22 +1143,21 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0' 'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0'
); );
// Desktop and VSCode: show abort button in footer when Esc triggered const cancelButton = canAbort ? (
const showAbortInFooter = !isMobile && isAbortPromptActive && canAbort;
const actionButton = showAbortInFooter ? (
<button <button
type='button' type="button"
onClick={handleAbort} onClick={handleAbort}
className={cn( className={cn(
iconButtonBaseClass, iconButtonBaseClass,
'text-[var(--status-error)] hover:text-[var(--status-error)]' 'text-[var(--status-error)] hover:text-[var(--status-error)]'
)} )}
aria-label='Stop generating' aria-label="Stop generating"
> >
<RiCloseCircleLine className={cn(iconSizeClass)} /> <StopIcon className={cn(iconSizeClass)} />
</button> </button>
) : ( ) : null;
const sendButton = (
<button <button
type={isMobile ? 'button' : 'submit'} type={isMobile ? 'button' : 'submit'}
disabled={!canSend || (!currentSessionId && !newSessionDraftOpen)} disabled={!canSend || (!currentSessionId && !newSessionDraftOpen)}
@@ -1319,9 +1312,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}; };
}, []); }, []);
// For mobile only, show abort in StatusRow; desktop and vscode show in footer (Esc-triggered)
const showAbortInStatusRow = isMobile && canAbort;
return ( return (
<form <form
@@ -1344,8 +1334,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
abortActive={working.abortActive} abortActive={working.abortActive}
completionId={working.lastCompletionId} completionId={working.lastCompletionId}
isComplete={working.isComplete} isComplete={working.isComplete}
showAbort={showAbortInStatusRow}
onAbort={handleAbort}
showAbortStatus={showAbortStatus} showAbortStatus={showAbortStatus}
/> />
</div> </div>
@@ -1462,7 +1450,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
className={cn( className={cn(
'bg-transparent', 'bg-transparent',
footerPaddingClass, footerPaddingClass,
isMobile ? 'flex items-center gap-x-1.5' : cn('flex items-center justify-between', footerGapClass) cn('flex items-center justify-between', footerGapClass)
)} )}
style={{ style={{
borderBottomLeftRadius: cornerRadius, borderBottomLeftRadius: cornerRadius,
@@ -1470,29 +1458,18 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}} }}
data-chat-input-footer="true" data-chat-input-footer="true"
> >
{isMobile ? ( <div className={cn('flex items-center min-w-0 flex-1', footerGapClass)}>
<div className="flex w-full items-center gap-x-1.5"> <div className={cn('flex items-center flex-shrink-0', footerGapClass)}>
<div className="flex items-center flex-shrink-0 gap-x-1"> {attachmentsControls}
{attachmentsControls}
</div>
<div className="flex flex-1 items-center justify-end gap-x-1 min-w-0">
<div className="flex flex-1 min-w-0 justify-end overflow-hidden">
<ModelControls className={cn('w-full flex items-center justify-end min-w-0')} />
</div>
{actionButton}
</div>
</div> </div>
) : ( <div className="flex min-w-0 flex-1 overflow-hidden">
<> <ModelControls className={cn('w-full min-w-0')} />
<div className={cn("flex items-center flex-shrink-0", footerGapClass)}> </div>
{attachmentsControls} </div>
</div> <div className={cn('flex items-center flex-shrink-0', footerGapClass)}>
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}> {cancelButton}
<ModelControls className={cn('flex-1 min-w-0 justify-end')} /> {sendButton}
{actionButton} </div>
</div>
</>
)}
</div> </div>
</div> </div>
@@ -37,6 +37,7 @@ import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useProviderLogo } from '@/hooks/useProviderLogo';
import { useIsDesktopRuntime, useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs'; import { useIsDesktopRuntime, useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { getAgentColor } from '@/lib/agentColors'; import { getAgentColor } from '@/lib/agentColors';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
@@ -138,6 +139,27 @@ type ModalityIcon = {
type ModelApplyResult = 'applied' | 'provider-missing' | 'model-missing'; type ModelApplyResult = 'applied' | 'provider-missing' | 'model-missing';
const ProviderLogoOrFallback: React.FC<{
providerId: string;
className?: string;
fallbackClassName?: string;
}> = ({ providerId, className, fallbackClassName }) => {
const { src, onError, hasLogo } = useProviderLogo(providerId);
if (hasLogo && src) {
return (
<img
src={src}
alt={`${providerId} logo`}
className={cn('dark:invert', className)}
onError={onError}
/>
);
}
return <RiPencilAiLine className={cn(className, fallbackClassName)} aria-hidden="true" />;
};
const MODALITY_ICON_MAP: Record<string, ModalityIconDefinition> = { const MODALITY_ICON_MAP: Record<string, ModalityIconDefinition> = {
text: { icon: RiText, label: 'Text' }, text: { icon: RiText, label: 'Text' },
image: { icon: RiFileImageLine, label: 'Image' }, image: { icon: RiFileImageLine, label: 'Image' },
@@ -415,7 +437,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
const editToggleIconClass = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4'; const editToggleIconClass = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4';
const controlIconSize = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4'; const controlIconSize = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4';
const controlTextSize = isCompact ? 'typography-micro' : 'typography-meta'; const controlTextSize = isCompact ? 'typography-micro' : 'typography-meta';
const inlineGapClass = sizeVariant === 'mobile' ? 'gap-x-1' : sizeVariant === 'vscode' ? 'gap-x-1' : 'gap-x-3'; const inlineGapClass = sizeVariant === 'mobile' ? 'gap-x-0.5' : sizeVariant === 'vscode' ? 'gap-x-1' : 'gap-x-2';
const renderEditModeIcon = React.useCallback((mode: EditPermissionMode, iconClass = editToggleIconClass) => { const renderEditModeIcon = React.useCallback((mode: EditPermissionMode, iconClass = editToggleIconClass) => {
const combinedClassName = cn(iconClass, 'flex-shrink-0'); const combinedClassName = cn(iconClass, 'flex-shrink-0');
const modeColors = getEditModeColors(mode); const modeColors = getEditModeColors(mode);
@@ -1973,11 +1995,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
> >
{currentProviderId ? ( {currentProviderId ? (
<> <>
<ProviderLogo <ProviderLogoOrFallback
providerId={currentProviderId} providerId={currentProviderId}
className={cn(controlIconSize, 'flex-shrink-0')} className={cn(controlIconSize, 'flex-shrink-0')}
fallbackClassName="text-muted-foreground"
/> />
<RiPencilAiLine className={cn(controlIconSize, 'text-primary/60 hidden')} />
</> </>
) : ( ) : (
<RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} /> <RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} />
@@ -2093,15 +2115,17 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
onTouchEnd={handleLongPressEnd} onTouchEnd={handleLongPressEnd}
onTouchCancel={handleLongPressEnd} onTouchCancel={handleLongPressEnd}
className={cn( className={cn(
'model-controls__model-trigger flex items-center gap-1.5 min-w-0 focus:outline-none', 'model-controls__model-trigger flex items-center gap-1 min-w-0 focus:outline-none',
'cursor-pointer hover:opacity-70', 'cursor-pointer hover:opacity-70',
buttonHeight buttonHeight,
'px-1'
)} )}
> >
{currentProviderId ? ( {currentProviderId ? (
<ProviderLogo <ProviderLogoOrFallback
providerId={currentProviderId} providerId={currentProviderId}
className={cn(controlIconSize, 'flex-shrink-0')} className={cn(controlIconSize, 'flex-shrink-0')}
fallbackClassName="text-muted-foreground"
/> />
) : ( ) : (
<RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} /> <RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} />
@@ -2109,13 +2133,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
<span <span
ref={modelLabelRef} ref={modelLabelRef}
className={cn( className={cn(
'model-controls__model-label typography-micro font-medium overflow-hidden min-w-0', 'model-controls__model-label typography-micro font-medium min-w-0 truncate',
isMobile ? 'max-w-[120px]' : 'max-w-[220px]', isMobile ? 'max-w-[120px]' : 'max-w-[220px]',
)} )}
> >
<span className={cn('marquee-text', isModelLabelTruncated && 'marquee-text--active')}> {currentModelDisplayName}
{currentModelDisplayName}
</span>
</span> </span>
</button> </button>
)} )}
@@ -2261,21 +2283,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
type="button" type="button"
onClick={() => setActiveMobilePanel('variant')} onClick={() => setActiveMobilePanel('variant')}
className={cn( className={cn(
'model-controls__variant-trigger flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none', 'model-controls__variant-trigger flex items-center justify-center transition-opacity focus:outline-none',
buttonHeight, buttonHeight,
'w-9',
'cursor-pointer hover:opacity-70', 'cursor-pointer hover:opacity-70',
)} )}
aria-label={`Thinking: ${displayVariant}`}
title={`Thinking: ${displayVariant}`}
> >
<RiBrainAi3Line className={cn(controlIconSize, 'flex-shrink-0', colorClass)} /> <RiBrainAi3Line className={cn(controlIconSize, 'flex-shrink-0', colorClass)} />
<span className={cn(
'model-controls__variant-label',
controlTextSize,
'font-medium truncate min-w-0',
isMobile && 'max-w-[60px]',
colorClass
)}>
{displayVariant}
</span>
</button> </button>
); );
} }
@@ -2430,11 +2446,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
onTouchStart={() => handleLongPressStart('agent')} onTouchStart={() => handleLongPressStart('agent')}
onTouchEnd={handleLongPressEnd} onTouchEnd={handleLongPressEnd}
onTouchCancel={handleLongPressEnd} onTouchCancel={handleLongPressEnd}
className={cn( className={cn(
'model-controls__agent-trigger flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none', 'model-controls__agent-trigger flex items-center gap-1 transition-opacity min-w-0 focus:outline-none',
buttonHeight, buttonHeight,
'cursor-pointer hover:opacity-70', 'cursor-pointer hover:opacity-70',
)} 'px-1',
)}
> >
<RiAiAgentLine <RiAiAgentLine
className={cn( className={cn(
@@ -2460,9 +2477,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
}; };
const inlineClassName = cn( const inlineClassName = cn(
'@container/model-controls flex items-center min-w-0', (isMobile ? '@container' : '@container/model-controls'),
'flex items-center min-w-0',
// Only force full-width + truncation behaviors on true mobile layouts. // Only force full-width + truncation behaviors on true mobile layouts.
// VS Code also uses "compact" mode, but should keep its right-aligned inline sizing. // VS Code uses desktop dropdowns.
isMobile && 'w-full', isMobile && 'w-full',
className, className,
); );
@@ -2472,14 +2490,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
<div className={inlineClassName}> <div className={inlineClassName}>
<div <div
className={cn( className={cn(
'flex items-center min-w-0 flex-1 justify-end', 'flex items-center min-w-0 flex-1 justify-start',
inlineGapClass, inlineGapClass,
isMobile && 'overflow-hidden' isMobile && 'overflow-hidden'
)} )}
> >
{renderVariantSelector()}
{renderModelSelector()}
{renderAgentSelector()} {renderAgentSelector()}
{renderModelSelector()}
{renderVariantSelector()}
</div> </div>
</div> </div>
+1 -19
View File
@@ -55,9 +55,6 @@ interface StatusRowProps {
abortActive?: boolean; abortActive?: boolean;
completionId?: string | null; completionId?: string | null;
isComplete?: boolean; isComplete?: boolean;
// Abort state (for mobile/vscode)
showAbort?: boolean;
onAbort?: () => void;
// Abort status display // Abort status display
showAbortStatus?: boolean; showAbortStatus?: boolean;
} }
@@ -71,8 +68,6 @@ export const StatusRow: React.FC<StatusRowProps> = ({
abortActive, abortActive,
completionId, completionId,
isComplete, isComplete,
showAbort,
onAbort,
showAbortStatus, showAbortStatus,
}) => { }) => {
const [isExpanded, setIsExpanded] = React.useState(false); const [isExpanded, setIsExpanded] = React.useState(false);
@@ -158,18 +153,6 @@ export const StatusRow: React.FC<StatusRowProps> = ({
const toggleExpanded = () => setIsExpanded((prev) => !prev); const toggleExpanded = () => setIsExpanded((prev) => !prev);
// Abort button for mobile/vscode
const abortButton = showAbort && onAbort ? (
<button
type="button"
onClick={onAbort}
className="flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none flex-shrink-0"
aria-label="Stop generating"
>
<RiCloseCircleLine size={18} aria-hidden="true" />
</button>
) : null;
// Todo trigger button // Todo trigger button
const todoTrigger = hasActiveTodos ? ( const todoTrigger = hasActiveTodos ? (
<button <button
@@ -223,9 +206,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
) : null} ) : null}
</div> </div>
{/* Right: Abort (mobile only) + Todo */} {/* Right: Todo */}
<div className="relative flex items-center gap-2 flex-shrink-0" ref={popoverRef}> <div className="relative flex items-center gap-2 flex-shrink-0" ref={popoverRef}>
{abortButton}
{todoTrigger} {todoTrigger}
{/* Popover dropdown */} {/* Popover dropdown */}
@@ -0,0 +1,20 @@
import type { SVGProps } from 'react';
export function StopIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
fill="currentColor"
viewBox="0 0 256 256"
{...props}
>
<path
d="M208,56V200a8,8,0,0,1-8,8H56a8,8,0,0,1-8-8V56a8,8,0,0,1,8-8H200A8,8,0,0,1,208,56Z"
opacity="0.2"
/>
<path d="M200,40H56A16,16,0,0,0,40,56V200a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40Zm0,160H56V56H200V200Z" />
</svg>
);
}
+160 -22
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useEffect } from 'react'; import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -6,6 +6,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { RiCheckLine, RiClipboardLine, RiDownloadCloudLine, RiDownloadLine, RiExternalLinkLine, RiLoaderLine, RiRestartLine, RiTerminalLine } from '@remixicon/react'; import { RiCheckLine, RiClipboardLine, RiDownloadCloudLine, RiDownloadLine, RiExternalLinkLine, RiLoaderLine, RiRestartLine, RiTerminalLine } from '@remixicon/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop'; import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
@@ -28,6 +29,80 @@ interface UpdateDialogProps {
const GITHUB_RELEASES_URL = 'https://github.com/btriapitsyn/openchamber/releases'; const GITHUB_RELEASES_URL = 'https://github.com/btriapitsyn/openchamber/releases';
type ChangelogSection = {
version: string;
date: string;
start: number;
end: number;
raw: string;
};
type ParsedChangelog =
| {
kind: 'raw';
title: string;
content: string;
}
| {
kind: 'sections';
title: string;
sections: Array<{ version: string; dateLabel: string; content: string }>;
};
function formatIsoDateForUI(isoDate: string): string {
const d = new Date(`${isoDate}T00:00:00`);
if (Number.isNaN(d.getTime())) {
return isoDate;
}
return new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(d);
}
function stripChangelogHeading(sectionRaw: string): string {
return sectionRaw.replace(/^## \[[^\]]+\] - \d{4}-\d{2}-\d{2}\s*\n?/, '').trim();
}
function compareSemverDesc(a: string, b: string): number {
const pa = a.split('.').map((v) => Number.parseInt(v, 10));
const pb = b.split('.').map((v) => Number.parseInt(v, 10));
for (let i = 0; i < 3; i += 1) {
const da = Number.isFinite(pa[i]) ? (pa[i] as number) : 0;
const db = Number.isFinite(pb[i]) ? (pb[i] as number) : 0;
if (da !== db) {
return db - da;
}
}
return 0;
}
function parseChangelogSections(body: string): ChangelogSection[] {
const re = /^## \[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})\s*$/gm;
const matches: Array<{ version: string; date: string; start: number }> = [];
let m: RegExpExecArray | null;
while ((m = re.exec(body)) !== null) {
matches.push({
version: m[1] ?? '',
date: m[2] ?? '',
start: m.index,
});
}
if (matches.length === 0) {
return [];
}
return matches.map((match, idx) => {
const end = matches[idx + 1]?.start ?? body.length;
const raw = body.slice(match.start, end).trim();
return { version: match.version, date: match.date, start: match.start, end, raw };
});
}
async function installWebUpdate(): Promise<{ success: boolean; error?: string }> { async function installWebUpdate(): Promise<{ success: boolean; error?: string }> {
try { try {
const response = await fetch('/api/openchamber/update-install', { const response = await fetch('/api/openchamber/update-install', {
@@ -139,6 +214,38 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
const isWebUpdating = webUpdateState !== 'idle' && webUpdateState !== 'error'; const isWebUpdating = webUpdateState !== 'idle' && webUpdateState !== 'error';
const changelog = useMemo<ParsedChangelog | null>(() => {
if (!info?.body) {
return null;
}
const body = info.body.trim();
if (!body) {
return null;
}
const sections = parseChangelogSections(body);
if (sections.length === 0) {
return {
kind: 'raw',
title: "What's new",
content: body,
};
}
const sorted = [...sections].sort((a, b) => compareSemverDesc(a.version, b.version));
return {
kind: 'sections',
title: "What's new",
sections: sorted.map((section) => ({
version: section.version,
dateLabel: formatIsoDateForUI(section.date),
content: stripChangelogHeading(section.raw) || body,
})),
};
}, [info?.body]);
return ( return (
<Dialog open={open} onOpenChange={isWebUpdating ? undefined : onOpenChange}> <Dialog open={open} onOpenChange={isWebUpdating ? undefined : onOpenChange}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
@@ -183,28 +290,59 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
</div> </div>
)} )}
{info?.body && !isWebUpdating && ( {changelog && !isWebUpdating && (
<ScrollableOverlay <div className="space-y-2">
className="max-h-48 rounded-md border border-border bg-muted/30 p-3" <div className="flex items-center justify-between gap-3">
fillContainer={false} <div className="typography-ui-label font-medium text-foreground/90">
> {changelog.title}
<div className="text-sm text-muted-foreground whitespace-pre-wrap pr-3"> </div>
{info.body
.split(/^## \[(\d+\.\d+\.\d+)\] - \d{4}-\d{2}-\d{2}\s*/gm)
.filter(Boolean)
.map((part, index) => {
if (/^\d+\.\d+\.\d+$/.test(part.trim())) {
return (
<span key={index} className="font-semibold text-foreground">
v{part.trim()}
{'\n'}
</span>
);
}
return part.replace(/^- /gm, '• ').trim() + '\n\n';
})}
</div> </div>
</ScrollableOverlay>
<ScrollableOverlay
className={cn(
'max-h-56 rounded-md border border-border/70',
'bg-background/40 p-3'
)}
fillContainer={false}
>
{changelog.kind === 'raw' ? (
<SimpleMarkdownRenderer
content={changelog.content}
className="typography-markdown-body text-foreground/90 leading-relaxed pr-3"
/>
) : (
<div className="space-y-4 pr-3">
{changelog.sections.map((section, idx) => (
<div
key={section.version}
className={cn(
idx > 0 && 'border-t border-border/40 pt-3'
)}
>
<div className="flex items-center gap-2 mb-2">
<span
className={cn(
'typography-ui-badge font-mono',
'bg-primary/10 text-primary',
'px-2 py-0.5 rounded-md'
)}
>
v{section.version}
</span>
<span className="typography-micro text-muted-foreground">
{section.dateLabel}
</span>
</div>
<SimpleMarkdownRenderer
content={section.content}
className="typography-markdown-body text-foreground/90 leading-relaxed"
/>
</div>
))}
</div>
)}
</ScrollableOverlay>
</div>
)} )}
{/* Web runtime: show CLI command only on error as fallback */} {/* Web runtime: show CLI command only on error as fallback */}