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
@@ -32,7 +32,7 @@ import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { MobileDeleteWorktreeDialog } from './MobileDeleteWorktreeDialog';
|
||||
import { MobileSurfaceShell } from './MobileSurfaceShell';
|
||||
|
||||
export type MobileEditableProject = {
|
||||
type MobileEditableProject = {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
|
||||
@@ -19,15 +19,6 @@ export const DedicatedMobileAppProvider: React.FC<{
|
||||
<DedicatedMobileAppContext.Provider value={actions}>{children}</DedicatedMobileAppContext.Provider>
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns true when the surrounding tree is the dedicated MobileApp root
|
||||
* (Capacitor or hosted /mobile.html), as opposed to the desktop responsive
|
||||
* mobile path. Use this to suppress UI that exists only to bridge the
|
||||
* desktop sidebar/layout into mobile, since the dedicated mobile root has
|
||||
* its own native-feeling navigation and no sidebars to bridge into.
|
||||
*/
|
||||
export const useIsDedicatedMobileApp = (): boolean => React.useContext(DedicatedMobileAppContext) !== null;
|
||||
|
||||
/**
|
||||
* Returns the dedicated mobile app's surface-opening actions, or null when
|
||||
* not inside the dedicated mobile root. Components living in shared chat /
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1098,278 +1098,6 @@ export function DesktopHostSwitcherDialog({
|
||||
);
|
||||
}
|
||||
|
||||
type DesktopHostSwitcherButtonProps = {
|
||||
headerIconButtonClass: string;
|
||||
};
|
||||
|
||||
export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHostSwitcherButtonProps) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [label, setLabel] = React.useState('Local');
|
||||
const [status, setStatus] = React.useState<HostProbeResult['status'] | null>(null);
|
||||
const [localOrigin, setLocalOrigin] = React.useState<string>(() => getLocalOrigin());
|
||||
const attemptedDefaultSshConnectRef = React.useRef(false);
|
||||
const [startupSshModal, setStartupSshModal] = React.useState<{
|
||||
open: boolean;
|
||||
hostId: string | null;
|
||||
hostLabel: string;
|
||||
error: string | null;
|
||||
connecting: boolean;
|
||||
}>({
|
||||
open: false,
|
||||
hostId: null,
|
||||
hostLabel: '',
|
||||
error: null,
|
||||
connecting: false,
|
||||
});
|
||||
|
||||
const connectDefaultSshInstance = React.useCallback(async (
|
||||
hostId: string,
|
||||
hostLabel: string,
|
||||
options?: { showProgress?: boolean },
|
||||
): Promise<boolean> => {
|
||||
const showProgress = Boolean(options?.showProgress);
|
||||
if (showProgress) {
|
||||
setStartupSshModal({
|
||||
open: true,
|
||||
hostId,
|
||||
hostLabel,
|
||||
error: null,
|
||||
connecting: true,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await desktopSshConnect(hostId);
|
||||
const ready = await waitForSshReady(hostId, 45_000, () => {});
|
||||
const localUrl = normalizeHostUrl(ready.localUrl || '');
|
||||
if (!localUrl) {
|
||||
throw new Error('Connected but missing forwarded URL');
|
||||
}
|
||||
if (isElectronShell()) {
|
||||
switchRuntimeEndpoint({ apiBaseUrl: localUrl, clientToken: null, runtimeKey: `ssh:${hostId}` });
|
||||
} else {
|
||||
window.location.assign(toNavigationUrl(localUrl));
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStartupSshModal({
|
||||
open: true,
|
||||
hostId,
|
||||
hostLabel,
|
||||
error: message,
|
||||
connecting: false,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const switchStartupToLocal = React.useCallback(async () => {
|
||||
setStartupSshModal({
|
||||
open: false,
|
||||
hostId: null,
|
||||
hostLabel: '',
|
||||
error: null,
|
||||
connecting: false,
|
||||
});
|
||||
|
||||
let nextLocalOrigin = localOrigin;
|
||||
await desktopHostsGet()
|
||||
.then((cfg) => {
|
||||
if (cfg.localOrigin) {
|
||||
nextLocalOrigin = cfg.localOrigin;
|
||||
setLocalOrigin(cfg.localOrigin);
|
||||
}
|
||||
return desktopHostsSet({ hosts: cfg.hosts, defaultHostId: LOCAL_HOST_ID });
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
if (isElectronShell()) {
|
||||
const clientToken = await getLocalClientToken();
|
||||
switchRuntimeEndpoint({ apiBaseUrl: nextLocalOrigin, clientToken: clientToken || null, runtimeKey: 'local' });
|
||||
} else {
|
||||
window.location.assign(toNavigationUrl(nextLocalOrigin));
|
||||
}
|
||||
}, [localOrigin]);
|
||||
|
||||
const retryStartupSsh = React.useCallback(() => {
|
||||
const hostId = startupSshModal.hostId;
|
||||
if (!hostId) return;
|
||||
void connectDefaultSshInstance(hostId, startupSshModal.hostLabel || 'SSH instance', {
|
||||
showProgress: true,
|
||||
});
|
||||
}, [connectDefaultSshInstance, startupSshModal.hostId, startupSshModal.hostLabel]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDesktopShell()) return;
|
||||
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
try {
|
||||
const cfg = await desktopHostsGet();
|
||||
const nextLocalOrigin = cfg.localOrigin || localOrigin;
|
||||
if (cfg.localOrigin && cfg.localOrigin !== localOrigin) {
|
||||
setLocalOrigin(cfg.localOrigin);
|
||||
}
|
||||
const local = buildLocalHost(nextLocalOrigin);
|
||||
const all = [local, ...(cfg.hosts || [])];
|
||||
const current = resolveCurrentHost(all);
|
||||
|
||||
if (
|
||||
!isElectronShell() &&
|
||||
!attemptedDefaultSshConnectRef.current &&
|
||||
current.id === LOCAL_HOST_ID &&
|
||||
cfg.defaultHostId &&
|
||||
cfg.defaultHostId !== LOCAL_HOST_ID
|
||||
) {
|
||||
const sshCfg = await desktopSshInstancesGet().catch(() => ({ instances: [] }));
|
||||
const defaultSsh = sshCfg.instances.find((instance) => instance.id === cfg.defaultHostId);
|
||||
if (defaultSsh) {
|
||||
attemptedDefaultSshConnectRef.current = true;
|
||||
const hostLabel = redactSensitiveUrl(
|
||||
defaultSsh.nickname?.trim() || defaultSsh.sshParsed?.destination || defaultSsh.id,
|
||||
);
|
||||
const connected = await connectDefaultSshInstance(cfg.defaultHostId, hostLabel);
|
||||
if (connected || cancelled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
setLabel(redactSensitiveUrl(current.label || t('desktopHostSwitcher.instance.fallback')));
|
||||
const normalized = normalizeHostUrl(current.url);
|
||||
if (!normalized) {
|
||||
setStatus(null);
|
||||
return;
|
||||
}
|
||||
const res = await desktopHostProbe(normalized).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
|
||||
if (cancelled) return;
|
||||
setStatus(res.status);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLabel(t('desktopHostSwitcher.instance.fallback'));
|
||||
setStatus(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
const interval = window.setInterval(() => {
|
||||
// Skip polling when tab is hidden to reduce background work
|
||||
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
void run();
|
||||
}, 10_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [connectDefaultSshInstance, localOrigin, t]);
|
||||
|
||||
if (!isDesktopShell()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
|
||||
const isCurrentlyLocal = runtimeApiBaseUrl
|
||||
? locationMatchesHost(runtimeApiBaseUrl, localOrigin)
|
||||
: locationMatchesHost(window.location.href, localOrigin);
|
||||
|
||||
const fallbackLabel = typeof window !== 'undefined' && window.location.hostname
|
||||
? window.location.hostname
|
||||
: t('desktopHostSwitcher.instance.fallback');
|
||||
|
||||
const effectiveLabel = isCurrentlyLocal
|
||||
? t('desktopHostSwitcher.instance.local')
|
||||
: label === 'Local'
|
||||
? fallbackLabel
|
||||
: label;
|
||||
const safeEffectiveLabel = redactSensitiveUrl(effectiveLabel);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={t('desktopHostSwitcher.actions.switchInstanceAria')}
|
||||
data-oc-host-switcher
|
||||
className={cn(headerIconButtonClass, 'relative w-auto px-3')}
|
||||
>
|
||||
<Icon name="server" className="h-5 w-5" />
|
||||
<span className="hidden sm:inline typography-ui-label font-medium text-muted-foreground truncate max-w-[11rem]">
|
||||
{safeEffectiveLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none absolute top-1.5 right-1.5 h-1.5 w-1.5 rounded-full',
|
||||
statusDotClass(status)
|
||||
)}
|
||||
aria-label={t('desktopHostSwitcher.statusAria')}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('desktopHostSwitcher.title')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DesktopHostSwitcherDialog open={open} onOpenChange={setOpen} />
|
||||
<Dialog
|
||||
open={startupSshModal.open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && startupSshModal.connecting) {
|
||||
return;
|
||||
}
|
||||
if (!nextOpen) {
|
||||
setStartupSshModal((prev) => ({
|
||||
...prev,
|
||||
open: false,
|
||||
connecting: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setStartupSshModal((prev) => ({ ...prev, open: true }));
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-[min(30rem,calc(100vw-2rem))] max-w-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('desktopHostSwitcher.startup.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{startupSshModal.connecting
|
||||
? t('desktopHostSwitcher.startup.connectingTo', { host: startupSshModal.hostLabel || t('desktopHostSwitcher.ssh.instanceFallback') })
|
||||
: startupSshModal.error || t('desktopHostSwitcher.startup.failed')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void switchStartupToLocal()}
|
||||
disabled={startupSshModal.connecting}
|
||||
>
|
||||
{t('desktopHostSwitcher.actions.switchToLocal')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={retryStartupSsh}
|
||||
disabled={startupSshModal.connecting || !startupSshModal.hostId}
|
||||
>
|
||||
{startupSshModal.connecting ? <Icon name="loader-4" className="h-4 w-4 animate-spin" /> : null}
|
||||
{t('desktopHostSwitcher.actions.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesktopHostSwitcherInline() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { DiagramEditor, type DiagramEditorProps, type DiagramEditorHandle } from './DiagramEditor';
|
||||
export { DiagramEditor, type DiagramEditorHandle } from './DiagramEditor';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cn } from '@/lib/utils';
|
||||
import { useUIStore, RIGHT_SIDEBAR_MIN_WIDTH, RIGHT_SIDEBAR_MAX_WIDTH } from '@/stores/useUIStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const RIGHT_SIDEBAR_CONTENT_WIDTH = 420;
|
||||
const RIGHT_SIDEBAR_CONTENT_WIDTH = 420;
|
||||
|
||||
interface RightSidebarProps {
|
||||
isOpen: boolean;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
export const SIDEBAR_CONTENT_WIDTH = 280;
|
||||
const SIDEBAR_CONTENT_WIDTH = 280;
|
||||
const SIDEBAR_MIN_WIDTH = 280;
|
||||
const SIDEBAR_MAX_WIDTH = 500;
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { formatTimeForPreference } from '@/lib/timeFormat';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
import type { Session, UsageWindow } from '@/types';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { UsageWindow } from '@/types';
|
||||
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
||||
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDis
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ModelMetadata } from '@/types';
|
||||
|
||||
export type ProviderModel = Record<string, unknown> & { id?: string; name?: string };
|
||||
type ProviderModel = Record<string, unknown> & { id?: string; name?: string };
|
||||
|
||||
export type ModelPickerProvider = {
|
||||
id: string;
|
||||
@@ -34,7 +34,7 @@ export type ModelPickerEntry = {
|
||||
modelID: string;
|
||||
};
|
||||
|
||||
export type ModelPickerFavoriteEntry = ModelPickerEntry;
|
||||
type ModelPickerFavoriteEntry = ModelPickerEntry;
|
||||
|
||||
type HiddenModel = { providerID: string; modelID: string };
|
||||
|
||||
|
||||
@@ -17,12 +17,6 @@ import { useI18n } from '@/lib/i18n';
|
||||
/** localStorage key matching NewWorktreeDialog */
|
||||
const LAST_SOURCE_BRANCH_KEY = 'oc:lastWorktreeSourceBranch';
|
||||
|
||||
export type WorktreeBaseOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
group: 'special' | 'local' | 'remote';
|
||||
};
|
||||
|
||||
export interface BranchSelectorProps {
|
||||
/** Current directory to check for git repository */
|
||||
directory: string | null;
|
||||
|
||||
@@ -22,14 +22,6 @@ export interface ModelSelectionWithId {
|
||||
instanceId: string;
|
||||
}
|
||||
|
||||
/** Model selection without instanceId (for external use) */
|
||||
export interface ModelSelection {
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
displayName?: string;
|
||||
variant?: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- Utility is tightly coupled with ModelMultiSelect
|
||||
export const generateInstanceId = (): string => {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
@@ -39,7 +31,7 @@ export const generateInstanceId = (): string => {
|
||||
* Model selection chip with remove button.
|
||||
* Shows instance index (e.g., "(2)") when same model is selected multiple times.
|
||||
*/
|
||||
export const ModelChip: React.FC<{
|
||||
const ModelChip: React.FC<{
|
||||
model: ModelSelectionWithId;
|
||||
instanceIndex: number;
|
||||
totalSameModel: number;
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
export { MultiRunLauncher } from './MultiRunLauncher';
|
||||
export { ModelMultiSelect, ModelChip, generateInstanceId, type ModelSelectionWithId, type ModelSelection, type ModelMultiSelectProps } from './ModelMultiSelect';
|
||||
export { BranchSelector, useBranchOptions, type BranchSelectorProps, type BranchSelectorState, type WorktreeBaseOption } from './BranchSelector';
|
||||
export { AgentSelector, type AgentSelectorProps } from './AgentSelector';
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import React from 'react';
|
||||
import { SIDEBAR_SECTION_CONFIG_MAP, SIDEBAR_SECTION_DESCRIPTIONS } from '@/constants/sidebar';
|
||||
import type { SidebarSection } from '@/constants/sidebar';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { McpIcon } from '@/components/icons/McpIcon';
|
||||
|
||||
interface SectionPlaceholderProps {
|
||||
sectionId: SidebarSection;
|
||||
variant: 'sidebar' | 'page';
|
||||
}
|
||||
|
||||
export const SectionPlaceholder: React.FC<SectionPlaceholderProps> = ({ sectionId, variant }) => {
|
||||
const { t } = useI18n();
|
||||
const config = SIDEBAR_SECTION_CONFIG_MAP[sectionId];
|
||||
const icon = config.icon;
|
||||
|
||||
if (variant === 'sidebar') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<div className="rounded-full bg-accent/40 p-3 text-muted-foreground">
|
||||
{icon === 'mcp-custom' ? <McpIcon className="h-5 w-5" /> : <Icon name={icon} className="h-5 w-5" />}
|
||||
</div>
|
||||
<h3 className="typography-ui-label font-semibold text-foreground">{config.label}</h3>
|
||||
<p className="typography-meta max-w-xs text-muted-foreground">
|
||||
{SIDEBAR_SECTION_DESCRIPTIONS[sectionId]}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<div className="rounded-full bg-accent/40 p-4 text-muted-foreground">
|
||||
{icon === 'mcp-custom' ? <McpIcon className="h-8 w-8" /> : <Icon name={icon} className="h-8 w-8" />}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="typography-h2 font-semibold text-foreground">{config.label}</h2>
|
||||
<p className="typography-body max-w-md text-muted-foreground">
|
||||
{SIDEBAR_SECTION_DESCRIPTIONS[sectionId]}
|
||||
</p>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground/60">{t('settings.common.state.comingSoon')}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -384,5 +384,5 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
};
|
||||
|
||||
// Re-export for easy sidebar icon usage
|
||||
export { McpIcon } from '@/components/icons/McpIcon';
|
||||
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface ImportedMcpResult {
|
||||
readonly enabled: boolean;
|
||||
}
|
||||
|
||||
export type ImportedMcpError =
|
||||
type ImportedMcpError =
|
||||
| { readonly ok: false; readonly error: string }
|
||||
| { readonly ok: false; readonly error: string; readonly parsed: unknown };
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export const parseMcpOAuthCallbackStateKey = (params: URLSearchParams): string |
|
||||
return trimmed || null;
|
||||
};
|
||||
|
||||
export const parseMcpOAuthState = (raw: string | null | undefined): {
|
||||
const parseMcpOAuthState = (raw: string | null | undefined): {
|
||||
name: string;
|
||||
directory: string | null;
|
||||
} | null => {
|
||||
|
||||
@@ -234,7 +234,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
|
||||
return mode === 'markdown' ? 'markdown' : 'plain';
|
||||
};
|
||||
|
||||
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
|
||||
type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
|
||||
|
||||
interface OpenChamberVisualSettingsProps {
|
||||
/** Which settings to show. If undefined, shows all. */
|
||||
|
||||
@@ -300,5 +300,3 @@ export const AddPluginDialog: React.FC<AddPluginDialogProps> = ({
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddPluginDialog;
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { PluginsSidebar } from './PluginsSidebar';
|
||||
export { PluginsPage } from './PluginsPage';
|
||||
export { AddPluginDialog } from './AddPluginDialog';
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SettingsSidebarLayout } from '@/components/sections/shared/SettingsSidebarLayout';
|
||||
import { SettingsSidebarItem } from '@/components/sections/shared/SettingsSidebarItem';
|
||||
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { DesktopSshInstance } from '@/lib/desktopSsh';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type RemoteInstancesSidebarProps = {
|
||||
onItemSelect?: () => void;
|
||||
};
|
||||
|
||||
const makeId = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `ssh-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
};
|
||||
|
||||
const DIRECT_INSTANCES_ID = '__direct_instances__';
|
||||
|
||||
const randomPort = (): number => {
|
||||
return Math.floor(20000 + Math.random() * 30000);
|
||||
};
|
||||
|
||||
const isPortInUseError = (error: unknown): boolean => {
|
||||
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
||||
return message.includes('address already in use') || message.includes('eaddrinuse') || message.includes('port already in use');
|
||||
};
|
||||
|
||||
const phaseLabelKey = (phase?: string) => {
|
||||
switch (phase) {
|
||||
case 'ready':
|
||||
return 'settings.remoteInstances.sidebar.phase.ready';
|
||||
case 'error':
|
||||
return 'settings.remoteInstances.sidebar.phase.error';
|
||||
case 'degraded':
|
||||
return 'settings.remoteInstances.sidebar.phase.reconnect';
|
||||
case 'installing':
|
||||
return 'settings.remoteInstances.sidebar.phase.installing';
|
||||
case 'updating':
|
||||
return 'settings.remoteInstances.sidebar.phase.updating';
|
||||
case 'forwarding':
|
||||
return 'settings.remoteInstances.sidebar.phase.forwarding';
|
||||
case 'server_starting':
|
||||
return 'settings.remoteInstances.sidebar.phase.starting';
|
||||
case 'master_connecting':
|
||||
return 'settings.remoteInstances.sidebar.phase.connecting';
|
||||
default:
|
||||
return 'settings.remoteInstances.sidebar.phase.idle';
|
||||
}
|
||||
};
|
||||
|
||||
export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const instances = useDesktopSshStore((state) => state.instances);
|
||||
const statusesById = useDesktopSshStore((state) => state.statusesById);
|
||||
const isLoading = useDesktopSshStore((state) => state.isLoading);
|
||||
const load = useDesktopSshStore((state) => state.load);
|
||||
const loadImports = useDesktopSshStore((state) => state.loadImports);
|
||||
const createFromCommand = useDesktopSshStore((state) => state.createFromCommand);
|
||||
const connect = useDesktopSshStore((state) => state.connect);
|
||||
const disconnect = useDesktopSshStore((state) => state.disconnect);
|
||||
const retry = useDesktopSshStore((state) => state.retry);
|
||||
const removeInstance = useDesktopSshStore((state) => state.removeInstance);
|
||||
const upsertInstance = useDesktopSshStore((state) => state.upsertInstance);
|
||||
|
||||
const selectedId = useUIStore((state) => state.settingsRemoteInstancesSelectedId);
|
||||
const setSelectedId = useUIStore((state) => state.setSettingsRemoteInstancesSelectedId);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
void loadImports();
|
||||
}, [load, loadImports]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (selectedId === DIRECT_INSTANCES_ID) {
|
||||
return;
|
||||
}
|
||||
if (instances.length === 0) {
|
||||
if (selectedId !== null) {
|
||||
setSelectedId(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (selectedId && instances.some((instance) => instance.id === selectedId)) {
|
||||
return;
|
||||
}
|
||||
setSelectedId(instances[0].id);
|
||||
}, [instances, isLoading, selectedId, setSelectedId]);
|
||||
|
||||
const handleAdd = React.useCallback(async () => {
|
||||
const id = makeId();
|
||||
try {
|
||||
await createFromCommand(id, 'ssh user@example.com', t('settings.remoteInstances.sidebar.newSshInstanceName'));
|
||||
setSelectedId(id);
|
||||
onItemSelect?.();
|
||||
} catch (error) {
|
||||
toast.error(t('settings.remoteInstances.sidebar.toast.createFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}, [createFromCommand, onItemSelect, setSelectedId, t]);
|
||||
|
||||
const connectWithPortRecovery = React.useCallback(async (instance: DesktopSshInstance) => {
|
||||
try {
|
||||
await connect(instance.id);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isPortInUseError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const allow = window.confirm(t('settings.remoteInstances.sidebar.confirm.localPortInUseRetry'));
|
||||
if (!allow) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const nextInstance: DesktopSshInstance = {
|
||||
...instance,
|
||||
localForward: {
|
||||
...instance.localForward,
|
||||
preferredLocalPort: randomPort(),
|
||||
},
|
||||
};
|
||||
|
||||
await upsertInstance(nextInstance);
|
||||
await connect(nextInstance.id);
|
||||
toast.success(t('settings.remoteInstances.sidebar.toast.retriedWithRandomPort'));
|
||||
}
|
||||
}, [connect, t, upsertInstance]);
|
||||
|
||||
return (
|
||||
<SettingsSidebarLayout
|
||||
variant="background"
|
||||
header={
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.remoteInstances.sidebar.title')}</h2>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.remoteInstances.sidebar.total', { count: instances.length })}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 -my-1 text-muted-foreground"
|
||||
onClick={() => void handleAdd()}
|
||||
aria-label={t('settings.remoteInstances.sidebar.actions.addSshInstance')}
|
||||
>
|
||||
<Icon name="add" className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SettingsSidebarItem
|
||||
title={t('settings.remoteInstances.direct.sidebarTitle')}
|
||||
metadata={t('settings.remoteInstances.direct.sidebarDescription')}
|
||||
selected={selectedId === DIRECT_INSTANCES_ID || (!selectedId && instances.length === 0)}
|
||||
onSelect={() => {
|
||||
setSelectedId(DIRECT_INSTANCES_ID);
|
||||
onItemSelect?.();
|
||||
}}
|
||||
icon={<Icon name="global" className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
{instances.map((instance) => {
|
||||
const status = statusesById[instance.id];
|
||||
const selected = instance.id === selectedId;
|
||||
const title = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id;
|
||||
const metadata = `${t(phaseLabelKey(status?.phase))}${status?.localUrl ? ` · ${status.localUrl}` : ''}`;
|
||||
const isReady = status?.phase === 'ready';
|
||||
const canRetry = status?.phase === 'error' || status?.phase === 'degraded';
|
||||
|
||||
return (
|
||||
<SettingsSidebarItem
|
||||
key={instance.id}
|
||||
title={title}
|
||||
metadata={metadata}
|
||||
selected={selected}
|
||||
onSelect={() => {
|
||||
setSelectedId(instance.id);
|
||||
onItemSelect?.();
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
label: isReady ? t('settings.remoteInstances.sidebar.actions.disconnect') : t('settings.remoteInstances.sidebar.actions.connect'),
|
||||
icon: isReady ? 'stop' : 'plug-2',
|
||||
onClick: () => {
|
||||
const op = isReady ? disconnect(instance.id) : connectWithPortRecovery(instance);
|
||||
void op.catch((error) => {
|
||||
toast.error(
|
||||
isReady
|
||||
? t('settings.remoteInstances.sidebar.toast.disconnectFailed')
|
||||
: t('settings.remoteInstances.sidebar.toast.connectFailed'),
|
||||
{
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('settings.remoteInstances.sidebar.actions.retry'),
|
||||
icon: "refresh",
|
||||
onClick: () => {
|
||||
if (!canRetry) return;
|
||||
void retry(instance.id).catch((error) => {
|
||||
toast.error(t('settings.remoteInstances.sidebar.toast.retryFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('settings.remoteInstances.sidebar.actions.remove'),
|
||||
icon: "delete-bin",
|
||||
destructive: true,
|
||||
onClick: () => {
|
||||
void removeInstance(instance.id).then(() => {
|
||||
if (selectedId === instance.id) {
|
||||
const next = instances.find((item) => item.id !== instance.id);
|
||||
setSelectedId(next?.id || null);
|
||||
}
|
||||
}).catch((error) => {
|
||||
toast.error(t('settings.remoteInstances.sidebar.toast.removeFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SettingsSidebarLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SettingsSectionProps {
|
||||
/** Section content */
|
||||
children: React.ReactNode;
|
||||
/** Optional section title */
|
||||
title?: string;
|
||||
/** Optional section description */
|
||||
description?: string;
|
||||
/** If true, adds a top border divider */
|
||||
divider?: boolean;
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard section wrapper for settings page content.
|
||||
* Provides consistent spacing and optional divider.
|
||||
*
|
||||
* @example
|
||||
* <SettingsSection title="Appearance" description="Customize the look and feel">
|
||||
* <ThemeSelector />
|
||||
* <FontSizeSelector />
|
||||
* </SettingsSection>
|
||||
*
|
||||
* <SettingsSection divider>
|
||||
* <DangerZoneSettings />
|
||||
* </SettingsSection>
|
||||
*/
|
||||
export const SettingsSection: React.FC<SettingsSectionProps> = ({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
divider = false,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
divider && 'border-t border-border/40 pt-6',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{(title || description) && (
|
||||
<div className="mb-4 space-y-1">
|
||||
{title && (
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{description && (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SettingsSidebarHeaderProps {
|
||||
/** Total count to display (e.g., "Total 5") */
|
||||
count: number;
|
||||
/** Callback when add button is clicked. If undefined, no add button is shown. */
|
||||
onAdd?: () => void;
|
||||
/** Custom label prefix (default: "Total") */
|
||||
label?: string;
|
||||
/** Aria label for the add button */
|
||||
addButtonLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard header for settings sidebars.
|
||||
* Displays "Total X" on the left and an optional add button on the right.
|
||||
*
|
||||
* @example
|
||||
* <SettingsSidebarHeader
|
||||
* count={agents.length}
|
||||
* onAdd={() => setCreateDialogOpen(true)}
|
||||
* addButtonLabel="Create new agent"
|
||||
* />
|
||||
*/
|
||||
export const SettingsSidebarHeader: React.FC<SettingsSidebarHeaderProps> = ({
|
||||
count,
|
||||
onAdd,
|
||||
label = 'Total',
|
||||
addButtonLabel = 'Add new item',
|
||||
}) => {
|
||||
const { isMobile } = useDeviceInfo();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-b px-3',
|
||||
isMobile ? 'mt-2 py-3' : 'py-3'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{label} {count}
|
||||
</span>
|
||||
{onAdd && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 -my-1 text-muted-foreground"
|
||||
onClick={onAdd}
|
||||
aria-label={addButtonLabel}
|
||||
>
|
||||
<Icon name="add" className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SettingsSidebarItemAction {
|
||||
interface SettingsSidebarItemAction {
|
||||
/** Label shown in dropdown menu */
|
||||
label: string;
|
||||
/** Icon component to show before label */
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Shared boilerplate components for settings sections.
|
||||
*
|
||||
* These components provide consistent styling and behavior for settings sidebars and pages.
|
||||
* Use them as building blocks when creating new settings sections.
|
||||
*
|
||||
* @example Sidebar usage:
|
||||
* ```tsx
|
||||
* import {
|
||||
* SettingsSidebarLayout,
|
||||
* SettingsSidebarHeader,
|
||||
* SettingsSidebarItem,
|
||||
* } from '@/components/sections/shared';
|
||||
*
|
||||
* export const MySidebar = () => (
|
||||
* <SettingsSidebarLayout
|
||||
* header={<SettingsSidebarHeader count={items.length} onAdd={handleAdd} />}
|
||||
* >
|
||||
* {items.map(item => (
|
||||
* <SettingsSidebarItem
|
||||
* key={item.id}
|
||||
* title={item.name}
|
||||
* metadata={item.description}
|
||||
* selected={selectedId === item.id}
|
||||
* onSelect={() => setSelectedId(item.id)}
|
||||
* actions={[
|
||||
* { label: 'Delete', onClick: () => handleDelete(item.id), destructive: true }
|
||||
* ]}
|
||||
* />
|
||||
* ))}
|
||||
* </SettingsSidebarLayout>
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @example Page usage:
|
||||
* ```tsx
|
||||
* import { SettingsPageLayout, SettingsSection } from '@/components/sections/shared';
|
||||
*
|
||||
* export const MyPage = () => (
|
||||
* <SettingsPageLayout>
|
||||
* <SettingsSection title="General Settings">
|
||||
* <MySettingsForm />
|
||||
* </SettingsSection>
|
||||
* <SettingsSection title="Advanced" divider>
|
||||
* <AdvancedSettingsForm />
|
||||
* </SettingsSection>
|
||||
* </SettingsPageLayout>
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { SettingsSidebarLayout } from './SettingsSidebarLayout';
|
||||
export { SettingsSidebarHeader } from './SettingsSidebarHeader';
|
||||
export { SettingsSidebarItem, type SettingsSidebarItemAction } from './SettingsSidebarItem';
|
||||
export { SettingsPageLayout } from './SettingsPageLayout';
|
||||
export { SettingsSection } from './SettingsSection';
|
||||
export { SidebarGroup } from './SidebarGroup';
|
||||
@@ -1,597 +0,0 @@
|
||||
import React from 'react';
|
||||
import { toast } from '@/components/ui';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
locationPartsFrom,
|
||||
locationValueFrom,
|
||||
type SkillLocationValue,
|
||||
} from '../skillLocations';
|
||||
|
||||
interface InstallFromRepoDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type IdentityOption = { id: string; name: string };
|
||||
|
||||
export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ open, onOpenChange }) => {
|
||||
const { t } = useI18n();
|
||||
const scanRepo = useSkillsCatalogStore((s) => s.scanRepo);
|
||||
const installSkills = useSkillsCatalogStore((s) => s.installSkills);
|
||||
const isScanning = useSkillsCatalogStore((s) => s.isScanning);
|
||||
const isInstalling = useSkillsCatalogStore((s) => s.isInstalling);
|
||||
const installedSkills = useSkillsStore((s) => s.skills);
|
||||
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
|
||||
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
|
||||
|
||||
const projects = useProjectsStore((s) => s.projects);
|
||||
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
|
||||
const [targetProjectId, setTargetProjectId] = React.useState<string | null>(null);
|
||||
|
||||
const [source, setSource] = React.useState('');
|
||||
const [subpath, setSubpath] = React.useState('');
|
||||
const [scope, setScope] = React.useState<'user' | 'project'>('user');
|
||||
const [targetSource, setTargetSource] = React.useState<'opencode' | 'agents'>('opencode');
|
||||
|
||||
const [items, setItems] = React.useState<SkillsCatalogItem[]>([]);
|
||||
const [selected, setSelected] = React.useState<Record<string, boolean>>({});
|
||||
const [search, setSearch] = React.useState('');
|
||||
|
||||
const [identities, setIdentities] = React.useState<IdentityOption[]>([]);
|
||||
const [gitIdentityId, setGitIdentityId] = React.useState<string | null>(null);
|
||||
const scanRequestIdRef = React.useRef(0);
|
||||
|
||||
const invalidateScan = React.useCallback((options?: { clearIdentities?: boolean }) => {
|
||||
scanRequestIdRef.current += 1;
|
||||
setItems([]);
|
||||
setSelected({});
|
||||
if (options?.clearIdentities) {
|
||||
setIdentities([]);
|
||||
setGitIdentityId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [conflictsOpen, setConflictsOpen] = React.useState(false);
|
||||
const [conflicts, setConflicts] = React.useState<SkillConflict[]>([]);
|
||||
const [baseInstallRequest, setBaseInstallRequest] = React.useState<{
|
||||
source: string;
|
||||
subpath?: string;
|
||||
scope: 'user' | 'project';
|
||||
targetSource: 'opencode' | 'agents';
|
||||
selections: Array<{ skillDir: string }>;
|
||||
gitIdentityId?: string;
|
||||
directoryOverride?: string | null;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
scanRequestIdRef.current += 1;
|
||||
if (!open) return;
|
||||
setSource('');
|
||||
setSubpath('');
|
||||
setScope('user');
|
||||
setTargetSource('opencode');
|
||||
setTargetProjectId(activeProjectId);
|
||||
setItems([]);
|
||||
setSelected({});
|
||||
setSearch('');
|
||||
setIdentities([]);
|
||||
setGitIdentityId(null);
|
||||
void loadDefaultGitIdentityId();
|
||||
|
||||
setConflictsOpen(false);
|
||||
|
||||
setConflicts([]);
|
||||
setBaseInstallRequest(null);
|
||||
}, [open, loadDefaultGitIdentityId, activeProjectId]);
|
||||
|
||||
const resolvedTargetProjectId = React.useMemo(() => {
|
||||
if (projects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (targetProjectId && projects.some((p) => p.id === targetProjectId)) {
|
||||
return targetProjectId;
|
||||
}
|
||||
if (activeProjectId && projects.some((p) => p.id === activeProjectId)) {
|
||||
return activeProjectId;
|
||||
}
|
||||
return projects[0]?.id ?? null;
|
||||
}, [activeProjectId, projects, targetProjectId]);
|
||||
|
||||
const directoryOverride = React.useMemo(() => {
|
||||
if (scope !== 'project') {
|
||||
return null;
|
||||
}
|
||||
const id = resolvedTargetProjectId;
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const project = projects.find((p) => p.id === id);
|
||||
return project?.path ?? null;
|
||||
}, [projects, resolvedTargetProjectId, scope]);
|
||||
|
||||
const installedByName = React.useMemo(() => {
|
||||
const map = new Map<string, { scope: 'user' | 'project'; source: 'opencode' | 'claude' | 'agents' }>();
|
||||
for (const s of installedSkills) {
|
||||
map.set(s.name, { scope: s.scope, source: s.source });
|
||||
}
|
||||
return map;
|
||||
}, [installedSkills]);
|
||||
|
||||
const filteredItems = React.useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter((item) => {
|
||||
const name = item.skillName.toLowerCase();
|
||||
const desc = (item.description || '').toLowerCase();
|
||||
const fm = (item.frontmatterName || '').toLowerCase();
|
||||
return name.includes(q) || desc.includes(q) || fm.includes(q);
|
||||
});
|
||||
}, [items, search]);
|
||||
|
||||
const selectedDirs = React.useMemo(() => Object.keys(selected).filter((k) => selected[k]), [selected]);
|
||||
|
||||
const toggleAll = (value: boolean) => {
|
||||
const next: Record<string, boolean> = {};
|
||||
for (const item of items) {
|
||||
if (!item.installable) continue;
|
||||
next[item.skillDir] = value;
|
||||
}
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const locationLabelText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.label');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.label');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.label');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.label');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const locationDescriptionText = React.useCallback((value: SkillLocationValue) => {
|
||||
switch (value) {
|
||||
case 'project-opencode':
|
||||
return t('settings.skills.location.option.projectOpencode.description');
|
||||
case 'user-agents':
|
||||
return t('settings.skills.location.option.userAgents.description');
|
||||
case 'project-agents':
|
||||
return t('settings.skills.location.option.projectAgents.description');
|
||||
default:
|
||||
return t('settings.skills.location.option.userOpencode.description');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleScan = async () => {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed) {
|
||||
toast.error(t('settings.skills.catalog.shared.toast.repositoryRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setItems([]);
|
||||
setSelected({});
|
||||
const requestId = scanRequestIdRef.current + 1;
|
||||
scanRequestIdRef.current = requestId;
|
||||
|
||||
const result = await scanRepo({
|
||||
source: trimmed,
|
||||
subpath: subpath.trim() || undefined,
|
||||
gitIdentityId: gitIdentityId || undefined,
|
||||
});
|
||||
|
||||
if (scanRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
if (isVSCodeRuntime()) {
|
||||
toast.error(t('settings.skills.catalog.shared.toast.privateRepoNotSupportedVsCode'));
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = (result.error.identities || []) as IdentityOption[];
|
||||
setIdentities(ids);
|
||||
if (!gitIdentityId && ids.length > 0) {
|
||||
const preferred =
|
||||
defaultGitIdentityId &&
|
||||
defaultGitIdentityId !== 'global' &&
|
||||
ids.some((i) => i.id === defaultGitIdentityId)
|
||||
? defaultGitIdentityId
|
||||
: ids[0].id;
|
||||
setGitIdentityId(preferred);
|
||||
}
|
||||
toast.error(t('settings.skills.catalog.installFromRepo.toast.authenticationRequiredScan'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || t('settings.skills.catalog.installFromRepo.toast.scanFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const nextItems = result.items || [];
|
||||
setItems(nextItems);
|
||||
|
||||
// Auto-select all installable items when scanning returns a small set.
|
||||
const nextSelected: Record<string, boolean> = {};
|
||||
for (const item of nextItems) {
|
||||
if (item.installable) {
|
||||
nextSelected[item.skillDir] = true;
|
||||
}
|
||||
}
|
||||
setSelected(nextSelected);
|
||||
|
||||
setIdentities([]);
|
||||
toast.success(t('settings.skills.catalog.shared.toast.foundSkills', { count: nextItems.length }));
|
||||
};
|
||||
|
||||
const doInstall = async (opts: { conflictDecisions?: Record<string, ConflictDecision> }) => {
|
||||
if (selectedDirs.length === 0) {
|
||||
toast.error(t('settings.skills.catalog.installFromRepo.toast.selectAtLeastOne'));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = {
|
||||
source: source.trim(),
|
||||
subpath: subpath.trim() || undefined,
|
||||
scope,
|
||||
targetSource,
|
||||
selections: selectedDirs.map((dir) => ({ skillDir: dir })),
|
||||
gitIdentityId: gitIdentityId || undefined,
|
||||
directoryOverride,
|
||||
};
|
||||
|
||||
const result = await installSkills(
|
||||
{
|
||||
source: request.source,
|
||||
subpath: request.subpath,
|
||||
scope: request.scope,
|
||||
targetSource: request.targetSource,
|
||||
selections: request.selections,
|
||||
gitIdentityId: request.gitIdentityId,
|
||||
conflictPolicy: 'prompt',
|
||||
conflictDecisions: opts.conflictDecisions,
|
||||
},
|
||||
{ directory: request.directoryOverride ?? null }
|
||||
);
|
||||
|
||||
if (result.ok) {
|
||||
const installedCount = result.installed?.length || 0;
|
||||
toast.success(
|
||||
installedCount > 0
|
||||
? t('settings.skills.catalog.installFromRepo.toast.installedCount', { count: installedCount })
|
||||
: t('settings.skills.catalog.installFromRepo.toast.installCompleted')
|
||||
);
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error?.kind === 'conflicts') {
|
||||
setBaseInstallRequest(request);
|
||||
setConflicts(result.error.conflicts);
|
||||
setConflictsOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
if (isVSCodeRuntime()) {
|
||||
toast.error(t('settings.skills.catalog.shared.toast.privateRepoNotSupportedVsCode'));
|
||||
return;
|
||||
}
|
||||
const ids = (result.error.identities || []) as IdentityOption[];
|
||||
setIdentities(ids);
|
||||
if (!gitIdentityId && ids.length > 0) {
|
||||
const preferred =
|
||||
defaultGitIdentityId &&
|
||||
defaultGitIdentityId !== 'global' &&
|
||||
ids.some((i) => i.id === defaultGitIdentityId)
|
||||
? defaultGitIdentityId
|
||||
: ids[0].id;
|
||||
setGitIdentityId(preferred);
|
||||
}
|
||||
toast.error(t('settings.skills.catalog.installFromRepo.toast.authenticationRequiredInstall'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || t('settings.skills.catalog.installFromRepo.toast.installFailed'));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{t('settings.skills.catalog.installFromRepo.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('settings.skills.catalog.installFromRepo.descriptionPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono">SKILL.md</code>
|
||||
{t('settings.skills.catalog.installFromRepo.descriptionSuffix')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 flex-shrink-0">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.repository')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
setSource(e.target.value);
|
||||
invalidateScan({ clearIdentities: true });
|
||||
}}
|
||||
placeholder={t('settings.skills.catalog.shared.field.repositoryPlaceholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void handleScan()}
|
||||
disabled={isScanning || !source.trim()}
|
||||
className="gap-2"
|
||||
>
|
||||
<Icon name="git-repository" className="h-4 w-4" />
|
||||
{isScanning ? t('settings.skills.catalog.shared.actions.scanning') : t('settings.skills.catalog.shared.actions.scan')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('settings.skills.catalog.installFromRepo.repositoryHintPrefix')}
|
||||
{' '}
|
||||
<code className="font-mono">owner/repo/skills</code>
|
||||
{'.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.optionalSubpath')}</label>
|
||||
<Input
|
||||
value={subpath}
|
||||
onChange={(e) => {
|
||||
setSubpath(e.target.value);
|
||||
invalidateScan({ clearIdentities: true });
|
||||
}}
|
||||
placeholder={t('settings.skills.catalog.shared.field.subpathPlaceholder')}
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.targetLocation')}</label>
|
||||
<Select
|
||||
value={locationValueFrom(scope, targetSource)}
|
||||
onValueChange={(v) => {
|
||||
const next = locationPartsFrom(v as SkillLocationValue);
|
||||
setScope(next.scope);
|
||||
setTargetSource(next.source === 'agents' ? 'agents' : 'opencode');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="lg" className="w-full gap-1.5">
|
||||
{scope === 'user' ? <Icon name="user-3" className="h-4 w-4" /> : <Icon name="folder" className="h-4 w-4" />}
|
||||
{targetSource === 'agents' ? <Icon name="robot-2" className="h-4 w-4" /> : null}
|
||||
<span>{locationLabelText(locationValueFrom(scope, targetSource))}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <Icon name="user-3" className="h-4 w-4" /> : <Icon name="folder" className="h-4 w-4" />}
|
||||
{option.source === 'agents' ? <Icon name="robot-2" className="h-4 w-4" /> : null}
|
||||
<span>{locationLabelText(option.value)}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{locationDescriptionText(option.value)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scope === 'project' && (
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.field.project')}</label>
|
||||
{projects.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.skills.catalog.shared.field.noProjects')}</p>
|
||||
) : (
|
||||
<Select
|
||||
value={resolvedTargetProjectId ?? ''}
|
||||
onValueChange={(v) => setTargetProjectId(v)}
|
||||
disabled={projects.length === 1}
|
||||
>
|
||||
<SelectTrigger size="lg" className="w-full justify-between">
|
||||
<SelectValue placeholder={t('settings.skills.catalog.shared.field.chooseProjectPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{projects.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id} className="pr-2 [&>span:first-child]:hidden">
|
||||
{p.label || p.path}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{identities.length > 0 && !isVSCodeRuntime() ? (
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="typography-ui-label font-medium text-foreground">{t('settings.skills.catalog.shared.auth.title')}</div>
|
||||
<div className="typography-meta text-muted-foreground mt-1">
|
||||
{t('settings.skills.catalog.installFromRepo.authDescription')}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Select
|
||||
value={gitIdentityId || ''}
|
||||
onValueChange={(v) => {
|
||||
setGitIdentityId(v);
|
||||
invalidateScan();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="lg" className="w-full justify-between">
|
||||
<span>{identities.find((i) => i.id === gitIdentityId)?.name || t('settings.skills.catalog.shared.auth.chooseIdentity')}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{identities.map((id) => (
|
||||
<SelectItem key={id.id} value={id.id} className="pr-2 [&>span:first-child]:hidden">
|
||||
{id.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground mt-2">
|
||||
{t('settings.skills.catalog.shared.auth.footerHintArrow')}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-center text-muted-foreground">
|
||||
<div>
|
||||
<p className="typography-body">{t('settings.skills.catalog.installFromRepo.empty.noScanResultsTitle')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.installFromRepo.empty.noScanResultsDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('settings.skills.catalog.shared.field.searchSkillsPlaceholder')}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => toggleAll(true)}>{t('settings.skills.catalog.installFromRepo.actions.selectAll')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => toggleAll(false)}>{t('settings.skills.catalog.installFromRepo.actions.selectNone')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-2">
|
||||
{filteredItems.map((item) => {
|
||||
const installed = installedByName.get(item.skillName);
|
||||
const checked = Boolean(selected[item.skillDir]);
|
||||
const disabled = !item.installable;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={item.skillDir}
|
||||
className={
|
||||
'flex items-start gap-3 rounded-lg border bg-muted/10 px-3 py-2 cursor-pointer transition-colors ' +
|
||||
(disabled ? 'opacity-60 cursor-not-allowed' : 'hover:bg-interactive-hover/20')
|
||||
}
|
||||
>
|
||||
<div className="mt-1">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(newChecked) => setSelected((prev) => ({ ...prev, [item.skillDir]: newChecked }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="typography-ui-label truncate">{item.skillName}</div>
|
||||
{installed ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
{t('settings.skills.catalog.installFromRepo.badge.installed', {
|
||||
scope: installed.scope,
|
||||
source: installed.source,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{item.description ? (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
|
||||
) : (
|
||||
<div className="typography-micro text-muted-foreground mt-0.5">{t('settings.skills.catalog.shared.noDescription')}</div>
|
||||
)}
|
||||
{item.warnings?.length ? (
|
||||
<div className="typography-micro text-muted-foreground mt-1">
|
||||
{item.warnings.join(' · ')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</ScrollableOverlay>
|
||||
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
{t('settings.skills.catalog.installFromRepo.selectedCount', {
|
||||
selected: selectedDirs.length,
|
||||
total: items.filter((i) => i.installable).length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isInstalling || selectedDirs.length === 0 || !source.trim() || (scope === 'project' && !directoryOverride)}
|
||||
onClick={() => void doInstall({})}
|
||||
>
|
||||
{isInstalling ? t('settings.skills.catalog.shared.actions.installing') : t('settings.skills.catalog.installFromRepo.actions.installSelected')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<InstallConflictsDialog
|
||||
open={conflictsOpen}
|
||||
onOpenChange={setConflictsOpen}
|
||||
conflicts={conflicts}
|
||||
onConfirm={(decisions) => {
|
||||
if (!baseInstallRequest) {
|
||||
setConflictsOpen(false);
|
||||
return;
|
||||
}
|
||||
void doInstall({ conflictDecisions: decisions });
|
||||
setConflictsOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { SkillsSidebar } from './SkillsSidebar';
|
||||
export { SkillsPage } from './SkillsPage';
|
||||
@@ -57,10 +57,3 @@ export function locationPartsFrom(value: SkillLocationValue): { scope: SkillScop
|
||||
}
|
||||
return { scope: match.scope, source: match.source };
|
||||
}
|
||||
|
||||
export function locationLabel(scope: SkillScope, source: SkillSource): string {
|
||||
if (scope === 'user' && source === 'claude') return 'User / Claude';
|
||||
if (scope === 'project' && source === 'claude') return 'Project / Claude';
|
||||
const match = SKILL_LOCATION_OPTIONS.find((option) => option.scope === scope && option.source === source);
|
||||
return match?.label || `${scope} / ${source}`;
|
||||
}
|
||||
|
||||
@@ -1,575 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { deleteGitBranch, getGitBranches, git, renameBranch } from '@/lib/gitApi';
|
||||
import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { createWorktreeWithDefaults } from '@/lib/worktrees/worktreeCreate';
|
||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { useSessions } from '@/sync/sync-context';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export interface BranchPickerProject {
|
||||
id: string;
|
||||
path: string;
|
||||
normalizedPath: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface BranchPickerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
project: BranchPickerProject | null;
|
||||
}
|
||||
|
||||
const displayProjectName = (project: BranchPickerProject): string =>
|
||||
project.label || project.normalizedPath.split('/').pop() || project.normalizedPath;
|
||||
|
||||
const normalizeBranchName = (value: string | null | undefined): string => {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^refs\/heads\//, '')
|
||||
.replace(/^heads\//, '')
|
||||
.replace(/^remotes\//, '');
|
||||
};
|
||||
|
||||
const normalizePath = (value: string | null | undefined): string => {
|
||||
const raw = String(value || '').trim().replace(/\\/g, '/');
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (raw === '/') {
|
||||
return '/';
|
||||
}
|
||||
return raw.length > 1 ? raw.replace(/\/+$/, '') : raw;
|
||||
};
|
||||
|
||||
export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const sessions = useSessions();
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [branches, setBranches] = React.useState<GitBranch | null>(null);
|
||||
const [worktrees, setWorktrees] = React.useState<GitWorktreeInfo[]>([]);
|
||||
const [rootBranchName, setRootBranchName] = React.useState<string | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [creatingWorktreeBranch, setCreatingWorktreeBranch] = React.useState<string | null>(null);
|
||||
const [deletingBranch, setDeletingBranch] = React.useState<string | null>(null);
|
||||
const [confirmingDelete, setConfirmingDelete] = React.useState<string | null>(null);
|
||||
const [forceDeleteBranch, setForceDeleteBranch] = React.useState<string | null>(null);
|
||||
const [editingBranch, setEditingBranch] = React.useState<string | null>(null);
|
||||
const [editValue, setEditValue] = React.useState('');
|
||||
const [renamingBranchKey, setRenamingBranchKey] = React.useState<string | null>(null);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
if (!project) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [b, w, rootBranch] = await Promise.all([
|
||||
getGitBranches(project.path),
|
||||
git.worktree.list(project.path),
|
||||
getRootBranch(project.path).catch(() => null),
|
||||
]);
|
||||
setBranches(b);
|
||||
setWorktrees(w);
|
||||
setRootBranchName(rootBranch);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('branchPickerDialog.error.failedToLoad'));
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setRootBranchName(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [project, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setSearchQuery('');
|
||||
setConfirmingDelete(null);
|
||||
setForceDeleteBranch(null);
|
||||
setEditingBranch(null);
|
||||
setEditValue('');
|
||||
setRenamingBranchKey(null);
|
||||
setCreatingWorktreeBranch(null);
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
}, [open, refresh]);
|
||||
|
||||
const filterBranches = (list: string[], query: string): string[] => {
|
||||
if (!query.trim()) return list;
|
||||
const lower = query.toLowerCase();
|
||||
return list.filter((b) => b.toLowerCase().includes(lower));
|
||||
};
|
||||
|
||||
const beginRename = React.useCallback((branchName: string) => {
|
||||
setEditingBranch(branchName);
|
||||
setEditValue(branchName);
|
||||
}, []);
|
||||
|
||||
const cancelRename = React.useCallback(() => {
|
||||
setEditingBranch(null);
|
||||
setEditValue('');
|
||||
setRenamingBranchKey(null);
|
||||
}, []);
|
||||
|
||||
const cancelDelete = React.useCallback(() => {
|
||||
setConfirmingDelete(null);
|
||||
setForceDeleteBranch(null);
|
||||
}, []);
|
||||
|
||||
const commitRename = React.useCallback(async (oldName: string) => {
|
||||
if (!project) return;
|
||||
const newName = editValue.trim();
|
||||
if (!newName || newName === oldName) {
|
||||
cancelRename();
|
||||
return;
|
||||
}
|
||||
|
||||
setRenamingBranchKey(oldName);
|
||||
try {
|
||||
const result = await renameBranch(project.path, oldName, newName);
|
||||
if (!result?.success) {
|
||||
throw new Error(t('branchPickerDialog.error.renameRejected'));
|
||||
}
|
||||
await refresh();
|
||||
cancelRename();
|
||||
toast.success(t('branchPickerDialog.toast.branchRenamed'), { description: `${oldName} -> ${newName}` });
|
||||
} catch (err) {
|
||||
toast.error(t('branchPickerDialog.toast.failedToRenameBranch'), {
|
||||
description: err instanceof Error ? err.message : t('branchPickerDialog.error.renameFailed'),
|
||||
});
|
||||
setRenamingBranchKey(null);
|
||||
}
|
||||
}, [project, editValue, refresh, cancelRename, t]);
|
||||
|
||||
const handleDeleteBranch = React.useCallback(async (branchName: string) => {
|
||||
if (!project) return;
|
||||
setDeletingBranch(branchName);
|
||||
try {
|
||||
const force = forceDeleteBranch === branchName;
|
||||
const result = await deleteGitBranch(project.path, { branch: branchName, force });
|
||||
if (!result?.success) {
|
||||
throw new Error(t('branchPickerDialog.error.deleteRejected'));
|
||||
}
|
||||
await refresh();
|
||||
toast.success(t('branchPickerDialog.toast.branchDeleted'), { description: branchName });
|
||||
setConfirmingDelete(null);
|
||||
setForceDeleteBranch(null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : t('branchPickerDialog.error.deleteFailed');
|
||||
// If branch isn't merged, prompt for force delete on next confirm.
|
||||
if (/not fully merged/i.test(message) && forceDeleteBranch !== branchName) {
|
||||
setForceDeleteBranch(branchName);
|
||||
toast.error(t('branchPickerDialog.toast.branchNotMerged'), {
|
||||
description: t('branchPickerDialog.toast.confirmAgainToForceDelete'),
|
||||
});
|
||||
} else {
|
||||
toast.error(t('branchPickerDialog.toast.failedToDeleteBranch'), { description: message });
|
||||
}
|
||||
} finally {
|
||||
setDeletingBranch(null);
|
||||
}
|
||||
}, [project, refresh, forceDeleteBranch, t]);
|
||||
|
||||
const handleCreateWorktreeForBranch = React.useCallback(async (branchName: string) => {
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatingWorktreeBranch(branchName);
|
||||
try {
|
||||
const setupCommands = await getWorktreeSetupCommands({
|
||||
id: project.id,
|
||||
path: project.path,
|
||||
});
|
||||
await createWorktreeWithDefaults(
|
||||
{
|
||||
id: project.id,
|
||||
path: project.path,
|
||||
},
|
||||
{
|
||||
preferredName: branchName,
|
||||
mode: 'existing',
|
||||
existingBranch: branchName,
|
||||
branchName,
|
||||
worktreeName: branchName,
|
||||
setupCommands,
|
||||
}
|
||||
);
|
||||
await refresh();
|
||||
toast.success(t('branchPickerDialog.toast.worktreeCreated'), { description: branchName });
|
||||
} catch (err) {
|
||||
toast.error(t('branchPickerDialog.toast.failedToCreateWorktree'), {
|
||||
description: err instanceof Error ? err.message : t('branchPickerDialog.error.createWorktreeFailed'),
|
||||
});
|
||||
} finally {
|
||||
setCreatingWorktreeBranch(null);
|
||||
}
|
||||
}, [project, refresh, t]);
|
||||
|
||||
const handleRemoveWorktree = React.useCallback((worktree: GitWorktreeInfo | null) => {
|
||||
if (!project || !worktree) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedWorktreePath = normalizePath(worktree.path);
|
||||
const directSessions = sessions.filter((session) => {
|
||||
const sessionPath = normalizePath(session.directory ?? null);
|
||||
return Boolean(sessionPath) && sessionPath === normalizedWorktreePath;
|
||||
});
|
||||
const directSessionIds = new Set(directSessions.map((session) => session.id));
|
||||
|
||||
const findSubsessions = (parentIds: Set<string>): typeof sessions => {
|
||||
const subsessions = sessions.filter((session) => {
|
||||
const parentID = (session as { parentID?: string | null }).parentID;
|
||||
if (!parentID) {
|
||||
return false;
|
||||
}
|
||||
return parentIds.has(parentID);
|
||||
});
|
||||
if (subsessions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const subsessionIds = new Set(subsessions.map((session) => session.id));
|
||||
return [...subsessions, ...findSubsessions(subsessionIds)];
|
||||
};
|
||||
|
||||
const allSubsessions = findSubsessions(directSessionIds);
|
||||
const seenIds = new Set<string>();
|
||||
const allSessions = [...directSessions, ...allSubsessions].filter((session) => {
|
||||
if (seenIds.has(session.id)) {
|
||||
return false;
|
||||
}
|
||||
seenIds.add(session.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const normalizedBranch = normalizeBranchName(worktree.branch);
|
||||
const worktreeMetadata: WorktreeMetadata = {
|
||||
source: 'sdk',
|
||||
name: worktree.name,
|
||||
path: worktree.path,
|
||||
projectDirectory: project.path,
|
||||
branch: normalizedBranch,
|
||||
label: normalizedBranch || worktree.name,
|
||||
};
|
||||
|
||||
sessionEvents.requestDelete({
|
||||
sessions: allSessions,
|
||||
mode: 'worktree',
|
||||
worktree: worktreeMetadata,
|
||||
});
|
||||
}, [project, sessions]);
|
||||
|
||||
const worktreeByBranch = new Map<string, GitWorktreeInfo>();
|
||||
for (const worktree of worktrees) {
|
||||
const branchName = normalizeBranchName(worktree.branch);
|
||||
if (branchName && !worktreeByBranch.has(branchName)) {
|
||||
worktreeByBranch.set(branchName, worktree);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedRootBranch = normalizeBranchName(rootBranchName);
|
||||
const allBranches = branches?.all || [];
|
||||
const filteredBranches = filterBranches(allBranches, searchQuery);
|
||||
const localBranches = filteredBranches.filter((b) => !b.startsWith('remotes/'));
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col overflow-hidden gap-3">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Icon name="git-branch" className="h-5 w-5" />
|
||||
{t('branchPickerDialog.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{project ? t('branchPickerDialog.description.localBranchesForProject', { project: displayProjectName(project) }) : t('branchPickerDialog.description.selectProject')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative flex-shrink-0">
|
||||
<Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('branchPickerDialog.search.placeholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="space-y-1">
|
||||
{!project ? (
|
||||
<div className="text-center py-8 text-muted-foreground">{t('branchPickerDialog.state.noProjectSelected')}</div>
|
||||
) : loading ? (
|
||||
<div className="px-2 py-2 text-muted-foreground text-sm">{t('branchPickerDialog.state.loadingBranches')}</div>
|
||||
) : error ? (
|
||||
<div className="px-2 py-2 text-destructive text-sm">{error}</div>
|
||||
) : localBranches.length === 0 ? (
|
||||
<div className="px-2 py-2 text-muted-foreground text-sm">
|
||||
{searchQuery ? t('branchPickerDialog.state.noMatchingBranches') : t('branchPickerDialog.state.noBranchesFound')}
|
||||
</div>
|
||||
) : (
|
||||
localBranches.map((branchName) => {
|
||||
const details = branches?.branches[branchName];
|
||||
const normalizedBranchName = normalizeBranchName(branchName);
|
||||
const isCurrent = Boolean(details?.current);
|
||||
const isDeleting = deletingBranch === branchName;
|
||||
const isRenaming = renamingBranchKey === branchName;
|
||||
const attachedWorktree = worktreeByBranch.get(normalizedBranchName) ?? null;
|
||||
const hasAttachedWorktree = Boolean(attachedWorktree);
|
||||
const isProjectRootBranch = Boolean(
|
||||
normalizedBranchName &&
|
||||
normalizedRootBranch &&
|
||||
normalizedBranchName === normalizedRootBranch
|
||||
);
|
||||
const isEditing = editingBranch === branchName;
|
||||
const isConfirming = confirmingDelete === branchName;
|
||||
const isForceDelete = forceDeleteBranch === branchName;
|
||||
const isCreatingWorktree = creatingWorktreeBranch === branchName;
|
||||
|
||||
const disableCreateWorktree = Boolean(
|
||||
hasAttachedWorktree || isCreatingWorktree || isDeleting || isRenaming || isEditing
|
||||
);
|
||||
const disableDelete = Boolean(
|
||||
isCurrent || isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch
|
||||
);
|
||||
const disableRename = Boolean(
|
||||
isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch
|
||||
);
|
||||
const disableWorktreeDelete = Boolean(
|
||||
isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch || !attachedWorktree
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={branchName}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 hover:bg-interactive-hover/30 rounded-md overflow-hidden"
|
||||
>
|
||||
<Icon name="git-branch" className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{isEditing ? (
|
||||
<form
|
||||
className="flex w-full items-center min-w-0"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void commitRename(branchName);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={editValue}
|
||||
onChange={(event) => setEditValue(event.target.value)}
|
||||
className="flex-1 min-w-0 h-5 bg-transparent text-sm leading-none outline-none placeholder:text-muted-foreground"
|
||||
autoFocus
|
||||
placeholder={t('branchPickerDialog.search.renameBranchPlaceholder')}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void commitRename(branchName);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<span className={cn('text-sm truncate', isCurrent && 'font-medium text-primary')}>
|
||||
{branchName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isCurrent && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
|
||||
{t('branchPickerDialog.badge.head')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hasAttachedWorktree && !isEditing && (
|
||||
<span className="text-xs bg-muted/40 text-muted-foreground px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
|
||||
{t('branchPickerDialog.badge.worktree')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{details?.commit ? (
|
||||
<span className="font-mono">{details.commit.slice(0, 7)}</span>
|
||||
) : null}
|
||||
{typeof details?.ahead === 'number' && details.ahead > 0 ? (
|
||||
<span className="text-[color:var(--status-success)]">↑{details.ahead}</span>
|
||||
) : null}
|
||||
{typeof details?.behind === 'number' && details.behind > 0 ? (
|
||||
<span className="text-[color:var(--status-warning)]">↓{details.behind}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEditing && !isConfirming ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreateWorktreeForBranch(branchName)}
|
||||
disabled={disableCreateWorktree}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
aria-label={t('branchPickerDialog.actions.createWorktreeAria')}
|
||||
>
|
||||
{isCreatingWorktree ? (
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Icon name="split-cells-horizontal" className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{hasAttachedWorktree ? t('branchPickerDialog.tooltip.worktreeAlreadyExists') : t('branchPickerDialog.tooltip.createWorktree')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => beginRename(branchName)}
|
||||
disabled={disableRename}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
aria-label={t('branchPickerDialog.actions.renameAria')}
|
||||
>
|
||||
<Icon name="pencil" className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{isProjectRootBranch ? t('branchPickerDialog.tooltip.renameDisabledForRoot') : t('branchPickerDialog.tooltip.rename')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasAttachedWorktree) {
|
||||
handleRemoveWorktree(attachedWorktree);
|
||||
return;
|
||||
}
|
||||
setConfirmingDelete(branchName);
|
||||
}}
|
||||
disabled={hasAttachedWorktree ? disableWorktreeDelete : disableDelete}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
||||
aria-label={hasAttachedWorktree ? t('branchPickerDialog.actions.deleteWorktreeAria') : t('branchPickerDialog.actions.deleteAria')}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Icon name="delete-bin" className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{hasAttachedWorktree
|
||||
? isProjectRootBranch
|
||||
? t('branchPickerDialog.tooltip.deleteWorktreeRootProtected')
|
||||
: t('branchPickerDialog.tooltip.deleteWorktree')
|
||||
: isCurrent
|
||||
? t('branchPickerDialog.tooltip.deleteCurrentBranch')
|
||||
: isProjectRootBranch
|
||||
? t('branchPickerDialog.tooltip.deleteDisabledForRoot')
|
||||
: t('branchPickerDialog.tooltip.delete')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void commitRename(branchName)}
|
||||
disabled={isRenaming}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
aria-label={t('branchPickerDialog.actions.confirmRenameAria')}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Icon name="check" className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelRename}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t('branchPickerDialog.actions.cancelRenameAria')}
|
||||
>
|
||||
<Icon name="close" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isEditing && isConfirming && !hasAttachedWorktree ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<span className={cn(
|
||||
'text-xs mr-1',
|
||||
isForceDelete ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}>
|
||||
{isForceDelete ? t('branchPickerDialog.actions.forceDeletePrompt') : t('branchPickerDialog.actions.deletePrompt')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeleteBranch(branchName)}
|
||||
disabled={isDeleting}
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors disabled:opacity-50',
|
||||
isForceDelete
|
||||
? 'bg-destructive/10 text-destructive hover:bg-destructive/15'
|
||||
: 'hover:bg-destructive/10 text-muted-foreground hover:text-destructive'
|
||||
)}
|
||||
aria-label={t('branchPickerDialog.actions.confirmDeleteAria')}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Icon name="check" className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelDelete}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t('branchPickerDialog.actions.cancelDeleteAria')}
|
||||
>
|
||||
<Icon name="close" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { opencodeClient, type FilesystemEntry } from '@/lib/opencode/client';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
|
||||
interface DirectoryAutocompleteProps {
|
||||
inputValue: string;
|
||||
homeDirectory: string | null;
|
||||
onSelectSuggestion: (path: string) => void;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
showHidden: boolean;
|
||||
}
|
||||
|
||||
export interface DirectoryAutocompleteHandle {
|
||||
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => boolean;
|
||||
}
|
||||
|
||||
export const DirectoryAutocomplete = React.forwardRef<DirectoryAutocompleteHandle, DirectoryAutocompleteProps>(({
|
||||
inputValue,
|
||||
homeDirectory,
|
||||
onSelectSuggestion,
|
||||
visible,
|
||||
onClose,
|
||||
showHidden,
|
||||
}, ref) => {
|
||||
const [suggestions, setSuggestions] = React.useState<FilesystemEntry[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
// Fuzzy matching score - returns null if no match, higher score = better match
|
||||
const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const c = candidate.toLowerCase();
|
||||
let score = 0;
|
||||
let lastIndex = -1;
|
||||
let consecutive = 0;
|
||||
|
||||
for (let i = 0; i < q.length; i += 1) {
|
||||
const ch = q[i];
|
||||
if (!ch || ch === ' ') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const idx = c.indexOf(ch, lastIndex + 1);
|
||||
if (idx === -1) {
|
||||
return null; // Character not found - no match
|
||||
}
|
||||
|
||||
const gap = idx - lastIndex - 1;
|
||||
if (gap === 0) {
|
||||
consecutive += 1;
|
||||
} else {
|
||||
consecutive = 0;
|
||||
}
|
||||
|
||||
score += 10; // Base score per matched char
|
||||
score += Math.max(0, 18 - idx); // Bonus for early matches
|
||||
score -= Math.max(0, gap); // Penalty for gaps
|
||||
|
||||
// Bonus for match at start or after separator
|
||||
if (idx === 0) {
|
||||
score += 12;
|
||||
} else {
|
||||
const prev = c[idx - 1];
|
||||
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
|
||||
score += 10;
|
||||
}
|
||||
}
|
||||
|
||||
score += consecutive > 0 ? 12 : 0; // Bonus for consecutive matches
|
||||
lastIndex = idx;
|
||||
}
|
||||
|
||||
score += Math.max(0, 24 - Math.round(c.length / 3)); // Shorter names score higher
|
||||
return score;
|
||||
}, []);
|
||||
|
||||
// Expand ~ to home directory
|
||||
const expandPath = React.useCallback((path: string): string => {
|
||||
if (path.startsWith('~') && homeDirectory) {
|
||||
return path.replace(/^~/, homeDirectory);
|
||||
}
|
||||
return path;
|
||||
}, [homeDirectory]);
|
||||
|
||||
// Get the directory part of the path for listing
|
||||
const getParentDir = React.useCallback((path: string): string => {
|
||||
const expanded = expandPath(path);
|
||||
// If ends with /, list that directory
|
||||
if (expanded.endsWith('/')) {
|
||||
return expanded;
|
||||
}
|
||||
// Otherwise, get parent directory
|
||||
const lastSlash = expanded.lastIndexOf('/');
|
||||
if (lastSlash === -1) return '';
|
||||
if (lastSlash === 0) return '/';
|
||||
return expanded.substring(0, lastSlash + 1);
|
||||
}, [expandPath]);
|
||||
|
||||
// Get the partial name being typed (for filtering)
|
||||
const getPartialName = React.useCallback((path: string): string => {
|
||||
const expanded = expandPath(path);
|
||||
if (expanded.endsWith('/')) return '';
|
||||
const lastSlash = expanded.lastIndexOf('/');
|
||||
if (lastSlash === -1) return expanded;
|
||||
return expanded.substring(lastSlash + 1);
|
||||
}, [expandPath]);
|
||||
|
||||
const debouncedInputValue = useDebouncedValue(inputValue, 150);
|
||||
|
||||
// Fetch directory suggestions
|
||||
React.useEffect(() => {
|
||||
if (!visible || !debouncedInputValue) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const parentDir = getParentDir(debouncedInputValue);
|
||||
const partialName = getPartialName(debouncedInputValue).toLowerCase();
|
||||
|
||||
if (!parentDir) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
opencodeClient.listLocalDirectory(parentDir)
|
||||
.then((entries) => {
|
||||
if (cancelled) return;
|
||||
|
||||
// Filter to directories only, respect hidden setting
|
||||
const directories = entries.filter((entry) => {
|
||||
if (!entry.isDirectory) return false;
|
||||
if (!showHidden && entry.name.startsWith('.')) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Apply fuzzy matching and sort by score
|
||||
const scored = partialName
|
||||
? directories
|
||||
.map((entry) => {
|
||||
const score = fuzzyScore(partialName, entry.name);
|
||||
return score !== null ? { entry, score } : null;
|
||||
})
|
||||
.filter((item): item is { entry: FilesystemEntry; score: number } => item !== null)
|
||||
.sort((a, b) => b.score - a.score || a.entry.name.localeCompare(b.entry.name))
|
||||
.map((item) => item.entry)
|
||||
: directories.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
setSuggestions(scored.slice(0, 10)); // Limit suggestions
|
||||
setSelectedIndex(0);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setSuggestions([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [visible, debouncedInputValue, getParentDir, getPartialName, showHidden, fuzzyScore]);
|
||||
|
||||
// Scroll selected item into view
|
||||
React.useEffect(() => {
|
||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest'
|
||||
});
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Handle outside click
|
||||
React.useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (!target || !containerRef.current) return;
|
||||
if (containerRef.current.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
};
|
||||
}, [visible, onClose]);
|
||||
|
||||
const handleSelectSuggestion = React.useCallback((entry: FilesystemEntry) => {
|
||||
// Append the selected directory name to current path, with trailing slash
|
||||
const path = entry.path.endsWith('/') ? entry.path : entry.path + '/';
|
||||
onSelectSuggestion(path);
|
||||
}, [onSelectSuggestion]);
|
||||
|
||||
// Expose key handler to parent
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>): boolean => {
|
||||
if (!visible || suggestions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const total = suggestions.length;
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
// Shift+Tab: previous suggestion
|
||||
setSelectedIndex((prev) => (prev - 1 + total) % total);
|
||||
} else {
|
||||
// Tab: next suggestion or select if only one
|
||||
if (total === 1) {
|
||||
const selected = suggestions[0];
|
||||
if (selected) {
|
||||
handleSelectSuggestion(selected);
|
||||
}
|
||||
} else {
|
||||
setSelectedIndex((prev) => (prev + 1) % total);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % total);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + total) % total);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
// Select current item and close autocomplete
|
||||
const safeIndex = ((selectedIndex % total) + total) % total;
|
||||
const selected = suggestions[safeIndex];
|
||||
if (selected) {
|
||||
handleSelectSuggestion(selected);
|
||||
}
|
||||
onClose();
|
||||
return true; // Consume the event, don't let parent confirm yet
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}), [visible, suggestions, selectedIndex, handleSelectSuggestion, onClose]);
|
||||
|
||||
if (!visible || (suggestions.length === 0 && !loading)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] w-full max-h-48 bg-background border border-border rounded-lg shadow-none top-full mt-1 left-0 flex flex-col overflow-hidden"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-3">
|
||||
<Icon name="refresh" className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-y-auto py-1">
|
||||
{suggestions.map((entry, index) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
return (
|
||||
<div
|
||||
key={entry.path}
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label",
|
||||
isSelected && "bg-interactive-selection"
|
||||
)}
|
||||
onClick={() => { handleSelectSuggestion(entry); onClose(); }}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<Icon name="folder" className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 py-1.5 border-t typography-meta text-muted-foreground bg-sidebar/50">
|
||||
Tab cycle • ↑↓ navigate • Enter select
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DirectoryAutocomplete.displayName = 'DirectoryAutocomplete';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
export const RECENT_SESSION_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
||||
const RECENT_SESSION_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
||||
|
||||
const isSubtaskSession = (session: Session): boolean => {
|
||||
return Boolean((session as Session & { parentID?: string | null }).parentID);
|
||||
@@ -22,7 +22,7 @@ const getSessionUpdatedAt = (session: Session): number => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const sortSessionsByUpdated = (sessions: Session[]): Session[] => {
|
||||
const sortSessionsByUpdated = (sessions: Session[]): Session[] => {
|
||||
return [...sessions].sort((a, b) => getSessionUpdatedAt(b) - getSessionUpdatedAt(a));
|
||||
};
|
||||
|
||||
@@ -42,5 +42,3 @@ export const deriveRecentSessions = (
|
||||
});
|
||||
return sortSessionsByUpdated(recent);
|
||||
};
|
||||
|
||||
export const getSessionUpdatedAtMs = getSessionUpdatedAt;
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { dedupeSessionsById, getArchivedScopeKey, isSessionRelatedToProject, normalizePath, resolveArchivedFolderName } from '../utils';
|
||||
|
||||
export type ProjectForArchivedFolders = {
|
||||
type ProjectForArchivedFolders = {
|
||||
normalizedPath: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -146,20 +146,6 @@ export const compareSessionsByPinnedAndTime = (
|
||||
return getSessionUpdatedAt(b) - getSessionUpdatedAt(a);
|
||||
};
|
||||
|
||||
export const compareSessionsByPinnedAndCreated = (
|
||||
a: Session,
|
||||
b: Session,
|
||||
pinnedSessionIds: Set<string>,
|
||||
): number => {
|
||||
const aPinned = pinnedSessionIds.has(a.id);
|
||||
const bPinned = pinnedSessionIds.has(b.id);
|
||||
if (aPinned !== bPinned) {
|
||||
return aPinned ? -1 : 1;
|
||||
}
|
||||
|
||||
return getSessionCreatedAt(b) - getSessionCreatedAt(a);
|
||||
};
|
||||
|
||||
export const dedupeSessionsById = (sessions: Session[]): Session[] => {
|
||||
const byId = new Map<string, Session>();
|
||||
sessions.forEach((session) => {
|
||||
|
||||
@@ -99,4 +99,3 @@ const JsonTreeView = React.memo(function JsonTreeView({
|
||||
});
|
||||
|
||||
export { JsonTreeView };
|
||||
export type { JsonTreeViewProps };
|
||||
|
||||
@@ -277,4 +277,3 @@ const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: ()
|
||||
);
|
||||
|
||||
export { JsonTreeViewer };
|
||||
export type { JsonTreeViewerProps };
|
||||
|
||||
@@ -99,7 +99,7 @@ const PerfSection: React.FC<{ title: string; snapshot: StreamPerfSnapshot; empty
|
||||
);
|
||||
};
|
||||
|
||||
export const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
|
||||
const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
|
||||
const { t } = useI18n();
|
||||
const [activeTab, setActiveTab] = React.useState<DebugTab>('memory');
|
||||
const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'error'>('idle');
|
||||
|
||||
@@ -140,5 +140,3 @@ export const MobileOverlayPanel: React.FC<MobileOverlayPanelProps> = ({
|
||||
|
||||
return createPortal(content, overlayRootRef.current);
|
||||
};
|
||||
|
||||
export default MobileOverlayPanel;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface OpenCodeIconProps {
|
||||
className?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export const OpenCodeIcon: React.FC<OpenCodeIconProps> = ({
|
||||
className = '',
|
||||
width = 70,
|
||||
height = 70
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 70 70"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface OpenCodeLogoProps {
|
||||
className?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export const OpenCodeLogo: React.FC<OpenCodeLogoProps> = ({
|
||||
className = '',
|
||||
width = 288,
|
||||
height = 50
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 288 50"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M8 16.5H24V33H8V16.5Z" fill="currentColor" fillOpacity="0.15"/>
|
||||
<path d="M48 16.5H64V33H48V16.5Z" fill="currentColor" fillOpacity="0.15"/>
|
||||
<path d="M120 16.5H136V33H120V16.5Z" fill="currentColor" fillOpacity="0.15"/>
|
||||
<path d="M160 16.5H176V33H160V16.5Z" fill="currentColor" fillOpacity="0.15"/>
|
||||
<path d="M192 16.5H208V33H192V16.5Z" fill="currentColor" fillOpacity="0.15"/>
|
||||
<path d="M232 16.5H248V33H232V16.5Z" fill="currentColor" fillOpacity="0.15"/>
|
||||
<path d="M264 0H288V8.5H272V16.5H288V25H272V33H288V41.5H264V0Z" fill="currentColor" fillOpacity="0.95"/>
|
||||
<path d="M248 0H224V41.5H248V33H232V8.5H248V0Z" fill="currentColor" fillOpacity="0.95"/>
|
||||
<path d="M256 8.5H248V33H256V8.5Z" fill="currentColor" fillOpacity="0.95"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M184 0H216V41.5H184V0ZM208 8.5H192V33H208V8.5Z" fill="currentColor" fillOpacity="0.95"/>
|
||||
<path d="M144 8.5H136V41.5H144V8.5Z" fill="currentColor" fillOpacity="0.55"/>
|
||||
<path d="M136 0H112V41.5H120V8.5H136V0Z" fill="currentColor" fillOpacity="0.55"/>
|
||||
<path d="M80 0H104V8.5H88V16.5H104V25H88V33H104V41.5H80V0Z" fill="currentColor" fillOpacity="0.55"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M40 0H72V41.5H48V49.5H40V0ZM64 8.5H48V33H64V8.5Z" fill="currentColor" fillOpacity="0.55"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M0 0H32V41.5955H0V0ZM24 8.5H8V33H24V8.5Z" fill="currentColor" fillOpacity="0.55"/>
|
||||
<path d="M152 0H176V8.5H160V33H176V41.5H152V0Z" fill="currentColor" fillOpacity="0.95"/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -1,99 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
motion,
|
||||
AnimatePresence,
|
||||
} from 'motion/react';
|
||||
import type {
|
||||
Transition,
|
||||
Variants,
|
||||
AnimatePresenceProps,
|
||||
} from 'motion/react';
|
||||
import { useState, useEffect, Children } from 'react';
|
||||
|
||||
export type TextLoopProps = {
|
||||
children: React.ReactNode[];
|
||||
className?: string;
|
||||
interval?: number;
|
||||
transition?: Transition;
|
||||
variants?: Variants;
|
||||
onIndexChange?: (index: number) => void;
|
||||
trigger?: boolean;
|
||||
mode?: AnimatePresenceProps['mode'];
|
||||
};
|
||||
|
||||
export function TextLoop({
|
||||
children,
|
||||
className,
|
||||
interval = 2,
|
||||
transition = { duration: 0.3 },
|
||||
variants,
|
||||
onIndexChange,
|
||||
trigger = true,
|
||||
mode = 'popLayout',
|
||||
}: TextLoopProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const items = Children.toArray(children);
|
||||
|
||||
useEffect(() => {
|
||||
let next = currentIndex;
|
||||
if (items.length === 0) {
|
||||
next = 0;
|
||||
} else if (!Number.isInteger(currentIndex) || currentIndex < 0) {
|
||||
next = 0;
|
||||
} else if (currentIndex >= items.length) {
|
||||
next = items.length - 1;
|
||||
}
|
||||
|
||||
if (next !== currentIndex) {
|
||||
setCurrentIndex(next);
|
||||
onIndexChange?.(next);
|
||||
}
|
||||
}, [currentIndex, items.length, onIndexChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger || items.length <= 1) return;
|
||||
|
||||
const intervalMs = interval * 1000;
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((current) => {
|
||||
const next = (current + 1) % items.length;
|
||||
onIndexChange?.(next);
|
||||
return next;
|
||||
});
|
||||
}, intervalMs);
|
||||
return () => clearInterval(timer);
|
||||
}, [items.length, interval, onIndexChange, trigger]);
|
||||
|
||||
const motionVariants: Variants = {
|
||||
initial: { y: 20, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { y: -20, opacity: 0 },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
{/* Invisible element to maintain consistent width based on longest item */}
|
||||
<div className="invisible whitespace-nowrap">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className={i === 0 ? '' : 'absolute'}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Animated visible element */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<AnimatePresence mode={mode} initial={false}>
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial='initial'
|
||||
animate='animate'
|
||||
exit='exit'
|
||||
transition={transition}
|
||||
variants={variants || motionVariants}
|
||||
className="absolute whitespace-nowrap"
|
||||
>
|
||||
{items[currentIndex]}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-xl border px-4 py-3 typography-ui-label grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 typography-ui-label [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -114,5 +114,4 @@ function Button({
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Button, buttonVariants }
|
||||
export { Button }
|
||||
|
||||
@@ -38,29 +38,6 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground typography-ui-label", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
@@ -71,22 +48,9 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
|
||||
@@ -6,13 +6,6 @@ import { Command as CommandPrimitive } from "cmdk"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ScrollableOverlay } from "@/components/ui/ScrollableOverlay";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
@@ -38,38 +31,6 @@ function Command({
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0 transform-gpu will-change-transform", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-4 [&_[cmdk-input-wrapper]_svg]:w-4 [&_[cmdk-input]]:h-8 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-1.5 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4 [&_[cmdk-item]]:typography-meta">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
@@ -232,7 +193,6 @@ function CommandShortcut({
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { ContextMenu as BaseContextMenu } from "@base-ui/react/context-menu";
|
||||
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
dropdownMenuItemClass,
|
||||
dropdownMenuPopupClass,
|
||||
dropdownMenuSeparatorClass,
|
||||
dropdownMenuSubTriggerClass,
|
||||
} from "./dropdown-menu.styles";
|
||||
|
||||
function ContextMenu({ ...props }: React.ComponentProps<typeof BaseContextMenu.Root>) {
|
||||
@@ -53,47 +51,10 @@ function ContextMenuSeparator({ className, ...props }: React.ComponentProps<type
|
||||
return <BaseContextMenu.Separator className={cn(dropdownMenuSeparatorClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuSub({ ...props }: React.ComponentProps<typeof BaseContextMenu.SubmenuRoot>) {
|
||||
return <BaseContextMenu.SubmenuRoot {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({ className, children, ...props }: React.ComponentProps<typeof BaseContextMenu.SubmenuTrigger>) {
|
||||
return (
|
||||
<BaseContextMenu.SubmenuTrigger className={cn(dropdownMenuSubTriggerClass, className)} {...props}>
|
||||
{children}
|
||||
<Icon name="arrow-right-s" className="ml-auto size-3.5" />
|
||||
</BaseContextMenu.SubmenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({ className, positionerClassName, children, style, ...props }: ContentProps) {
|
||||
return (
|
||||
<BaseContextMenu.Portal>
|
||||
<BaseContextMenu.Positioner className={cn("app-region-no-drag z-50", positionerClassName)}>
|
||||
<BaseContextMenu.Popup
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
style={{
|
||||
backgroundColor: "var(--surface-elevated)",
|
||||
color: "var(--surface-elevated-foreground)",
|
||||
...style,
|
||||
}}
|
||||
className={cn(dropdownMenuPopupClass, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</BaseContextMenu.Popup>
|
||||
</BaseContextMenu.Positioner>
|
||||
</BaseContextMenu.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuSubContent,
|
||||
};
|
||||
|
||||
@@ -41,15 +41,6 @@ function DialogPortal({
|
||||
return <BaseDialog.Portal {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
asChild,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseDialog.Close> & AsChildProps) {
|
||||
const r = renderFromAsChild(asChild, children);
|
||||
return <BaseDialog.Close data-slot="dialog-close" {...props} {...r} />
|
||||
}
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentPropsWithoutRef<typeof BaseDialog.Backdrop>
|
||||
@@ -177,13 +168,10 @@ function DialogDescription({
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
|
||||
@@ -48,13 +48,6 @@ function DropdownMenu({
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseMenu.Portal>) {
|
||||
const portalContext = React.useContext(DropdownPortalContext);
|
||||
return <BaseMenu.Portal {...props} container={portalContext?.portalContainer || props.container} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
asChild,
|
||||
children,
|
||||
@@ -147,12 +140,6 @@ function DropdownMenuContent({
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseMenu.Group>) {
|
||||
return <BaseMenu.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
@@ -188,32 +175,6 @@ function DropdownMenuItem({
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseMenu.CheckboxItem>) {
|
||||
return (
|
||||
<BaseMenu.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover data-[checked]:bg-interactive-selection data-[checked]:text-interactive-selection-foreground relative flex cursor-pointer items-center gap-2 rounded-lg py-1 px-2 typography-ui-label outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<BaseMenu.CheckboxItemIndicator>
|
||||
<Icon name="check" className="size-3" />
|
||||
</BaseMenu.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</BaseMenu.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseMenu.RadioGroup>) {
|
||||
@@ -277,22 +238,6 @@ function DropdownMenuSeparator({
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto typography-meta tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseMenu.SubmenuRoot>) {
|
||||
@@ -353,17 +298,13 @@ function DropdownMenuSubContent({
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Slot } from "@/components/ui/slot";
|
||||
|
||||
// Flat tinted fancy-button: pale tinted fill + saturated tinted border +
|
||||
// saturated tinted text. Matches the Button primitive design language.
|
||||
const TINT_PRIMARY = [
|
||||
"bg-[color-mix(in_srgb,var(--primary-base)_10%,var(--background))]",
|
||||
"text-[var(--primary-base)]",
|
||||
"border border-[color-mix(in_srgb,var(--primary-base)_12%,transparent)]",
|
||||
"hover:bg-[color-mix(in_srgb,var(--primary-base)_16%,var(--background))]",
|
||||
"active:bg-[color-mix(in_srgb,var(--primary-base)_22%,var(--background))]",
|
||||
"dark:bg-[color-mix(in_srgb,var(--primary-base)_16%,transparent)]",
|
||||
"dark:border-[color-mix(in_srgb,var(--primary-base)_20%,transparent)]",
|
||||
"dark:hover:bg-[color-mix(in_srgb,var(--primary-base)_22%,transparent)]",
|
||||
"dark:active:bg-[color-mix(in_srgb,var(--primary-base)_30%,transparent)]",
|
||||
].join(" ");
|
||||
|
||||
const TINT_DESTRUCTIVE = [
|
||||
"bg-[color-mix(in_srgb,var(--status-error)_7%,var(--background))]",
|
||||
"text-[var(--status-error)]",
|
||||
"border border-[color-mix(in_srgb,var(--status-error)_9%,transparent)]",
|
||||
"hover:bg-[color-mix(in_srgb,var(--status-error)_11%,var(--background))]",
|
||||
"active:bg-[color-mix(in_srgb,var(--status-error)_16%,var(--background))]",
|
||||
"dark:bg-[color-mix(in_srgb,var(--status-error)_9%,transparent)]",
|
||||
"dark:border-[color-mix(in_srgb,var(--status-error)_14%,transparent)]",
|
||||
"dark:hover:bg-[color-mix(in_srgb,var(--status-error)_14%,transparent)]",
|
||||
"dark:active:bg-[color-mix(in_srgb,var(--status-error)_20%,transparent)]",
|
||||
].join(" ");
|
||||
|
||||
const fancyButtonRoot = cva(
|
||||
[
|
||||
"group relative inline-flex items-center justify-center whitespace-nowrap [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] typography-ui-label outline-none",
|
||||
"transition-[background-color,border-color,color,opacity] duration-150 ease-out",
|
||||
"focus:outline-none",
|
||||
"disabled:pointer-events-none disabled:text-muted-foreground/60",
|
||||
"disabled:bg-interactive-hover",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
neutral:
|
||||
"bg-interactive-hover text-foreground border border-border/60 hover:bg-interactive-active",
|
||||
primary: TINT_PRIMARY,
|
||||
destructive: TINT_DESTRUCTIVE,
|
||||
basic:
|
||||
"bg-background text-foreground border border-border/60 hover:bg-interactive-hover hover:text-foreground",
|
||||
},
|
||||
size: {
|
||||
medium: "h-10 gap-3 rounded-[var(--radius-xl)] px-3.5",
|
||||
small: "h-9 gap-3 rounded-[var(--radius-lg)] px-3",
|
||||
xsmall: "h-8 gap-2 rounded-[var(--radius-lg)] px-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "neutral",
|
||||
size: "medium",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const fancyButtonIcon = cva("relative z-10 size-5 shrink-0", {
|
||||
variants: {
|
||||
size: {
|
||||
medium: "-mx-1",
|
||||
small: "-mx-1",
|
||||
xsmall: "-mx-1",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "medium",
|
||||
},
|
||||
});
|
||||
|
||||
type FancyButtonVariants = VariantProps<typeof fancyButtonRoot>;
|
||||
|
||||
type FancyButtonContextValue = Pick<FancyButtonVariants, "variant" | "size">;
|
||||
|
||||
const FancyButtonContext = React.createContext<FancyButtonContextValue>({});
|
||||
|
||||
type RootProps = FancyButtonVariants &
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
const FancyButtonRoot = React.forwardRef<HTMLButtonElement, RootProps>(
|
||||
({ asChild, children, variant, size, className, ...rest }, ref) => {
|
||||
const Component = (asChild ? Slot : "button") as React.ElementType;
|
||||
const ctx = React.useMemo<FancyButtonContextValue>(
|
||||
() => ({ variant, size }),
|
||||
[variant, size],
|
||||
);
|
||||
|
||||
return (
|
||||
<FancyButtonContext.Provider value={ctx}>
|
||||
<Component
|
||||
ref={ref}
|
||||
className={cn(fancyButtonRoot({ variant, size }), className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
</FancyButtonContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
FancyButtonRoot.displayName = "FancyButton.Root";
|
||||
|
||||
type IconProps<T extends React.ElementType = "div"> = {
|
||||
as?: T;
|
||||
className?: string;
|
||||
} & Omit<React.ComponentPropsWithoutRef<T>, "as" | "className">;
|
||||
|
||||
function FancyButtonIcon<T extends React.ElementType = "div">({
|
||||
as,
|
||||
className,
|
||||
...rest
|
||||
}: IconProps<T>) {
|
||||
const { size } = React.useContext(FancyButtonContext);
|
||||
const Component = (as ?? "div") as React.ElementType;
|
||||
return (
|
||||
<Component
|
||||
className={cn(fancyButtonIcon({ size }), className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
FancyButtonIcon.displayName = "FancyButton.Icon";
|
||||
|
||||
export { FancyButtonRoot as Root, FancyButtonIcon as Icon };
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { fancyButtonRoot as fancyButtonVariants };
|
||||
@@ -1,2 +1 @@
|
||||
export { Toaster } from './sonner'
|
||||
export { toast } from './toast'
|
||||
export { toast } from './toast'
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useI18n } from "@/lib/i18n"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
|
||||
export interface NumberInputProps
|
||||
interface NumberInputProps
|
||||
extends Omit<React.ComponentProps<"input">, "value" | "onChange" | "type"> {
|
||||
value?: number
|
||||
onValueChange: (value: number) => void
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { ScrollArea as BaseScrollArea } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseScrollArea.Root>) {
|
||||
return (
|
||||
<BaseScrollArea.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<BaseScrollArea.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:outline-none focus-visible:ring-[3px] will-change-scroll"
|
||||
>
|
||||
{children}
|
||||
</BaseScrollArea.Viewport>
|
||||
<ScrollBar />
|
||||
<BaseScrollArea.Corner />
|
||||
</BaseScrollArea.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseScrollArea.Scrollbar>) {
|
||||
return (
|
||||
<BaseScrollArea.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<BaseScrollArea.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</BaseScrollArea.Scrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -272,50 +272,12 @@ function SelectSeparator({
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseSelect.ScrollUpArrow>) {
|
||||
return (
|
||||
<BaseSelect.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon name="arrow-up-s" className="size-4" />
|
||||
</BaseSelect.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseSelect.ScrollDownArrow>) {
|
||||
return (
|
||||
<BaseSelect.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon name="arrow-down-s" className="size-4" />
|
||||
</BaseSelect.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as BaseSeparator } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseSeparator>) {
|
||||
return (
|
||||
<BaseSeparator
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -1,64 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SliderProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
valueFormatter?: (value: number) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Range slider component for numeric input
|
||||
* Uses native range input styled with Tailwind CSS
|
||||
*/
|
||||
export const Slider: React.FC<SliderProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
min = 0,
|
||||
max = 1,
|
||||
step = 0.1,
|
||||
disabled = false,
|
||||
className,
|
||||
label,
|
||||
valueFormatter = (v) => v.toFixed(1),
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-3', className)}>
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-full h-2 rounded-lg appearance-none cursor-pointer bg-muted',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4',
|
||||
'[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary',
|
||||
'[&::-webkit-slider-thumb]:shadow-none [&::-webkit-slider-thumb]:transition-transform',
|
||||
'[&::-webkit-slider-thumb]:hover:scale-110 [&::-webkit-slider-thumb]:active:scale-95',
|
||||
'[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full',
|
||||
'[&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:shadow-none'
|
||||
)}
|
||||
aria-label={label}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
/>
|
||||
</div>
|
||||
<span className="typography-mono text-xs text-muted-foreground min-w-[3ch] text-right">
|
||||
{valueFormatter(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -213,29 +213,4 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
function TextareaCharCounter({
|
||||
current,
|
||||
max,
|
||||
className,
|
||||
}: {
|
||||
current?: number;
|
||||
max?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
if (current === undefined || max === undefined) return null;
|
||||
const isError = current > max;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"typography-meta text-muted-foreground",
|
||||
"group-has-[[disabled]]/textarea:text-muted-foreground/60",
|
||||
isError && "text-[var(--status-error)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{current}/{max}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea, TextareaCharCounter }
|
||||
export { Textarea }
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { Toggle as BaseToggle } from "@base-ui/react/toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-xl typography-ui-label font-medium hover:bg-interactive-hover hover:text-foreground disabled:pointer-events-none disabled:opacity-50 data-[pressed]:bg-interactive-selection data-[pressed]:text-interactive-selection-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-none hover:bg-interactive-hover hover:text-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseToggle> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<BaseToggle
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -1,61 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export type TypewriterTextProps = {
|
||||
children: string;
|
||||
speed?: number;
|
||||
loop?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const LOOP_RESTART_DELAY_MS = 1000;
|
||||
|
||||
export const TypewriterText: React.FC<TypewriterTextProps> = ({
|
||||
children,
|
||||
speed = 50,
|
||||
loop = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const [displayed, setDisplayed] = useState('');
|
||||
const index = useRef(0);
|
||||
const timeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayed('');
|
||||
index.current = 0;
|
||||
|
||||
function type() {
|
||||
setDisplayed(children.slice(0, index.current + 1));
|
||||
if (index.current < children.length - 1) {
|
||||
index.current += 1;
|
||||
timeout.current = setTimeout(type, speed);
|
||||
} else if (loop) {
|
||||
timeout.current = setTimeout(() => {
|
||||
setDisplayed('');
|
||||
index.current = 0;
|
||||
type();
|
||||
}, LOOP_RESTART_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
if (children && children.length > 0) {
|
||||
type();
|
||||
} else {
|
||||
setDisplayed('');
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
};
|
||||
}, [children, speed, loop]);
|
||||
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <span className={className}>{displayed}</span>;
|
||||
};
|
||||
|
||||
export default TypewriterText;
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useGitStore, useGitStatus, useIsGitRepo, useGitFileCount, useGitLoadingStatus } from '@/stores/useGitStore';
|
||||
import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GitStatus } from '@/lib/api/types';
|
||||
import {
|
||||
@@ -1694,22 +1694,3 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useDiffFileCount = (): number => {
|
||||
const { git } = useRuntimeAPIs();
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
|
||||
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
|
||||
const ensureStatus = useGitStore((state) => state.ensureStatus);
|
||||
const fileCount = useGitFileCount(effectiveDirectory ?? null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (effectiveDirectory) {
|
||||
setActiveDirectory(effectiveDirectory);
|
||||
void ensureStatus(effectiveDirectory, git);
|
||||
}
|
||||
}, [effectiveDirectory, setActiveDirectory, ensureStatus, git]);
|
||||
|
||||
return fileCount;
|
||||
};
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
export { AgentManagerView } from './AgentManagerView';
|
||||
export { AgentManagerSidebar } from './AgentManagerSidebar';
|
||||
export { AgentManagerEmptyState } from './AgentManagerEmptyState';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import type { LanedCommit } from './gitGraph';
|
||||
|
||||
export const LANE_WIDTH = 8;
|
||||
const LANE_WIDTH = 8;
|
||||
|
||||
interface GitGraphSegmentProps {
|
||||
laned: LanedCommit;
|
||||
|
||||
@@ -25,7 +25,7 @@ export type FlattenedTreeRow =
|
||||
file: GitStatus['files'][number];
|
||||
};
|
||||
|
||||
export const normalizePathForTree = (value: string): string =>
|
||||
const normalizePathForTree = (value: string): string =>
|
||||
value.replace(/\\/g, '/').replace(/^\/+/, '').trim();
|
||||
|
||||
const createDirectoryNode = (path: string, name: string): ChangesTreeDirectoryNode => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GitLogEntry } from '@/lib/api/types';
|
||||
|
||||
export type LaneColor = string;
|
||||
type LaneColor = string;
|
||||
|
||||
/**
|
||||
* Describes one visible line/curve in a commit row's SVG.
|
||||
@@ -14,7 +14,7 @@ export type LaneColor = string;
|
||||
* - 'branch-out' : bezier from (dot-x, dot-y) to (toLane-x, 100%) — new parent lane opens
|
||||
* - 'merge-in' : bezier from (fromLane-x, 0) to (dot-x, dot-y) — lane converges here
|
||||
*/
|
||||
export interface ConnectorSegment {
|
||||
interface ConnectorSegment {
|
||||
fromLane: number;
|
||||
toLane: number;
|
||||
color: LaneColor;
|
||||
@@ -40,7 +40,7 @@ const LANE_COLORS: LaneColor[] = [
|
||||
'var(--status-info)',
|
||||
];
|
||||
|
||||
export function laneColor(lane: number): LaneColor {
|
||||
function laneColor(lane: number): LaneColor {
|
||||
return LANE_COLORS[lane % LANE_COLORS.length];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type GitIndexMutationDirection = 'stage' | 'unstage';
|
||||
|
||||
export type QueuedGitIndexMutation = {
|
||||
type QueuedGitIndexMutation = {
|
||||
directory: string;
|
||||
direction: GitIndexMutationDirection;
|
||||
paths: Set<string>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user