chore: remove dead code (59 unused files + ~125 unused exports) (#1835)
* chore: remove dead/unreferenced files across ui, vscode Remove 59 unused source files (components, hooks, lib utils, stores, barrels, and orphaned vscode github modules) that are not imported by any entry-reachable code. Also drop a stale test mock for the removed execCommands module. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * refactor: remove unused exported symbols (types, functions, consts, hooks) Remove exported symbols whose identifier is referenced nowhere in the repository (verified via repo-wide search), across ui types/contracts, lib utilities, sync layer, stores, and components. Also drop the few imports/private helpers orphaned by these removals. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * refactor: remove more unused exports (desktop, shortcuts, worktree, vscode) Continue removing repo-wide unreferenced exported functions, consts and types across lib/desktop, shortcuts, worktreeSessionCreator, sync, and vscode gitService, with cascading orphaned helpers/imports cleaned up. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * chore: add dead-code cleanup tooling * refactor: checkpoint dead-code cleanup * refactor: remove dead-code suppressions --------- Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Serhii Dziupin
Bohdan Triapitsyn
parent
4a37b9a005
commit
00821700de
@@ -1,256 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn, fuzzyMatch } from '@/lib/utils';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useAgentsStore, isAgentBuiltIn, type AgentWithExtras } from '@/stores/useAgentsStore';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface AgentInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
mode?: string | null;
|
||||
scope?: string;
|
||||
isBuiltIn?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentMentionAutocompleteHandle {
|
||||
handleKeyDown: (key: string) => void;
|
||||
}
|
||||
|
||||
type AutocompleteTab = 'commands' | 'agents' | 'files';
|
||||
|
||||
const isMentionableAgentMode = (mode?: string | null): boolean => {
|
||||
if (!mode) return false;
|
||||
return mode !== 'primary';
|
||||
};
|
||||
|
||||
interface AgentMentionAutocompleteProps {
|
||||
searchQuery: string;
|
||||
onAgentSelect: (agentName: string) => void;
|
||||
onClose: () => void;
|
||||
showTabs?: boolean;
|
||||
activeTab?: AutocompleteTab;
|
||||
onTabSelect?: (tab: AutocompleteTab) => void;
|
||||
}
|
||||
|
||||
export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocompleteHandle, AgentMentionAutocompleteProps>(({
|
||||
searchQuery,
|
||||
onAgentSelect,
|
||||
onClose,
|
||||
showTabs,
|
||||
activeTab = 'agents',
|
||||
onTabSelect,
|
||||
}, ref) => {
|
||||
const { t } = useI18n();
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const selectedIndexRef = React.useRef(0);
|
||||
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
|
||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||
const ignoreTabClickRef = React.useRef(false);
|
||||
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||
const configAgentsCount = useConfigStore((state) => state.agents.length);
|
||||
const agentsWithMetadata = useAgentsStore((state) => state.agents);
|
||||
const loadAgents = useAgentsStore((state) => state.loadAgents);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (agentsWithMetadata.length === 0 && configAgentsCount === 0) {
|
||||
void loadAgents();
|
||||
}
|
||||
}, [loadAgents, agentsWithMetadata.length, configAgentsCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const visibleAgents = getVisibleAgents();
|
||||
const filtered = visibleAgents
|
||||
.filter((agent) => isMentionableAgentMode(agent.mode))
|
||||
.map((agent) => {
|
||||
const metadata = agentsWithMetadata.find(a => a.name === agent.name) as (AgentWithExtras & { scope?: string }) | undefined;
|
||||
return {
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
mode: agent.mode ?? undefined,
|
||||
scope: metadata?.scope,
|
||||
isBuiltIn: metadata ? isAgentBuiltIn(metadata) : false,
|
||||
};
|
||||
});
|
||||
|
||||
const normalizedQuery = searchQuery.trim();
|
||||
const matches = normalizedQuery.length
|
||||
? filtered.filter((agent) => fuzzyMatch(agent.name, normalizedQuery))
|
||||
: filtered;
|
||||
|
||||
matches.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
setAgents(matches);
|
||||
setSelectedIndex(0);
|
||||
}, [getVisibleAgents, searchQuery, agentsWithMetadata]);
|
||||
|
||||
React.useEffect(() => {
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
}, [selectedIndex]);
|
||||
|
||||
React.useEffect(() => {
|
||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
});
|
||||
}, [selectedIndex]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (!target || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!containerRef.current.contains(target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
handleKeyDown: (key: string) => {
|
||||
if (key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agents.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'ArrowDown') {
|
||||
setSelectedIndex((prev) => (prev + 1) % agents.length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'ArrowUp') {
|
||||
setSelectedIndex((prev) => (prev - 1 + agents.length) % agents.length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'Enter' || key === 'Tab') {
|
||||
const safeIndex = ((selectedIndexRef.current % agents.length) + agents.length) % agents.length;
|
||||
const agent = agents[safeIndex];
|
||||
if (agent) {
|
||||
onAgentSelect(agent.name);
|
||||
}
|
||||
}
|
||||
},
|
||||
}), [agents, onAgentSelect, onClose]);
|
||||
|
||||
const renderAgent = (agent: AgentInfo, index: number) => {
|
||||
const isSystem = agent.isBuiltIn;
|
||||
const isProject = agent.scope === 'project';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={agent.name}
|
||||
ref={(el) => {
|
||||
itemRefs.current[index] = el;
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
|
||||
index === selectedIndex && 'bg-interactive-selection'
|
||||
)}
|
||||
onClick={() => onAgentSelect(agent.name)}
|
||||
onMouseMove={() => setSelectedIndex(index)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">#{agent.name}</span>
|
||||
{isSystem ? (
|
||||
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-warning-background)] text-[var(--status-warning)] border-[var(--status-warning-border)] px-1.5 py-1 rounded border flex-shrink-0">
|
||||
{t('chat.agentMentionAutocomplete.badge.system')}
|
||||
</span>
|
||||
) : agent.scope ? (
|
||||
<span className={cn(
|
||||
"text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0",
|
||||
isProject
|
||||
? "bg-[var(--status-info-background)] text-[var(--status-info)] border-[var(--status-info-border)]"
|
||||
: "bg-[var(--status-success-background)] text-[var(--status-success)] border-[var(--status-success-border)]"
|
||||
)}>
|
||||
{agent.scope}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{agent.description && (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
||||
{agent.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const tabs = React.useMemo(() => ([
|
||||
{ id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') },
|
||||
{ id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') },
|
||||
{ id: 'files' as const, label: t('chat.autocomplete.tabs.files') },
|
||||
]), [t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] min-w-0 w-full max-w-[360px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
||||
>
|
||||
{showTabs ? (
|
||||
<div className="px-2 pt-2 pb-1 border-b border-border/60">
|
||||
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-1 px-2.5 py-1 rounded-md typography-meta font-semibold transition-none',
|
||||
activeTab === tab.id
|
||||
? 'bg-interactive-selection text-interactive-selection-foreground shadow-none'
|
||||
: 'text-muted-foreground hover:bg-interactive-hover/50'
|
||||
)}
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType !== 'touch') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ignoreTabClickRef.current = true;
|
||||
onTabSelect?.(tab.id);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (ignoreTabClickRef.current) {
|
||||
ignoreTabClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
onTabSelect?.(tab.id);
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2">
|
||||
{agents.length ? (
|
||||
<div>
|
||||
{agents.map((agent, index) => renderAgent(agent, index))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
||||
{t('chat.agentMentionAutocomplete.empty')}
|
||||
</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
||||
{t('chat.autocomplete.keyboardHint')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AgentMentionAutocomplete.displayName = 'AgentMentionAutocomplete';
|
||||
@@ -15,7 +15,7 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
|
||||
export const FileAttachmentButton = memo(() => {
|
||||
const FileAttachmentButton = memo(() => {
|
||||
const { t } = useI18n();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
|
||||
@@ -912,7 +912,7 @@ interface ImageGalleryProps {
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
}
|
||||
|
||||
export const ImageGallery = memo(({ urls, caption, onShowPopup }: ImageGalleryProps) => {
|
||||
const ImageGallery = memo(({ urls, caption, onShowPopup }: ImageGalleryProps) => {
|
||||
if (urls.length === 0) return null;
|
||||
|
||||
const getGridCols = () => {
|
||||
|
||||
@@ -6,9 +6,6 @@ import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
// DOM morphing, plus beautiful-mermaid) is loaded on demand, keeping the
|
||||
// initial bundle lean.
|
||||
|
||||
export type { MarkdownVariant } from './MarkdownRendererImpl';
|
||||
|
||||
|
||||
const MarkdownRendererLazy = lazyWithChunkRecovery(() =>
|
||||
import('./MarkdownRendererImpl').then((m) => ({ default: m.MarkdownRenderer }))
|
||||
);
|
||||
|
||||
@@ -92,5 +92,3 @@ export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onCycleAge
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileAgentButton;
|
||||
|
||||
@@ -36,5 +36,3 @@ export const MobileModelButton: React.FC<MobileModelButtonProps> = ({ onOpenMode
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileModelButton;
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { PermissionRequest as PermissionRequestPayload, PermissionResponse } from '@/types/permission';
|
||||
import * as sessionActions from '@/sync/session-actions';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
|
||||
interface PermissionRequestProps {
|
||||
permission: PermissionRequestPayload;
|
||||
onResponse?: (response: 'once' | 'always' | 'reject') => void;
|
||||
}
|
||||
|
||||
export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
||||
permission,
|
||||
onResponse
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isResponding, setIsResponding] = React.useState(false);
|
||||
const [hasResponded, setHasResponded] = React.useState(false);
|
||||
const respondToPermission = sessionActions.respondToPermission;
|
||||
|
||||
const handleResponse = async (response: PermissionResponse) => {
|
||||
setIsResponding(true);
|
||||
|
||||
try {
|
||||
await respondToPermission(permission.sessionID, permission.id, response);
|
||||
setHasResponded(true);
|
||||
onResponse?.(response);
|
||||
} catch (error) {
|
||||
console.error('[PermissionRequest] Failed to respond to permission:', error);
|
||||
} finally {
|
||||
setIsResponding(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (hasResponded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const command = typeof permission.metadata.command === 'string'
|
||||
? permission.metadata.command
|
||||
: (permission.patterns?.[0] ?? permission.permission);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="min-w-0">
|
||||
<span className="typography-ui-label font-medium text-muted-foreground">
|
||||
{t('chat.permissionRequest.required')}
|
||||
</span>
|
||||
<code className="ml-2 typography-meta bg-amber-100/50 dark:bg-amber-800/30 px-1.5 py-0.5 rounded font-mono text-amber-800 dark:text-amber-200">
|
||||
{command}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 ml-4">
|
||||
<button
|
||||
onClick={() => handleResponse('once')}
|
||||
disabled={isResponding}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded border h-6",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
borderColor: 'var(--status-success)',
|
||||
color: 'var(--status-success)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--status-success-background)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<Icon name="check" className="h-3 w-3" />
|
||||
{t('chat.permissionRequest.actions.once')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleResponse('always')}
|
||||
disabled={isResponding}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded border h-6",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
borderColor: 'var(--status-info)',
|
||||
color: 'var(--status-info)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--status-info-background)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<Icon name="time" className="h-3 w-3" />
|
||||
{t('chat.permissionRequest.actions.always')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleResponse('reject')}
|
||||
disabled={isResponding}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded border h-6",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
borderColor: 'var(--status-error)',
|
||||
color: 'var(--status-error)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--status-error-background)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<Icon name="close" className="h-3 w-3" />
|
||||
{t('chat.permissionRequest.actions.reject')}
|
||||
</button>
|
||||
|
||||
{isResponding && (
|
||||
<div className="ml-2 flex items-center">
|
||||
<div className="animate-spin h-3 w-3 border-2 border-t-transparent rounded-full" style={{ borderColor: 'var(--loading-spinner)' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,139 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface PermissionToastActionsProps {
|
||||
sessionTitle: string;
|
||||
permissionBody: string;
|
||||
disabled?: boolean;
|
||||
onOnce: () => Promise<void> | void;
|
||||
onAlways: () => Promise<void> | void;
|
||||
onDeny: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
const truncateToastText = (value: string, maxLength: number): string => {
|
||||
const normalized = value.trim();
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
|
||||
};
|
||||
|
||||
export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
|
||||
sessionTitle,
|
||||
permissionBody,
|
||||
disabled = false,
|
||||
onOnce,
|
||||
onAlways,
|
||||
onDeny,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isBusy, setIsBusy] = React.useState(false);
|
||||
const hasSessionTitle = sessionTitle.trim().length > 0;
|
||||
const sessionPreview = truncateToastText(sessionTitle, 64) || t('chat.permissionToast.sessionFallback');
|
||||
const permissionPreview = truncateToastText(permissionBody, 120) || t('chat.permissionToast.permissionFallback');
|
||||
|
||||
const handleAction = async (action: () => Promise<void> | void) => {
|
||||
if (isBusy || disabled) return;
|
||||
setIsBusy(true);
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1.5 min-w-0 space-y-0.5">
|
||||
<p className="typography-meta text-muted-foreground" title={sessionTitle}>
|
||||
{t('chat.permissionToast.labels.session')}{' '}
|
||||
<span className="inline-block max-w-[280px] align-bottom truncate text-foreground">
|
||||
{sessionPreview}
|
||||
</span>
|
||||
</p>
|
||||
<p className="typography-meta text-muted-foreground" title={permissionBody}>
|
||||
{t('chat.permissionToast.labels.permission')}{' '}
|
||||
<span className="inline-block max-w-[280px] align-bottom truncate">
|
||||
{permissionPreview}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => handleAction(onOnce)}
|
||||
disabled={disabled || isBusy}
|
||||
aria-label={hasSessionTitle
|
||||
? t('chat.permissionToast.actions.approveOnceAriaWithSession', { session: sessionTitle })
|
||||
: t('chat.permissionToast.actions.approveOnceAria')}
|
||||
className={cn(
|
||||
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: 'rgb(var(--status-success) / 0.1)',
|
||||
color: 'var(--status-success)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.1)';
|
||||
}}
|
||||
>
|
||||
{t('chat.permissionToast.actions.once')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAction(onAlways)}
|
||||
disabled={disabled || isBusy}
|
||||
aria-label={hasSessionTitle
|
||||
? t('chat.permissionToast.actions.approveAlwaysAriaWithSession', { session: sessionTitle })
|
||||
: t('chat.permissionToast.actions.approveAlwaysAria')}
|
||||
className={cn(
|
||||
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: 'rgb(var(--muted) / 0.5)',
|
||||
color: 'var(--muted-foreground)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.7)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.5)';
|
||||
}}
|
||||
>
|
||||
{t('chat.permissionToast.actions.always')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAction(onDeny)}
|
||||
disabled={disabled || isBusy}
|
||||
aria-label={hasSessionTitle
|
||||
? t('chat.permissionToast.actions.denyAriaWithSession', { session: sessionTitle })
|
||||
: t('chat.permissionToast.actions.denyAria')}
|
||||
className={cn(
|
||||
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: 'rgb(var(--status-error) / 0.1)',
|
||||
color: 'var(--status-error)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.1)';
|
||||
}}
|
||||
>
|
||||
{t('chat.permissionToast.actions.deny')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useContextStore } from '@/stores/contextStore';
|
||||
import { formatEffortLabel, getAgentDisplayName, getModelDisplayName } from './mobileControlsUtils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const STATUS_CHIP_STYLE = {
|
||||
height: '28px',
|
||||
maxHeight: '28px',
|
||||
minHeight: '28px',
|
||||
};
|
||||
|
||||
interface StatusChipProps {
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const StatusChip: React.FC<StatusChipProps> = ({ onClick, className }) => {
|
||||
const { t } = useI18n();
|
||||
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||
const currentVariant = useConfigStore((state) => state.currentVariant);
|
||||
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
|
||||
const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants);
|
||||
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const sessionAgentName = useContextStore((state) =>
|
||||
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
|
||||
);
|
||||
|
||||
const agents = getVisibleAgents();
|
||||
const uiAgentName = currentSessionId ? (sessionAgentName || currentAgentName) : currentAgentName;
|
||||
const agentLabel = getAgentDisplayName(agents, uiAgentName);
|
||||
const currentProvider = getCurrentProvider();
|
||||
const modelLabel = getModelDisplayName(currentProvider, currentModelId, t('chat.modelControls.selectModel'));
|
||||
const hasEffort = getCurrentModelVariants().length > 0;
|
||||
const effortLabel = hasEffort ? formatEffortLabel(currentVariant) : null;
|
||||
const fullLabel = [agentLabel, modelLabel, effortLabel].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex min-w-0 items-center justify-center',
|
||||
'rounded-md border border-border/50 px-1.5',
|
||||
'text-[11px] font-medium text-foreground/80',
|
||||
'focus:outline-none hover:bg-[var(--interactive-hover)]',
|
||||
className
|
||||
)}
|
||||
style={STATUS_CHIP_STYLE}
|
||||
title={fullLabel}
|
||||
>
|
||||
<span className="shrink-0">{agentLabel}</span>
|
||||
<span className="shrink-0 text-muted-foreground mx-0.5">·</span>
|
||||
<span className="min-w-0 truncate">{modelLabel}</span>
|
||||
{effortLabel && (
|
||||
<>
|
||||
<span className="shrink-0 text-muted-foreground mx-0.5">·</span>
|
||||
<span className="shrink-0">{effortLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusChip;
|
||||
@@ -1,27 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface TurnListEntry {
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface TurnListProps<TEntry extends TurnListEntry> {
|
||||
entries: TEntry[];
|
||||
renderEntry: (entry: TEntry) => React.ReactNode;
|
||||
}
|
||||
|
||||
const TurnList = <TEntry extends TurnListEntry>({ entries, renderEntry }: TurnListProps<TEntry>): React.ReactElement => {
|
||||
return (
|
||||
<>
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
data-turn-entry={entry.key}
|
||||
>
|
||||
{renderEntry(entry)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TurnList) as typeof TurnList;
|
||||
@@ -17,7 +17,7 @@
|
||||
* with ordinary prose (`2 * 3`, `foo_bar`).
|
||||
*/
|
||||
|
||||
export type HighlightStyle =
|
||||
type HighlightStyle =
|
||||
| 'marker'
|
||||
| 'code'
|
||||
| 'codeFence'
|
||||
@@ -27,7 +27,7 @@ export type HighlightStyle =
|
||||
| 'blockquote'
|
||||
| 'listMarker';
|
||||
|
||||
export type MentionKind = 'file' | 'agent';
|
||||
type MentionKind = 'file' | 'agent';
|
||||
|
||||
export interface HighlightRange {
|
||||
start: number;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
|
||||
export type ChatHashTarget =
|
||||
type ChatHashTarget =
|
||||
| { kind: 'turn'; id: string }
|
||||
| { kind: 'message'; id: string };
|
||||
|
||||
export const parseChatHashTarget = (hashValue: string): ChatHashTarget | null => {
|
||||
const parseChatHashTarget = (hashValue: string): ChatHashTarget | null => {
|
||||
const value = hashValue.startsWith('#') ? hashValue.slice(1) : hashValue;
|
||||
if (!value) {
|
||||
return null;
|
||||
@@ -28,7 +28,7 @@ type TurnOffsetTarget =
|
||||
| { kind: 'resume' }
|
||||
| { kind: 'turn'; turnId: string };
|
||||
|
||||
export const resolveTurnOffsetTarget = (
|
||||
const resolveTurnOffsetTarget = (
|
||||
turnIds: string[],
|
||||
activeTurnId: string | null,
|
||||
offset: number,
|
||||
|
||||
@@ -9,7 +9,7 @@ interface UseStreamingTextThrottleInput {
|
||||
|
||||
const DEFAULT_STREAMING_TEXT_THROTTLE_MS = 100;
|
||||
|
||||
export const computeStreamingThrottleDelay = (lastEmitAt: number, now: number, throttleMs: number): number => {
|
||||
const computeStreamingThrottleDelay = (lastEmitAt: number, now: number, throttleMs: number): number => {
|
||||
const elapsed = now - lastEmitAt;
|
||||
return Math.max(0, throttleMs - elapsed);
|
||||
};
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import React from 'react';
|
||||
import type { TurnProjectionResult } from '../lib/turns/types';
|
||||
|
||||
export interface TurnLookupResult {
|
||||
turnById: TurnProjectionResult['indexes']['turnById'];
|
||||
messageToTurnId: TurnProjectionResult['indexes']['messageToTurnId'];
|
||||
messageMetaById: TurnProjectionResult['indexes']['messageMetaById'];
|
||||
getTurnByMessageId: (messageId: string) => TurnProjectionResult['turns'][number] | undefined;
|
||||
}
|
||||
|
||||
export const useTurnLookup = (projection: TurnProjectionResult): TurnLookupResult => {
|
||||
const getTurnByMessageId = React.useCallback((messageId: string) => {
|
||||
const turnId = projection.indexes.messageToTurnId.get(messageId);
|
||||
if (!turnId) {
|
||||
return undefined;
|
||||
}
|
||||
return projection.indexes.turnById.get(turnId);
|
||||
}, [projection.indexes.messageToTurnId, projection.indexes.turnById]);
|
||||
|
||||
return {
|
||||
turnById: projection.indexes.turnById,
|
||||
messageToTurnId: projection.indexes.messageToTurnId,
|
||||
messageMetaById: projection.indexes.messageMetaById,
|
||||
getTurnByMessageId,
|
||||
};
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
interface SessionLinkRecord {
|
||||
id: string;
|
||||
parentID?: string;
|
||||
}
|
||||
|
||||
export const collectVisibleSessionIdsForBlockingRequests = (
|
||||
sessions: SessionLinkRecord[] | undefined,
|
||||
currentSessionId: string | null,
|
||||
): string[] => {
|
||||
if (!currentSessionId) return [];
|
||||
if (!Array.isArray(sessions) || sessions.length === 0) return [currentSessionId];
|
||||
|
||||
const current = sessions.find((session) => session.id === currentSessionId);
|
||||
if (!current) return [currentSessionId];
|
||||
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
for (const session of sessions) {
|
||||
if (!session.parentID) {
|
||||
continue;
|
||||
}
|
||||
const existing = childrenByParent.get(session.parentID) ?? [];
|
||||
existing.push(session.id);
|
||||
childrenByParent.set(session.parentID, existing);
|
||||
}
|
||||
|
||||
const scoped = [currentSessionId];
|
||||
const seen = new Set(scoped);
|
||||
for (const sessionId of scoped) {
|
||||
const children = childrenByParent.get(sessionId) ?? [];
|
||||
for (const childId of children) {
|
||||
if (seen.has(childId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(childId);
|
||||
scoped.push(childId);
|
||||
}
|
||||
}
|
||||
|
||||
return scoped;
|
||||
};
|
||||
|
||||
export const flattenBlockingRequests = <T extends { id: string }>(
|
||||
source: Map<string, T[]>,
|
||||
sessionIds: string[],
|
||||
): T[] => {
|
||||
if (sessionIds.length === 0) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: T[] = [];
|
||||
|
||||
for (const sessionId of sessionIds) {
|
||||
const entries = source.get(sessionId);
|
||||
if (!entries || entries.length === 0) continue;
|
||||
for (const entry of entries) {
|
||||
if (seen.has(entry.id)) continue;
|
||||
seen.add(entry.id);
|
||||
result.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
export const normalizeWheelDelta = (input: {
|
||||
deltaY: number;
|
||||
deltaMode: number;
|
||||
rootHeight?: number;
|
||||
}): number => {
|
||||
if (input.deltaMode === 1) {
|
||||
return input.deltaY * 40;
|
||||
}
|
||||
if (input.deltaMode === 2) {
|
||||
return input.deltaY * (input.rootHeight ?? 120);
|
||||
}
|
||||
return input.deltaY;
|
||||
};
|
||||
|
||||
export const shouldMarkBoundaryGesture = (input: {
|
||||
delta: number;
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
}): boolean => {
|
||||
const max = input.scrollHeight - input.clientHeight;
|
||||
if (max <= 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!input.delta) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (input.delta < 0) {
|
||||
return input.scrollTop + input.delta <= 0;
|
||||
}
|
||||
|
||||
const remaining = max - input.scrollTop;
|
||||
return input.delta > remaining;
|
||||
};
|
||||
|
||||
export const boundaryTarget = (root: HTMLElement, target: EventTarget | null): HTMLElement => {
|
||||
const current = target instanceof Element ? target : undefined;
|
||||
const nested = current?.closest('[data-scrollable]');
|
||||
if (!nested || nested === root) {
|
||||
return root;
|
||||
}
|
||||
if (!(nested instanceof HTMLElement)) {
|
||||
return root;
|
||||
}
|
||||
return nested;
|
||||
};
|
||||
|
||||
export const shouldPauseAutoScrollOnWheel = (input: {
|
||||
root: HTMLElement;
|
||||
target: EventTarget | null;
|
||||
delta: number;
|
||||
}): boolean => {
|
||||
if (input.delta >= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = boundaryTarget(input.root, input.target);
|
||||
if (target === input.root) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return shouldMarkBoundaryGesture({
|
||||
delta: input.delta,
|
||||
scrollTop: target.scrollTop,
|
||||
scrollHeight: target.scrollHeight,
|
||||
clientHeight: target.clientHeight,
|
||||
});
|
||||
};
|
||||
|
||||
export const isNearTop = (scrollTop: number, threshold: number): boolean => {
|
||||
return scrollTop <= threshold;
|
||||
};
|
||||
|
||||
export const isNearBottom = (distanceFromBottom: number, threshold: number): boolean => {
|
||||
return distanceFromBottom <= threshold;
|
||||
};
|
||||
@@ -18,7 +18,7 @@ type ScrollSpyInput = {
|
||||
MutationObserver?: typeof globalThis.MutationObserver;
|
||||
};
|
||||
|
||||
export const pickVisibleTurnId = (list: VisibleTurn[], line: number): string | undefined => {
|
||||
const pickVisibleTurnId = (list: VisibleTurn[], line: number): string | undefined => {
|
||||
if (list.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export const pickVisibleTurnId = (list: VisibleTurn[], line: number): string | u
|
||||
return sorted[0]?.id;
|
||||
};
|
||||
|
||||
export const pickOffsetTurnId = (list: OffsetTurn[], cutoff: number): string | undefined => {
|
||||
const pickOffsetTurnId = (list: OffsetTurn[], cutoff: number): string | undefined => {
|
||||
if (list.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
export const ACTIVITY_STANDALONE_TOOL_NAMES = new Set<string>(['task']);
|
||||
|
||||
export const HIDDEN_INTERNAL_TOOL_NAMES = new Set<string>(['todowrite', 'todoread']);
|
||||
|
||||
export const TURN_TEXT_THROTTLE_DEFAULT_MS = 100;
|
||||
|
||||
@@ -1,67 +1,6 @@
|
||||
import type { SessionMemoryState } from '@/sync/viewport-store';
|
||||
|
||||
export interface TurnHistorySignalsInput {
|
||||
memoryState: SessionMemoryState | null;
|
||||
loadedMessageCount: number;
|
||||
loadedTurnCount: number;
|
||||
turnStart: number;
|
||||
defaultHistoryLimit: number;
|
||||
}
|
||||
|
||||
export interface TurnHistorySignals {
|
||||
hasBufferedTurns: boolean;
|
||||
hasMoreAboveTurns: boolean;
|
||||
historyLoading: boolean;
|
||||
canLoadEarlier: boolean;
|
||||
}
|
||||
|
||||
const deriveHasMoreAbove = (
|
||||
memoryState: SessionMemoryState | null,
|
||||
loadedMessageCount: number,
|
||||
loadedTurnCount: number,
|
||||
defaultHistoryLimit: number,
|
||||
): boolean => {
|
||||
if (!memoryState) {
|
||||
return loadedMessageCount >= defaultHistoryLimit;
|
||||
}
|
||||
|
||||
if (memoryState.historyComplete === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (memoryState.hasMoreTurnsAbove === true || memoryState.hasMoreAbove === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (memoryState.historyComplete === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (memoryState.hasMoreTurnsAbove === false || memoryState.hasMoreAbove === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fallbackMessageSignal = loadedMessageCount >= defaultHistoryLimit;
|
||||
const fallbackTurnSignal = loadedTurnCount >= Math.max(1, Math.floor(defaultHistoryLimit / 2));
|
||||
return fallbackMessageSignal || fallbackTurnSignal;
|
||||
};
|
||||
|
||||
export const deriveTurnHistorySignals = (
|
||||
input: TurnHistorySignalsInput,
|
||||
): TurnHistorySignals => {
|
||||
const hasBufferedTurns = input.turnStart > 0;
|
||||
const hasMoreAboveTurns = deriveHasMoreAbove(
|
||||
input.memoryState,
|
||||
input.loadedMessageCount,
|
||||
input.loadedTurnCount,
|
||||
input.defaultHistoryLimit,
|
||||
);
|
||||
const historyLoading = Boolean(input.memoryState?.historyLoading);
|
||||
|
||||
return {
|
||||
hasBufferedTurns,
|
||||
hasMoreAboveTurns,
|
||||
historyLoading,
|
||||
canLoadEarlier: hasBufferedTurns || hasMoreAboveTurns,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { projectTurnIndexes } from './projectTurnIndexes';
|
||||
import type { TurnProjectionResult, TurnRecord } from './types';
|
||||
|
||||
const areTurnMessagesReferenceStable = (previousTurn: TurnRecord, nextTurn: TurnRecord): boolean => {
|
||||
if (previousTurn.userMessage !== nextTurn.userMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (previousTurn.assistantMessages.length !== nextTurn.assistantMessages.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < previousTurn.assistantMessages.length; index += 1) {
|
||||
if (previousTurn.assistantMessages[index] !== nextTurn.assistantMessages[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const buildTurnSignature = (turn: TurnRecord): string => {
|
||||
const assistantIds = turn.assistantMessageIds.join(',');
|
||||
return [
|
||||
turn.turnId,
|
||||
turn.headerMessageId ?? '',
|
||||
assistantIds,
|
||||
turn.summaryText ?? '',
|
||||
turn.stream.isStreaming ? '1' : '0',
|
||||
turn.stream.isRetrying ? '1' : '0',
|
||||
turn.completedAt ?? '',
|
||||
].join('|');
|
||||
};
|
||||
|
||||
export const stabilizeTurnProjection = (
|
||||
nextProjection: TurnProjectionResult,
|
||||
previousProjection: TurnProjectionResult | null,
|
||||
): TurnProjectionResult => {
|
||||
if (!previousProjection || previousProjection.turns.length === 0 || nextProjection.turns.length === 0) {
|
||||
return nextProjection;
|
||||
}
|
||||
|
||||
const previousById = new Map(previousProjection.turns.map((turn) => [turn.turnId, turn]));
|
||||
let reused = false;
|
||||
|
||||
const stabilizedTurns = nextProjection.turns.map((turn, index) => {
|
||||
const isLastTurn = index === nextProjection.turns.length - 1;
|
||||
if (isLastTurn) {
|
||||
return turn;
|
||||
}
|
||||
|
||||
const previousTurn = previousById.get(turn.turnId);
|
||||
if (!previousTurn) {
|
||||
return turn;
|
||||
}
|
||||
|
||||
if (buildTurnSignature(previousTurn) !== buildTurnSignature(turn)) {
|
||||
return turn;
|
||||
}
|
||||
|
||||
if (!areTurnMessagesReferenceStable(previousTurn, turn)) {
|
||||
return turn;
|
||||
}
|
||||
|
||||
reused = true;
|
||||
return previousTurn;
|
||||
});
|
||||
|
||||
if (!reused) {
|
||||
return nextProjection;
|
||||
}
|
||||
|
||||
const projection = projectTurnIndexes(stabilizedTurns);
|
||||
return {
|
||||
...projection,
|
||||
ungroupedMessageIds: nextProjection.ungroupedMessageIds,
|
||||
};
|
||||
};
|
||||
@@ -1,159 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface TurnStageConfig {
|
||||
init: number;
|
||||
batch: number;
|
||||
}
|
||||
|
||||
export interface UseStageTurnsOptions {
|
||||
sessionKey: string;
|
||||
turnStart: number;
|
||||
totalTurns: number;
|
||||
config?: Partial<TurnStageConfig>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface StageTurnsResult {
|
||||
stagedCount: number;
|
||||
stageStartIndex: number;
|
||||
isStaging: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_STAGE_CONFIG: TurnStageConfig = {
|
||||
init: 10,
|
||||
batch: 8,
|
||||
};
|
||||
|
||||
export const getInitialStageCount = (total: number, config: TurnStageConfig): number => {
|
||||
if (total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(total, Math.max(1, config.init));
|
||||
};
|
||||
|
||||
export const getNextStageCount = (current: number, total: number, config: TurnStageConfig): number => {
|
||||
if (total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const batch = Math.max(1, config.batch);
|
||||
return Math.min(total, current + batch);
|
||||
};
|
||||
|
||||
export const getStageStartIndex = (total: number, stagedCount: number): number => {
|
||||
if (stagedCount >= total) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, total - stagedCount);
|
||||
};
|
||||
|
||||
export const useStageTurns = ({
|
||||
sessionKey,
|
||||
turnStart,
|
||||
totalTurns,
|
||||
config,
|
||||
disabled,
|
||||
}: UseStageTurnsOptions): StageTurnsResult => {
|
||||
const effectiveConfig = React.useMemo<TurnStageConfig>(() => {
|
||||
return {
|
||||
init: config?.init ?? DEFAULT_STAGE_CONFIG.init,
|
||||
batch: config?.batch ?? DEFAULT_STAGE_CONFIG.batch,
|
||||
};
|
||||
}, [config?.batch, config?.init]);
|
||||
|
||||
const [state, setState] = React.useState(() => ({
|
||||
activeSession: '',
|
||||
completedSession: '',
|
||||
count: totalTurns,
|
||||
}));
|
||||
|
||||
const stateRef = React.useRef(state);
|
||||
React.useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let frameId: number | null = null;
|
||||
const snapshot = stateRef.current;
|
||||
const shouldStage =
|
||||
!disabled
|
||||
&& turnStart > 0
|
||||
&& totalTurns > effectiveConfig.init
|
||||
&& snapshot.completedSession !== sessionKey
|
||||
&& snapshot.activeSession !== sessionKey;
|
||||
|
||||
if (!shouldStage) {
|
||||
setState((previous) => {
|
||||
if (previous.count === totalTurns && previous.activeSession === '') {
|
||||
return previous;
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
activeSession: '',
|
||||
count: totalTurns,
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
if (frameId !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let nextCount = getInitialStageCount(totalTurns, effectiveConfig);
|
||||
setState((previous) => ({
|
||||
...previous,
|
||||
activeSession: sessionKey,
|
||||
count: nextCount,
|
||||
}));
|
||||
|
||||
const step = () => {
|
||||
nextCount = getNextStageCount(nextCount, totalTurns, effectiveConfig);
|
||||
setState((previous) => ({
|
||||
...previous,
|
||||
count: nextCount,
|
||||
}));
|
||||
|
||||
if (nextCount >= totalTurns) {
|
||||
setState((previous) => ({
|
||||
...previous,
|
||||
completedSession: sessionKey,
|
||||
activeSession: '',
|
||||
count: totalTurns,
|
||||
}));
|
||||
frameId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
frameId = window.requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
frameId = window.requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (frameId !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
};
|
||||
}, [disabled, effectiveConfig, sessionKey, totalTurns, turnStart]);
|
||||
|
||||
const stagedCount = React.useMemo(() => {
|
||||
if (turnStart <= 0 || disabled) {
|
||||
return totalTurns;
|
||||
}
|
||||
if (state.completedSession === sessionKey) {
|
||||
return totalTurns;
|
||||
}
|
||||
if (state.count <= 0) {
|
||||
return getInitialStageCount(totalTurns, effectiveConfig);
|
||||
}
|
||||
return Math.min(totalTurns, state.count);
|
||||
}, [disabled, effectiveConfig, sessionKey, state.completedSession, state.count, totalTurns, turnStart]);
|
||||
|
||||
return {
|
||||
stagedCount,
|
||||
stageStartIndex: getStageStartIndex(totalTurns, stagedCount),
|
||||
isStaging: !disabled && turnStart > 0 && state.activeSession === sessionKey && state.completedSession !== sessionKey,
|
||||
};
|
||||
};
|
||||
@@ -5,7 +5,7 @@ export interface ChatMessageEntry {
|
||||
parts: Part[];
|
||||
}
|
||||
|
||||
export type TurnActivityKind = 'tool' | 'reasoning' | 'justification';
|
||||
type TurnActivityKind = 'tool' | 'reasoning' | 'justification';
|
||||
|
||||
export interface TurnMessageRecord {
|
||||
messageId: string;
|
||||
@@ -83,7 +83,7 @@ export interface TurnRecord {
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface TurnMessageMeta {
|
||||
interface TurnMessageMeta {
|
||||
turnId: string;
|
||||
messageId: string;
|
||||
userMessageId: string;
|
||||
|
||||
@@ -140,13 +140,13 @@ const extractTableData = (table: HTMLTableElement): { headers: string[]; rows: s
|
||||
const escapeCsv = (value: string): string =>
|
||||
/[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
||||
|
||||
export const tableToCSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string =>
|
||||
const tableToCSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string =>
|
||||
[headers, ...rows].map((row) => row.map(escapeCsv).join(',')).join('\n');
|
||||
|
||||
export const tableToTSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string =>
|
||||
const tableToTSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string =>
|
||||
[headers, ...rows].map((row) => row.join('\t')).join('\n');
|
||||
|
||||
export const tableToMarkdown = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
|
||||
const tableToMarkdown = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
|
||||
const head = `| ${headers.join(' | ')} |`;
|
||||
const sep = `| ${headers.map(() => '---').join(' | ')} |`;
|
||||
const body = rows.map((row) => `| ${row.join(' | ')} |`).join('\n');
|
||||
|
||||
@@ -13,7 +13,7 @@ const escapeAttr = (value: string): string =>
|
||||
// Streaming block segmentation (port of OpenCode's markdown-stream)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MarkdownBlock = {
|
||||
type MarkdownBlock = {
|
||||
raw: string;
|
||||
src: string;
|
||||
mode: 'full' | 'live';
|
||||
@@ -54,7 +54,7 @@ const heal = (text: string): string => {
|
||||
* unclosed trailing code fence into its own `live` block so a partial fence
|
||||
* does not corrupt the parse of stable content above it.
|
||||
*/
|
||||
export const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => {
|
||||
const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => {
|
||||
if (!live) return [{ raw: text, src: text, mode: 'full', highlight: true }];
|
||||
// Reference-style links/footnotes span multiple tokens (definition elsewhere);
|
||||
// keep them as a single block so per-block parsing doesn't break the refs.
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdow
|
||||
// `--md-syntax-*` CSS variables) lives in the dependency-free
|
||||
// `markdownShikiThemeDefinition` module so it can also be imported inside the
|
||||
// Shiki Web Worker. See that module for the rationale.
|
||||
export { MARKDOWN_SHIKI_THEME };
|
||||
|
||||
|
||||
let registered = false;
|
||||
|
||||
|
||||
@@ -717,5 +717,3 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default TextSelectionMenu;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Part } from '@opencode-ai/sdk/v2';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||
|
||||
export const isValidPart = (part: unknown): part is Part => {
|
||||
const isValidPart = (part: unknown): part is Part => {
|
||||
return Boolean(part && typeof part === 'object' && typeof (part as { type?: unknown }).type === 'string');
|
||||
};
|
||||
|
||||
@@ -67,13 +67,3 @@ export const filterVisibleParts = (parts: Part[], options: VisibleFilterOptions
|
||||
return !isPatchPart;
|
||||
});
|
||||
};
|
||||
|
||||
type PartWithTime = Part & { time?: { start?: number; end?: number } };
|
||||
|
||||
export const isFinalizedTextPart = (part: Part): boolean => {
|
||||
if (part.type !== 'text') {
|
||||
return false;
|
||||
}
|
||||
const time = (part as PartWithTime).time;
|
||||
return Boolean(time && typeof time.end !== 'undefined');
|
||||
};
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MigratingPartProps {
|
||||
|
||||
isMigrating: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MigratingPart: React.FC<MigratingPartProps> = ({
|
||||
isMigrating,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
'w-full overflow-hidden',
|
||||
isMigrating && 'pointer-events-none',
|
||||
className
|
||||
)}
|
||||
style={isMigrating ? { animation: 'oc-migrate-up 220ms ease-out forwards' } : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(MigratingPart);
|
||||
@@ -18,7 +18,7 @@ const TOOL_ROW_DESCRIPTION_CLASS = cn('typography-meta', TOOL_ROW_TEXT_CLASS);
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
|
||||
|
||||
export type ReasoningVariant = 'thinking' | 'justification';
|
||||
type ReasoningVariant = 'thinking' | 'justification';
|
||||
|
||||
const cleanReasoningText = (text: string): string => {
|
||||
if (typeof text !== 'string' || text.trim().length === 0) {
|
||||
@@ -536,7 +536,4 @@ export const MergedReasoningPart = React.memo(({
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const formatReasoningText = (text: string): string => cleanReasoningText(text);
|
||||
|
||||
export default ReasoningPart;
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* 5x5 grid letter patterns (indices 0-24).
|
||||
* Grid layout:
|
||||
* 0 1 2 3 4
|
||||
* 5 6 7 8 9
|
||||
* 10 11 12 13 14
|
||||
* 15 16 17 18 19
|
||||
* 20 21 22 23 24
|
||||
*
|
||||
* Each letter is represented as an array of "on" cell indices.
|
||||
*/
|
||||
const LETTER_PATTERNS: Record<string, readonly number[]> = {
|
||||
// 0 1 2 3 4
|
||||
// 5 6 7 8 9
|
||||
// 10 11 12 13 14
|
||||
// 15 16 17 18 19
|
||||
// 20 21 22 23 24
|
||||
A: [1, 2, 3, 5, 9, 10, 11, 12, 13, 14, 15, 19, 20, 24],
|
||||
B: [0, 1, 2, 3, 5, 9, 10, 11, 12, 13, 15, 19, 20, 21, 22, 23],
|
||||
C: [1, 2, 3, 5, 10, 15, 21, 22, 23],
|
||||
D: [0, 1, 2, 3, 5, 9, 10, 14, 15, 19, 20, 21, 22, 23],
|
||||
E: [0, 1, 2, 3, 5, 10, 11, 12, 15, 20, 21, 22, 23],
|
||||
F: [0, 1, 2, 3, 5, 10, 11, 12, 15, 20],
|
||||
G: [1, 2, 3, 5, 10, 12, 13, 15, 18, 19, 21, 22, 23],
|
||||
H: [0, 4, 5, 9, 10, 11, 12, 13, 14, 15, 19, 20, 24],
|
||||
I: [1, 2, 3, 7, 12, 17, 21, 22, 23],
|
||||
J: [1, 2, 3, 8, 13, 15, 18, 21, 22],
|
||||
K: [0, 3, 5, 7, 10, 11, 15, 17, 20, 23],
|
||||
L: [0, 5, 10, 15, 20, 21, 22, 23],
|
||||
M: [0, 4, 5, 6, 8, 9, 10, 12, 14, 15, 19, 20, 24],
|
||||
N: [0, 4, 5, 6, 9, 10, 12, 14, 15, 18, 19, 20, 24],
|
||||
O: [1, 2, 3, 5, 9, 10, 14, 15, 19, 21, 22, 23],
|
||||
P: [0, 1, 2, 3, 5, 8, 9, 10, 11, 12, 13, 15, 20],
|
||||
Q: [1, 2, 3, 5, 9, 10, 14, 15, 18, 19, 21, 22, 24],
|
||||
R: [0, 1, 2, 3, 5, 8, 9, 10, 11, 12, 13, 15, 17, 20, 23],
|
||||
S: [1, 2, 3, 5, 11, 12, 13, 19, 21, 22, 23],
|
||||
T: [0, 1, 2, 3, 4, 7, 12, 17, 22],
|
||||
U: [0, 4, 5, 9, 10, 14, 15, 19, 21, 22, 23],
|
||||
V: [0, 4, 5, 9, 10, 14, 16, 18, 22],
|
||||
W: [0, 4, 5, 9, 10, 12, 14, 15, 16, 18, 19, 21, 23],
|
||||
X: [0, 4, 6, 8, 12, 16, 18, 20, 24],
|
||||
Y: [0, 4, 6, 8, 12, 17, 22],
|
||||
Z: [0, 1, 2, 3, 4, 8, 12, 16, 20, 21, 22, 23, 24],
|
||||
'0': [1, 2, 3, 5, 9, 10, 14, 15, 19, 21, 22, 23],
|
||||
'1': [2, 6, 7, 12, 17, 20, 21, 22, 23, 24],
|
||||
'2': [1, 2, 3, 9, 11, 12, 13, 16, 20, 21, 22, 23, 24],
|
||||
'3': [0, 1, 2, 3, 9, 11, 12, 13, 19, 20, 21, 22, 23],
|
||||
'4': [0, 4, 5, 9, 10, 11, 12, 13, 14, 19, 24],
|
||||
'5': [0, 1, 2, 3, 4, 5, 10, 11, 12, 13, 19, 20, 21, 22, 23],
|
||||
'6': [1, 2, 3, 5, 10, 11, 12, 13, 15, 19, 21, 22, 23],
|
||||
'7': [0, 1, 2, 3, 4, 9, 13, 17, 22],
|
||||
'8': [1, 2, 3, 5, 9, 11, 12, 13, 15, 19, 21, 22, 23],
|
||||
'9': [1, 2, 3, 5, 9, 11, 12, 13, 19, 21, 22, 23],
|
||||
' ': [],
|
||||
};
|
||||
|
||||
// Build Set versions for O(1) lookups
|
||||
const LETTER_SETS: Record<string, Set<number>> = {};
|
||||
for (const [key, indices] of Object.entries(LETTER_PATTERNS)) {
|
||||
LETTER_SETS[key] = new Set(indices);
|
||||
}
|
||||
|
||||
/** Duration each letter is displayed (ms) */
|
||||
const LETTER_DURATION_MS = 800;
|
||||
/** Crossfade transition duration (ms) */
|
||||
const TRANSITION_MS = 500;
|
||||
/** Pause between full cycles (ms) */
|
||||
const CYCLE_PAUSE_MS = 1000;
|
||||
|
||||
/** Spacing between dot centers in SVG units */
|
||||
const DOT_SPACING = 4;
|
||||
/** Dot radius */
|
||||
const DOT_RADIUS = 1.2;
|
||||
|
||||
/**
|
||||
* Octagonal grid layout (7 rows):
|
||||
*
|
||||
* • • • row 0: 3 dots (cols 2-4)
|
||||
* • • • • • row 1: 5 dots (cols 1-5) → letter row 0
|
||||
* • • • • • • • row 2: 7 dots (cols 0-6) → letter row 1
|
||||
* • • • • • • • row 3: 7 dots (cols 0-6) → letter row 2
|
||||
* • • • • • • • row 4: 7 dots (cols 0-6) → letter row 3
|
||||
* • • • • • row 5: 5 dots (cols 1-5) → letter row 4
|
||||
* • • • row 6: 3 dots (cols 2-4)
|
||||
*
|
||||
* Letter indices (0-24) map to the inner 5x5 zone:
|
||||
* rows 1-5, cols 1-5
|
||||
*/
|
||||
const OCTAGON_ROWS: { row: number; cols: number[] }[] = [
|
||||
{ row: 0, cols: [2, 3, 4] },
|
||||
{ row: 1, cols: [1, 2, 3, 4, 5] },
|
||||
{ row: 2, cols: [0, 1, 2, 3, 4, 5, 6] },
|
||||
{ row: 3, cols: [0, 1, 2, 3, 4, 5, 6] },
|
||||
{ row: 4, cols: [0, 1, 2, 3, 4, 5, 6] },
|
||||
{ row: 5, cols: [1, 2, 3, 4, 5] },
|
||||
{ row: 6, cols: [2, 3, 4] },
|
||||
];
|
||||
|
||||
interface OctCell {
|
||||
id: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
/** Index into the 5x5 letter grid (0-24), or -1 for border-only dots */
|
||||
letterIndex: number;
|
||||
// Stable random timing
|
||||
shimmerDuration: number;
|
||||
shimmerDelay: number;
|
||||
idleDuration: number;
|
||||
idleDelay: number;
|
||||
}
|
||||
|
||||
const CELLS: OctCell[] = [];
|
||||
let cellId = 0;
|
||||
for (const { row, cols } of OCTAGON_ROWS) {
|
||||
for (const col of cols) {
|
||||
const cx = col * DOT_SPACING;
|
||||
const cy = row * DOT_SPACING;
|
||||
|
||||
// Letter zone: rows 1-5 (octagon), cols 1-5 (octagon)
|
||||
// maps to 5x5 letter index
|
||||
let letterIndex = -1;
|
||||
const letterRow = row - 1;
|
||||
const letterCol = col - 1;
|
||||
if (letterRow >= 0 && letterRow < 5 && letterCol >= 0 && letterCol < 5) {
|
||||
letterIndex = letterRow * 5 + letterCol;
|
||||
}
|
||||
|
||||
CELLS.push({
|
||||
id: cellId++,
|
||||
cx,
|
||||
cy,
|
||||
letterIndex,
|
||||
shimmerDuration: 3 + Math.random() * 3,
|
||||
shimmerDelay: Math.random() * 3,
|
||||
idleDuration: 1 + Math.random(),
|
||||
idleDelay: Math.random() * 1.5,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const VIEW_SIZE = 6 * DOT_SPACING + DOT_RADIUS * 2;
|
||||
const VIEW_OFFSET = -DOT_RADIUS;
|
||||
|
||||
interface SessionActiveSpinnerProps {
|
||||
className?: string;
|
||||
/** Text to spell out letter by letter. Falls back to idle pulse when empty/undefined. */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idle mode: random pulsing octagonal dot grid.
|
||||
* Text mode: cycles through characters of `text`, morphing between letter shapes.
|
||||
*/
|
||||
export function SessionActiveSpinner({ className, text }: SessionActiveSpinnerProps) {
|
||||
const normalizedText = text?.toUpperCase().replace(/[^A-Z0-9 ]/g, '') || '';
|
||||
const hasText = normalizedText.length > 0;
|
||||
|
||||
const [charIndex, setCharIndex] = React.useState(0);
|
||||
const [phase, setPhase] = React.useState<'hold' | 'morph'>('hold');
|
||||
|
||||
// Intro fade: foreground starts invisible and fades in
|
||||
const [introReady, setIntroReady] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setIntroReady(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
// Reset on text change
|
||||
React.useEffect(() => {
|
||||
setCharIndex(0);
|
||||
setPhase('hold');
|
||||
}, [normalizedText]);
|
||||
|
||||
// Letter cycling timer
|
||||
React.useEffect(() => {
|
||||
if (!hasText) return;
|
||||
|
||||
const total = normalizedText.length;
|
||||
|
||||
if (phase === 'hold') {
|
||||
const isLastChar = charIndex === total - 1;
|
||||
const delay = LETTER_DURATION_MS + (isLastChar ? CYCLE_PAUSE_MS : 0);
|
||||
const timer = setTimeout(() => setPhase('morph'), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setCharIndex((prev) => (prev + 1) % total);
|
||||
setPhase('hold');
|
||||
}, TRANSITION_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [hasText, charIndex, normalizedText, phase]);
|
||||
|
||||
// Compute current and next letter sets for morphing
|
||||
const total = normalizedText.length;
|
||||
const currentSet = hasText
|
||||
? (LETTER_SETS[normalizedText[charIndex]] ?? LETTER_SETS[' '])
|
||||
: null;
|
||||
const nextIndex = hasText ? (charIndex + 1) % total : 0;
|
||||
const nextSet = hasText
|
||||
? (LETTER_SETS[normalizedText[nextIndex]] ?? LETTER_SETS[' '])
|
||||
: null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`${VIEW_OFFSET} ${VIEW_OFFSET} ${VIEW_SIZE} ${VIEW_SIZE}`}
|
||||
data-component="session-active-spinner"
|
||||
className={className}
|
||||
fill="var(--foreground)"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Background layer: all dots with shimmer animation */}
|
||||
{CELLS.map((cell) => (
|
||||
<circle
|
||||
key={cell.id}
|
||||
cx={cell.cx}
|
||||
cy={cell.cy}
|
||||
r={DOT_RADIUS}
|
||||
style={{
|
||||
animation: `${currentSet ? 'pulse-opacity-dim' : 'pulse-opacity'} ${currentSet ? cell.shimmerDuration : cell.idleDuration}s ease-in-out infinite`,
|
||||
animationDelay: `${currentSet ? cell.shimmerDelay : cell.idleDelay}s`,
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Foreground layer: morphing letter dots (only on letter-zone cells) */}
|
||||
<g fill="var(--primary)">
|
||||
{currentSet && nextSet && CELLS.map((cell) => {
|
||||
if (cell.letterIndex < 0) return null;
|
||||
|
||||
const inCurrent = currentSet.has(cell.letterIndex);
|
||||
const inNext = nextSet.has(cell.letterIndex);
|
||||
|
||||
if (!inCurrent && !inNext) return null;
|
||||
|
||||
let opacity: number;
|
||||
if (!introReady) {
|
||||
opacity = 0;
|
||||
} else if (phase === 'hold') {
|
||||
opacity = inCurrent ? 1 : 0;
|
||||
} else {
|
||||
if (inCurrent && inNext) {
|
||||
opacity = 1;
|
||||
} else if (inCurrent) {
|
||||
opacity = 0;
|
||||
} else {
|
||||
opacity = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={`fg-${cell.id}`}
|
||||
cx={cell.cx}
|
||||
cy={cell.cy}
|
||||
r={DOT_RADIUS}
|
||||
style={{
|
||||
opacity,
|
||||
transition: `opacity ${TRANSITION_MS}ms ease-in-out`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
export type GeneratedCommitResult = {
|
||||
type GeneratedCommitResult = {
|
||||
kind: 'commit';
|
||||
subject: string;
|
||||
highlights: string[];
|
||||
raw: string;
|
||||
};
|
||||
|
||||
export type GeneratedPrResult = {
|
||||
type GeneratedPrResult = {
|
||||
kind: 'pr';
|
||||
title: string;
|
||||
body: string;
|
||||
|
||||
@@ -18,7 +18,7 @@ const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === 'object' && value !== null;
|
||||
};
|
||||
|
||||
export const normalizePatchText = (patch: string): string => {
|
||||
const normalizePatchText = (patch: string): string => {
|
||||
return patch.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
|
||||
};
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ const EXPANDABLE_TOOL_NAMES = new Set<string>([
|
||||
|
||||
const STANDALONE_TOOL_NAMES = new Set<string>(['task']);
|
||||
|
||||
const SEARCH_TOOL_NAMES = new Set<string>(['grep', 'search', 'find', 'ripgrep', 'glob']);
|
||||
|
||||
const normalizeToolName = (toolName: unknown): string => {
|
||||
if (typeof toolName !== 'string') return '';
|
||||
const trimmed = toolName.trim().toLowerCase();
|
||||
@@ -34,11 +32,3 @@ export const isStaticTool = (toolName: unknown): boolean => {
|
||||
if (typeof toolName !== 'string') return false;
|
||||
return !isExpandableTool(toolName) && !isStandaloneTool(toolName);
|
||||
};
|
||||
|
||||
export const getStaticGroupToolName = (toolName: string): string => {
|
||||
const normalized = normalizeToolName(toolName);
|
||||
if (SEARCH_TOOL_NAMES.has(normalized)) {
|
||||
return 'grep';
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
@@ -106,7 +106,7 @@ export const areRenderRelevantPartsEqual = (left: Part[], right: Part[]): boolea
|
||||
return true;
|
||||
};
|
||||
|
||||
export const areRenderRelevantMessageInfoEqual = (left: Message, right: Message): boolean => {
|
||||
const areRenderRelevantMessageInfoEqual = (left: Message, right: Message): boolean => {
|
||||
if (left === right) return true;
|
||||
|
||||
return left.id === right.id
|
||||
|
||||
@@ -11,7 +11,7 @@ const cleanOutput = (output: string) => {
|
||||
return cleaned.trim();
|
||||
};
|
||||
|
||||
export const hasLspDiagnostics = (output: string): boolean => {
|
||||
const hasLspDiagnostics = (output: string): boolean => {
|
||||
if (!output) return false;
|
||||
return output.includes('<diagnostics')
|
||||
|| output.includes('<file_diagnostics>')
|
||||
@@ -124,7 +124,7 @@ export const formatEditOutput = (output: string, toolName: string, metadata?: Re
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
export interface ParsedReadOutputLine {
|
||||
interface ParsedReadOutputLine {
|
||||
text: string;
|
||||
lineNumber: number | null;
|
||||
isInfo: boolean;
|
||||
@@ -528,9 +528,9 @@ export const renderWebSearchOutput = (output: string, options?: { unstyled?: boo
|
||||
}
|
||||
};
|
||||
|
||||
export type DiffLineType = 'context' | 'added' | 'removed';
|
||||
type DiffLineType = 'context' | 'added' | 'removed';
|
||||
|
||||
export interface UnifiedDiffLine {
|
||||
interface UnifiedDiffLine {
|
||||
type: DiffLineType;
|
||||
lineNumber: number | null;
|
||||
content: string;
|
||||
@@ -543,18 +543,6 @@ export interface UnifiedDiffHunk {
|
||||
lines: UnifiedDiffLine[];
|
||||
}
|
||||
|
||||
export interface SideBySideDiffLine {
|
||||
leftLine: { type: 'context' | 'removed' | 'empty'; lineNumber: number | null; content: string };
|
||||
rightLine: { type: 'context' | 'added' | 'empty'; lineNumber: number | null; content: string };
|
||||
}
|
||||
|
||||
export interface SideBySideDiffHunk {
|
||||
file: string;
|
||||
oldStart: number;
|
||||
newStart: number;
|
||||
lines: SideBySideDiffLine[];
|
||||
}
|
||||
|
||||
export const parseDiffToUnified = (diffText: string): UnifiedDiffHunk[] => {
|
||||
const lines = diffText.split('\n');
|
||||
let currentFile = '';
|
||||
@@ -615,199 +603,6 @@ export const parseDiffToUnified = (diffText: string): UnifiedDiffHunk[] => {
|
||||
return hunks;
|
||||
};
|
||||
|
||||
export const parseDiffToLines = (diffText: string): SideBySideDiffHunk[] => {
|
||||
const lines = diffText.split('\n');
|
||||
let currentFile = '';
|
||||
const hunks: SideBySideDiffHunk[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('Index:') || line.startsWith('===') || line.startsWith('---') || line.startsWith('+++')) {
|
||||
if (line.startsWith('Index:')) {
|
||||
currentFile = line.split(' ')[1].split('/').pop() || 'file';
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('@@')) {
|
||||
const match = line.match(/@@ -(\d+),\d+ \+(\d+),\d+ @@/);
|
||||
const oldStart = match ? parseInt(match[1]) : 0;
|
||||
const newStart = match ? parseInt(match[2]) : 0;
|
||||
|
||||
const changes: Array<{
|
||||
type: 'context' | 'added' | 'removed';
|
||||
content: string;
|
||||
oldLine?: number;
|
||||
newLine?: number;
|
||||
}> = [];
|
||||
|
||||
let oldLineNum = oldStart;
|
||||
let newLineNum = newStart;
|
||||
let j = i + 1;
|
||||
|
||||
while (j < lines.length && !lines[j].startsWith('@@') && !lines[j].startsWith('Index:')) {
|
||||
const contentLine = lines[j];
|
||||
if (contentLine.startsWith('+')) {
|
||||
changes.push({ type: 'added', content: contentLine.substring(1), newLine: newLineNum });
|
||||
newLineNum++;
|
||||
} else if (contentLine.startsWith('-')) {
|
||||
changes.push({ type: 'removed', content: contentLine.substring(1), oldLine: oldLineNum });
|
||||
oldLineNum++;
|
||||
} else if (contentLine.startsWith(' ')) {
|
||||
changes.push({
|
||||
type: 'context',
|
||||
content: contentLine.substring(1),
|
||||
oldLine: oldLineNum,
|
||||
newLine: newLineNum,
|
||||
});
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
|
||||
const alignedLines: Array<{
|
||||
leftLine: { type: 'context' | 'removed' | 'empty'; lineNumber: number | null; content: string };
|
||||
rightLine: { type: 'context' | 'added' | 'empty'; lineNumber: number | null; content: string };
|
||||
}> = [];
|
||||
|
||||
const leftSide: Array<{ type: 'context' | 'removed'; lineNumber: number; content: string }> = [];
|
||||
const rightSide: Array<{ type: 'context' | 'added'; lineNumber: number; content: string }> = [];
|
||||
|
||||
changes.forEach((change) => {
|
||||
if (change.type === 'context') {
|
||||
leftSide.push({ type: 'context', lineNumber: change.oldLine!, content: change.content });
|
||||
rightSide.push({ type: 'context', lineNumber: change.newLine!, content: change.content });
|
||||
} else if (change.type === 'removed') {
|
||||
leftSide.push({ type: 'removed', lineNumber: change.oldLine!, content: change.content });
|
||||
} else if (change.type === 'added') {
|
||||
rightSide.push({ type: 'added', lineNumber: change.newLine!, content: change.content });
|
||||
}
|
||||
});
|
||||
|
||||
const alignmentPoints: Array<{ leftIdx: number; rightIdx: number }> = [];
|
||||
|
||||
leftSide.forEach((leftItem, leftIdx) => {
|
||||
if (leftItem.type === 'context') {
|
||||
const rightIdx = rightSide.findIndex((rightItem, rIdx) =>
|
||||
rightItem.type === 'context' &&
|
||||
rightItem.content === leftItem.content &&
|
||||
!alignmentPoints.some((ap) => ap.rightIdx === rIdx)
|
||||
);
|
||||
if (rightIdx >= 0) {
|
||||
alignmentPoints.push({ leftIdx, rightIdx });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
alignmentPoints.sort((a, b) => a.leftIdx - b.leftIdx);
|
||||
|
||||
let leftIdx = 0;
|
||||
let rightIdx = 0;
|
||||
let alignIdx = 0;
|
||||
|
||||
while (leftIdx < leftSide.length || rightIdx < rightSide.length) {
|
||||
const nextAlign = alignIdx < alignmentPoints.length ? alignmentPoints[alignIdx] : null;
|
||||
|
||||
if (nextAlign && leftIdx === nextAlign.leftIdx && rightIdx === nextAlign.rightIdx) {
|
||||
const leftItem = leftSide[leftIdx];
|
||||
const rightItem = rightSide[rightIdx];
|
||||
|
||||
alignedLines.push({
|
||||
leftLine: {
|
||||
type: 'context',
|
||||
lineNumber: leftItem.lineNumber,
|
||||
content: leftItem.content,
|
||||
},
|
||||
rightLine: {
|
||||
type: 'context',
|
||||
lineNumber: rightItem.lineNumber,
|
||||
content: rightItem.content,
|
||||
},
|
||||
});
|
||||
|
||||
leftIdx++;
|
||||
rightIdx++;
|
||||
alignIdx++;
|
||||
} else {
|
||||
const needProcessLeft = leftIdx < leftSide.length && (!nextAlign || leftIdx < nextAlign.leftIdx);
|
||||
const needProcessRight = rightIdx < rightSide.length && (!nextAlign || rightIdx < nextAlign.rightIdx);
|
||||
|
||||
if (needProcessLeft && needProcessRight) {
|
||||
const leftItem = leftSide[leftIdx];
|
||||
const rightItem = rightSide[rightIdx];
|
||||
|
||||
alignedLines.push({
|
||||
leftLine: {
|
||||
type: leftItem.type,
|
||||
lineNumber: leftItem.lineNumber,
|
||||
content: leftItem.content,
|
||||
},
|
||||
rightLine: {
|
||||
type: rightItem.type,
|
||||
lineNumber: rightItem.lineNumber,
|
||||
content: rightItem.content,
|
||||
},
|
||||
});
|
||||
|
||||
leftIdx++;
|
||||
rightIdx++;
|
||||
} else if (needProcessLeft) {
|
||||
const leftItem = leftSide[leftIdx];
|
||||
alignedLines.push({
|
||||
leftLine: {
|
||||
type: leftItem.type,
|
||||
lineNumber: leftItem.lineNumber,
|
||||
content: leftItem.content,
|
||||
},
|
||||
rightLine: {
|
||||
type: 'empty',
|
||||
lineNumber: null,
|
||||
content: '',
|
||||
},
|
||||
});
|
||||
leftIdx++;
|
||||
} else if (needProcessRight) {
|
||||
const rightItem = rightSide[rightIdx];
|
||||
alignedLines.push({
|
||||
leftLine: {
|
||||
type: 'empty',
|
||||
lineNumber: null,
|
||||
content: '',
|
||||
},
|
||||
rightLine: {
|
||||
type: rightItem.type,
|
||||
lineNumber: rightItem.lineNumber,
|
||||
content: rightItem.content,
|
||||
},
|
||||
});
|
||||
rightIdx++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hunks.push({
|
||||
file: currentFile,
|
||||
oldStart,
|
||||
newStart,
|
||||
lines: alignedLines,
|
||||
});
|
||||
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return hunks;
|
||||
};
|
||||
|
||||
export const detectLanguageFromOutput = (output: string, toolName: string, input?: Record<string, unknown>) => {
|
||||
return detectToolOutputLanguage(toolName, output, input);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ export type MobileControlsPanel = 'model' | 'agent' | 'variant' | null;
|
||||
|
||||
export const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null;
|
||||
|
||||
export const getCyclablePrimaryAgents = (agents: Agent[]) => agents.filter((agent) => isPrimaryMode(agent.mode));
|
||||
const getCyclablePrimaryAgents = (agents: Agent[]) => agents.filter((agent) => isPrimaryMode(agent.mode));
|
||||
|
||||
export const getCycledPrimaryAgentName = (
|
||||
agents: Agent[],
|
||||
@@ -23,7 +23,7 @@ export const getCycledPrimaryAgentName = (
|
||||
return primaryAgents[nextIndex]?.name ?? null;
|
||||
};
|
||||
|
||||
export const capitalizeLabel = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
|
||||
const capitalizeLabel = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
|
||||
|
||||
export const getAgentDisplayName = (agents: Agent[], agentName?: string) => {
|
||||
if (agentName) {
|
||||
@@ -55,58 +55,3 @@ export const formatEffortLabel = (variant?: string) => {
|
||||
}
|
||||
return capitalizeLabel(trimmed);
|
||||
};
|
||||
|
||||
export const DEFAULT_EFFORT_KEY = 'default';
|
||||
|
||||
export const serializeEffortVariant = (variant?: string) => {
|
||||
const trimmed = typeof variant === 'string' ? variant.trim() : '';
|
||||
return trimmed.length > 0 ? trimmed : DEFAULT_EFFORT_KEY;
|
||||
};
|
||||
|
||||
export const parseEffortVariant = (variant: string) => {
|
||||
return variant === DEFAULT_EFFORT_KEY ? undefined : variant;
|
||||
};
|
||||
|
||||
const EFFORT_RANKS: Record<string, number> = {
|
||||
max: 6,
|
||||
maximum: 6,
|
||||
xhigh: 5,
|
||||
high: 4,
|
||||
medium: 3,
|
||||
default: 2,
|
||||
low: 1,
|
||||
min: 0,
|
||||
minimal: 0,
|
||||
};
|
||||
|
||||
export const getEffortRank = (variant?: string) => {
|
||||
if (!variant || variant.trim().length === 0) {
|
||||
return EFFORT_RANKS.default;
|
||||
}
|
||||
const normalized = variant.trim().toLowerCase();
|
||||
if (Object.prototype.hasOwnProperty.call(EFFORT_RANKS, normalized)) {
|
||||
return EFFORT_RANKS[normalized];
|
||||
}
|
||||
const numeric = Number.parseFloat(normalized);
|
||||
return Number.isFinite(numeric) ? numeric : 0;
|
||||
};
|
||||
|
||||
export const getQuickEffortOptions = (variants: string[]) => {
|
||||
const options = new Map<string, string | undefined>();
|
||||
options.set('default', undefined);
|
||||
for (const variant of variants) {
|
||||
options.set(variant, variant);
|
||||
}
|
||||
|
||||
const ordered = Array.from(options.values()).sort((a, b) => getEffortRank(b) - getEffortRank(a));
|
||||
if (ordered.length <= 4) {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
const top = ordered.slice(0, 3);
|
||||
const lowest = ordered[ordered.length - 1];
|
||||
if (top.some((item) => item === lowest)) {
|
||||
return top;
|
||||
}
|
||||
return [...top, lowest];
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
|
||||
import type { State } from '@/sync/types';
|
||||
|
||||
export type RevertedMessageRecord = {
|
||||
type RevertedMessageRecord = {
|
||||
message: Message & { role: 'user' };
|
||||
parts: Part[];
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type DraftStarterType,
|
||||
} from '@/lib/draftStarters';
|
||||
|
||||
export type StarterGroup = 'global' | 'project';
|
||||
type StarterGroup = 'global' | 'project';
|
||||
|
||||
export type ResolvedStarter = {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user