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]]
name = "openchamber-desktop"
version = "1.5.9"
version = "1.6.0"
dependencies = [
"anyhow",
"axum",
+21 -44
View File
@@ -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>
+1 -19
View File
@@ -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>
);
}
+160 -22
View File
@@ -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 */}