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:
committed by
GitHub
parent
0bd4dc12e4
commit
c5158ff52c
Generated
+1
-1
@@ -2994,7 +2994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openchamber-desktop"
|
||||
version = "1.5.9"
|
||||
version = "1.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
RiAddCircleLine,
|
||||
RiAiAgentLine,
|
||||
RiAttachment2,
|
||||
RiCloseCircleLine,
|
||||
RiFileUploadLine,
|
||||
RiSendPlane2Line,
|
||||
} from '@remixicon/react';
|
||||
@@ -31,6 +30,7 @@ import { toast } from '@/components/ui';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
import { StopIcon } from '@/components/icons/StopIcon';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -121,7 +121,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const abortCurrentOperation = useSessionStore((state) => state.abortCurrentOperation);
|
||||
const acknowledgeSessionAbort = useSessionStore((state) => state.acknowledgeSessionAbort);
|
||||
const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId);
|
||||
const abortPromptExpiresAt = useSessionStore((state) => state.abortPromptExpiresAt);
|
||||
const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt);
|
||||
const sessionAbortFlags = useSessionStore((state) => state.sessionAbortFlags);
|
||||
const attachedFiles = useSessionStore((state) => state.attachedFiles);
|
||||
@@ -297,11 +296,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
|
||||
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
|
||||
const handleQueueMessage = React.useCallback(() => {
|
||||
if (!hasContent || !currentSessionId) return;
|
||||
@@ -1138,7 +1132,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
event.target.value = '';
|
||||
}, [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 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');
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
// Desktop and VSCode: show abort button in footer when Esc triggered
|
||||
const showAbortInFooter = !isMobile && isAbortPromptActive && canAbort;
|
||||
|
||||
const actionButton = showAbortInFooter ? (
|
||||
const cancelButton = canAbort ? (
|
||||
<button
|
||||
type='button'
|
||||
type="button"
|
||||
onClick={handleAbort}
|
||||
className={cn(
|
||||
iconButtonBaseClass,
|
||||
'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>
|
||||
) : (
|
||||
) : null;
|
||||
|
||||
const sendButton = (
|
||||
<button
|
||||
type={isMobile ? 'button' : 'submit'}
|
||||
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 (
|
||||
|
||||
<form
|
||||
@@ -1344,8 +1334,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
abortActive={working.abortActive}
|
||||
completionId={working.lastCompletionId}
|
||||
isComplete={working.isComplete}
|
||||
showAbort={showAbortInStatusRow}
|
||||
onAbort={handleAbort}
|
||||
showAbortStatus={showAbortStatus}
|
||||
/>
|
||||
</div>
|
||||
@@ -1462,7 +1450,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
className={cn(
|
||||
'bg-transparent',
|
||||
footerPaddingClass,
|
||||
isMobile ? 'flex items-center gap-x-1.5' : cn('flex items-center justify-between', footerGapClass)
|
||||
cn('flex items-center justify-between', footerGapClass)
|
||||
)}
|
||||
style={{
|
||||
borderBottomLeftRadius: cornerRadius,
|
||||
@@ -1470,29 +1458,18 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}}
|
||||
data-chat-input-footer="true"
|
||||
>
|
||||
{isMobile ? (
|
||||
<div className="flex w-full items-center gap-x-1.5">
|
||||
<div className="flex items-center flex-shrink-0 gap-x-1">
|
||||
{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 className={cn('flex items-center min-w-0 flex-1', footerGapClass)}>
|
||||
<div className={cn('flex items-center flex-shrink-0', footerGapClass)}>
|
||||
{attachmentsControls}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={cn("flex items-center flex-shrink-0", footerGapClass)}>
|
||||
{attachmentsControls}
|
||||
</div>
|
||||
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
|
||||
<ModelControls className={cn('flex-1 min-w-0 justify-end')} />
|
||||
{actionButton}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 overflow-hidden">
|
||||
<ModelControls className={cn('w-full min-w-0')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('flex items-center flex-shrink-0', footerGapClass)}>
|
||||
{cancelButton}
|
||||
{sendButton}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useProviderLogo } from '@/hooks/useProviderLogo';
|
||||
import { useIsDesktopRuntime, useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
|
||||
import { getAgentColor } from '@/lib/agentColors';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
@@ -138,6 +139,27 @@ type ModalityIcon = {
|
||||
|
||||
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> = {
|
||||
text: { icon: RiText, label: 'Text' },
|
||||
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 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 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 combinedClassName = cn(iconClass, 'flex-shrink-0');
|
||||
const modeColors = getEditModeColors(mode);
|
||||
@@ -1973,11 +1995,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
>
|
||||
{currentProviderId ? (
|
||||
<>
|
||||
<ProviderLogo
|
||||
<ProviderLogoOrFallback
|
||||
providerId={currentProviderId}
|
||||
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')} />
|
||||
@@ -2093,15 +2115,17 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
onTouchEnd={handleLongPressEnd}
|
||||
onTouchCancel={handleLongPressEnd}
|
||||
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',
|
||||
buttonHeight
|
||||
buttonHeight,
|
||||
'px-1'
|
||||
)}
|
||||
>
|
||||
{currentProviderId ? (
|
||||
<ProviderLogo
|
||||
<ProviderLogoOrFallback
|
||||
providerId={currentProviderId}
|
||||
className={cn(controlIconSize, 'flex-shrink-0')}
|
||||
fallbackClassName="text-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} />
|
||||
@@ -2109,13 +2133,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
<span
|
||||
ref={modelLabelRef}
|
||||
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]',
|
||||
)}
|
||||
>
|
||||
<span className={cn('marquee-text', isModelLabelTruncated && 'marquee-text--active')}>
|
||||
{currentModelDisplayName}
|
||||
</span>
|
||||
{currentModelDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -2261,21 +2283,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
type="button"
|
||||
onClick={() => setActiveMobilePanel('variant')}
|
||||
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,
|
||||
'w-9',
|
||||
'cursor-pointer hover:opacity-70',
|
||||
)}
|
||||
aria-label={`Thinking: ${displayVariant}`}
|
||||
title={`Thinking: ${displayVariant}`}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -2430,11 +2446,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
onTouchStart={() => handleLongPressStart('agent')}
|
||||
onTouchEnd={handleLongPressEnd}
|
||||
onTouchCancel={handleLongPressEnd}
|
||||
className={cn(
|
||||
'model-controls__agent-trigger flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none',
|
||||
buttonHeight,
|
||||
'cursor-pointer hover:opacity-70',
|
||||
)}
|
||||
className={cn(
|
||||
'model-controls__agent-trigger flex items-center gap-1 transition-opacity min-w-0 focus:outline-none',
|
||||
buttonHeight,
|
||||
'cursor-pointer hover:opacity-70',
|
||||
'px-1',
|
||||
)}
|
||||
>
|
||||
<RiAiAgentLine
|
||||
className={cn(
|
||||
@@ -2460,9 +2477,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
};
|
||||
|
||||
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.
|
||||
// VS Code also uses "compact" mode, but should keep its right-aligned inline sizing.
|
||||
// VS Code uses desktop dropdowns.
|
||||
isMobile && 'w-full',
|
||||
className,
|
||||
);
|
||||
@@ -2472,14 +2490,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
<div className={inlineClassName}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center min-w-0 flex-1 justify-end',
|
||||
'flex items-center min-w-0 flex-1 justify-start',
|
||||
inlineGapClass,
|
||||
isMobile && 'overflow-hidden'
|
||||
)}
|
||||
>
|
||||
{renderVariantSelector()}
|
||||
{renderModelSelector()}
|
||||
{renderAgentSelector()}
|
||||
{renderModelSelector()}
|
||||
{renderVariantSelector()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -55,9 +55,6 @@ interface StatusRowProps {
|
||||
abortActive?: boolean;
|
||||
completionId?: string | null;
|
||||
isComplete?: boolean;
|
||||
// Abort state (for mobile/vscode)
|
||||
showAbort?: boolean;
|
||||
onAbort?: () => void;
|
||||
// Abort status display
|
||||
showAbortStatus?: boolean;
|
||||
}
|
||||
@@ -71,8 +68,6 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
abortActive,
|
||||
completionId,
|
||||
isComplete,
|
||||
showAbort,
|
||||
onAbort,
|
||||
showAbortStatus,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
@@ -158,18 +153,6 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
|
||||
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
|
||||
const todoTrigger = hasActiveTodos ? (
|
||||
<button
|
||||
@@ -223,9 +206,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Right: Abort (mobile only) + Todo */}
|
||||
{/* Right: Todo */}
|
||||
<div className="relative flex items-center gap-2 flex-shrink-0" ref={popoverRef}>
|
||||
{abortButton}
|
||||
{todoTrigger}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
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 { cn } from '@/lib/utils';
|
||||
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
|
||||
@@ -28,6 +29,80 @@ interface UpdateDialogProps {
|
||||
|
||||
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 }> {
|
||||
try {
|
||||
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 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 (
|
||||
<Dialog open={open} onOpenChange={isWebUpdating ? undefined : onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
@@ -183,28 +290,59 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info?.body && !isWebUpdating && (
|
||||
<ScrollableOverlay
|
||||
className="max-h-48 rounded-md border border-border bg-muted/30 p-3"
|
||||
fillContainer={false}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground whitespace-pre-wrap pr-3">
|
||||
{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';
|
||||
})}
|
||||
{changelog && !isWebUpdating && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="typography-ui-label font-medium text-foreground/90">
|
||||
{changelog.title}
|
||||
</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 */}
|
||||
|
||||
Reference in New Issue
Block a user