Merge branch 'btriapitsyn:main' into main
This commit is contained in:
Generated
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user