perf: reduce re-renders, fix mobile keyboard handling, add chunk load recovery, and improve PATH management (#1028)
* fix: exclude file content from reverted prompt text Revert and fork now restore only the user's original prompt, not server-injected file content Uses existing isSyntheticPart helper for type-safe filtering * fix: keep scrollbar visible when hovering over thumb * fix: prevent ESC abort from triggering when terminal is focused * fix: pass directory to permission/question reply calls so approvals actually resolve * fix: default model selection not responding after Base UI migration * fix: prevent modal content from shifting and clipping footer buttons * fix: improve session switching performance and add sub-agent export with prompt collapse Defer viewport anchor saving to eliminate ~800ms UI freeze when switching sessions Add export dialog to include sub-agent tasks recursively in markdown export Add collapse chevron button for expanded user prompts in sticky header * fix: resolve sidebar scroll and TDZ crash in session sidebar * perf: reduce CPU overhead and re-renders across chat, layout, and settings * fix: position collapse button at top of message and prevent ESC abort in terminal * fix: position collapse button at top and add padding only when expanded * refactor: extract shared PATH utilities and mobile keyboard hook * refactor: import shared path-utils in electron, use module-level style constants - Electron now imports pathLooksUserConfigured/mergePathValues from shared path-utils.js instead of inline duplication - ToolPart collapsedCustomStyle moved from useMemo([]) to module const * fix: resolve remaining merge conflicts and type errors - Remove duplicate variable declarations in SessionNodeItem - Remove orphaned export callback body from conflict resolution - Fix HelpDialog description -> descriptionKey (i18n rename) * fix: resolve type-check and lint errors in session-actions.test.ts - Added missing bun:test type declarations (beforeEach, mock, mock.module) - Removed unused State import - Replaced 'as any' casts with proper OpencodeClient and ChildStoreManager types - Added eslint-disable for unused _ parameter in mock function * fix PR 1028 export and PATH edge cases * fix startup retry exhaustion state * remove opencode package lock change * fix sub-session rename cancellation --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
632e6cc97b
commit
4523e9c486
@@ -707,6 +707,8 @@ const loadShellEnv = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Merge the user's login-shell env (PATH, etc.) into this process before we
|
// Merge the user's login-shell env (PATH, etc.) into this process before we
|
||||||
|
import { pathLooksUserConfigured, mergePathValues } from '@openchamber/web/server/lib/opencode/path-utils.js';
|
||||||
|
|
||||||
// import/start the server in-process. The server and its children (opencode
|
// import/start the server in-process. The server and its children (opencode
|
||||||
// CLI, git, etc.) inherit process.env directly now — there is no sidecar
|
// CLI, git, etc.) inherit process.env directly now — there is no sidecar
|
||||||
// subprocess to hand a custom env to.
|
// subprocess to hand a custom env to.
|
||||||
@@ -716,13 +718,7 @@ const inheritUserShellEnv = () => {
|
|||||||
|
|
||||||
const homeDir = os.homedir();
|
const homeDir = os.homedir();
|
||||||
const currentPath = process.env.PATH || '';
|
const currentPath = process.env.PATH || '';
|
||||||
const currentPathLooksUserConfigured = currentPath.split(':').some((segment) => (
|
const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath, homeDir, ':');
|
||||||
segment.startsWith(`${homeDir}${path.sep}`)
|
|
||||||
|| segment === homeDir
|
|
||||||
|| segment.startsWith('/opt/homebrew/')
|
|
||||||
|| segment.startsWith('/opt/pkg/')
|
|
||||||
|| segment.startsWith('/opt/pmk/')
|
|
||||||
));
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(shellEnv)) {
|
for (const [key, value] of Object.entries(shellEnv)) {
|
||||||
if (key === 'PATH') continue;
|
if (key === 'PATH') continue;
|
||||||
@@ -730,8 +726,10 @@ const inheritUserShellEnv = () => {
|
|||||||
process.env[key] = value;
|
process.env[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!currentPathLooksUserConfigured && typeof shellEnv.PATH === 'string' && shellEnv.PATH.length > 0) {
|
|
||||||
process.env.PATH = shellEnv.PATH;
|
const shellPath = typeof shellEnv.PATH === 'string' ? shellEnv.PATH : '';
|
||||||
|
if (!currentPathLooksUserConfigured && shellPath) {
|
||||||
|
process.env.PATH = mergePathValues(shellPath, currentPath, ':');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+99
-6
@@ -5,6 +5,7 @@ import { AgentManagerView } from '@/components/views/agent-manager';
|
|||||||
import { ChatView } from '@/components/views';
|
import { ChatView } from '@/components/views';
|
||||||
import { FireworksProvider } from '@/contexts/FireworksContext';
|
import { FireworksProvider } from '@/contexts/FireworksContext';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel';
|
import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel';
|
||||||
import { setStreamPerfEnabled } from '@/stores/utils/streamDebug';
|
import { setStreamPerfEnabled } from '@/stores/utils/streamDebug';
|
||||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||||
@@ -56,6 +57,7 @@ import { QuickOpenDialog } from '@/components/ui/QuickOpenDialog';
|
|||||||
import { McpOAuthCallbackPage } from '@/components/sections/mcp/McpOAuthCallbackPage';
|
import { McpOAuthCallbackPage } from '@/components/sections/mcp/McpOAuthCallbackPage';
|
||||||
import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth';
|
import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth';
|
||||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
// Lazy-loaded heavy views — loaded on demand to reduce initial bundle size.
|
// Lazy-loaded heavy views — loaded on demand to reduce initial bundle size.
|
||||||
const OnboardingScreen = lazyWithChunkRecovery(() =>
|
const OnboardingScreen = lazyWithChunkRecovery(() =>
|
||||||
@@ -73,6 +75,27 @@ const AboutDialogWrapper: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const StartupInitializationRecovery: React.FC<{
|
||||||
|
onRetry: () => void;
|
||||||
|
isRetrying: boolean;
|
||||||
|
}> = ({ onRetry, isRetrying }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center bg-background px-6 text-foreground">
|
||||||
|
<div className="flex max-w-md flex-col items-center gap-4 text-center">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="typography-title text-foreground">{t('startup.initRecovery.title')}</h1>
|
||||||
|
<p className="typography-body text-muted-foreground">{t('startup.initRecovery.description')}</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" onClick={onRetry} disabled={isRetrying}>
|
||||||
|
{isRetrying ? t('startup.initRecovery.retrying') : t('startup.initRecovery.retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
type AppProps = {
|
type AppProps = {
|
||||||
apis: RuntimeAPIs;
|
apis: RuntimeAPIs;
|
||||||
};
|
};
|
||||||
@@ -195,6 +218,9 @@ function App({ apis }: AppProps) {
|
|||||||
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
||||||
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
|
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
|
||||||
const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true);
|
const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true);
|
||||||
|
const [initRetryExhausted, setInitRetryExhausted] = React.useState(false);
|
||||||
|
const [initRetryEpoch, setInitRetryEpoch] = React.useState(0);
|
||||||
|
const [manualInitRetrying, setManualInitRetrying] = React.useState(false);
|
||||||
const isDesktopRuntime = React.useMemo(() => isDesktopShell(), []);
|
const isDesktopRuntime = React.useMemo(() => isDesktopShell(), []);
|
||||||
const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled);
|
const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled);
|
||||||
const [bootInjectionStatus, setBootInjectionStatus] = React.useState<BootInjectionStatus>(() => {
|
const [bootInjectionStatus, setBootInjectionStatus] = React.useState<BootInjectionStatus>(() => {
|
||||||
@@ -366,16 +392,27 @@ function App({ apis }: AppProps) {
|
|||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let retryCount = 0;
|
||||||
|
const MAX_RETRIES = 10;
|
||||||
|
const BASE_DELAY_MS = 1000;
|
||||||
|
|
||||||
const retryInitialization = async () => {
|
const retryInitialization = async () => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
if (retryCount >= MAX_RETRIES) {
|
||||||
|
setInitRetryExhausted(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const state = useConfigStore.getState();
|
const state = useConfigStore.getState();
|
||||||
if (state.isInitialized) return;
|
if (state.isInitialized) {
|
||||||
|
setInitRetryExhausted(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (initializationInFlightRef.current) {
|
if (initializationInFlightRef.current) {
|
||||||
retryTimer = setTimeout(retryInitialization, 1000);
|
retryTimer = setTimeout(retryInitialization, BASE_DELAY_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retryCount += 1;
|
||||||
initializationInFlightRef.current = true;
|
initializationInFlightRef.current = true;
|
||||||
try {
|
try {
|
||||||
await state.initializeApp();
|
await state.initializeApp();
|
||||||
@@ -384,17 +421,44 @@ function App({ apis }: AppProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const next = useConfigStore.getState();
|
const next = useConfigStore.getState();
|
||||||
if (!active || next.isInitialized) return;
|
if (!active) return;
|
||||||
retryTimer = setTimeout(retryInitialization, 1000);
|
if (next.isInitialized) {
|
||||||
|
setInitRetryExhausted(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (retryCount >= MAX_RETRIES) {
|
||||||
|
setInitRetryExhausted(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delay = Math.min(BASE_DELAY_MS * Math.pow(2, retryCount - 1), 16000);
|
||||||
|
retryTimer = setTimeout(retryInitialization, delay);
|
||||||
};
|
};
|
||||||
|
|
||||||
retryTimer = setTimeout(retryInitialization, 1000);
|
retryTimer = setTimeout(retryInitialization, BASE_DELAY_MS);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
if (retryTimer) clearTimeout(retryTimer);
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
};
|
};
|
||||||
}, [isInitialized, isVSCodeRuntime]);
|
}, [initRetryEpoch, isInitialized, isVSCodeRuntime]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isInitialized) {
|
||||||
|
setInitRetryExhausted(false);
|
||||||
|
}
|
||||||
|
}, [isInitialized]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!initRetryExhausted) return;
|
||||||
|
|
||||||
|
const loadingElement = document.getElementById('initial-loading');
|
||||||
|
if (loadingElement) {
|
||||||
|
loadingElement.classList.add('fade-out');
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingElement.remove();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}, [initRetryExhausted]);
|
||||||
|
|
||||||
// Startup recovery: poll until providers AND agents are loaded.
|
// Startup recovery: poll until providers AND agents are loaded.
|
||||||
// loadProviders/loadAgents resolve normally even on failure (errors swallowed),
|
// loadProviders/loadAgents resolve normally even on failure (errors swallowed),
|
||||||
@@ -679,6 +743,24 @@ function App({ apis }: AppProps) {
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleManualInitRetry = React.useCallback(async () => {
|
||||||
|
if (manualInitRetrying || initializationInFlightRef.current) return;
|
||||||
|
|
||||||
|
setInitRetryExhausted(false);
|
||||||
|
setManualInitRetrying(true);
|
||||||
|
initializationInFlightRef.current = true;
|
||||||
|
try {
|
||||||
|
await useConfigStore.getState().initializeApp();
|
||||||
|
} finally {
|
||||||
|
initializationInFlightRef.current = false;
|
||||||
|
setManualInitRetrying(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!useConfigStore.getState().isInitialized) {
|
||||||
|
setInitRetryEpoch((value) => value + 1);
|
||||||
|
}
|
||||||
|
}, [manualInitRetrying]);
|
||||||
|
|
||||||
// Map boot outcome kind to recovery variant
|
// Map boot outcome kind to recovery variant
|
||||||
const mapBootViewToRecoveryVariant = (view: DesktopBootView): RecoveryVariant | undefined => {
|
const mapBootViewToRecoveryVariant = (view: DesktopBootView): RecoveryVariant | undefined => {
|
||||||
if (view.screen === 'recovery') {
|
if (view.screen === 'recovery') {
|
||||||
@@ -758,6 +840,17 @@ function App({ apis }: AppProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (initRetryExhausted && !isInitialized && !isVSCodeRuntime && !embeddedSessionChat) {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<StartupInitializationRecovery
|
||||||
|
onRetry={() => { void handleManualInitRetry(); }}
|
||||||
|
isRetrying={manualInitRetrying}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// VS Code runtime - simplified layout without git/terminal views
|
// VS Code runtime - simplified layout without git/terminal views
|
||||||
if (isVSCodeRuntime) {
|
if (isVSCodeRuntime) {
|
||||||
// Check if this is the Agent Manager panel
|
// Check if this is the Agent Manager panel
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class ChatErrorBoundaryView extends React.Component<ChatErrorBoundaryViewProps,
|
|||||||
{this.state.error && (
|
{this.state.error && (
|
||||||
<details className="text-xs font-mono bg-muted p-3 rounded">
|
<details className="text-xs font-mono bg-muted p-3 rounded">
|
||||||
<summary className="cursor-pointer hover:bg-interactive-hover/80">{this.props.texts.detailsSummary}</summary>
|
<summary className="cursor-pointer hover:bg-interactive-hover/80">{this.props.texts.detailsSummary}</summary>
|
||||||
<pre className="mt-2 overflow-x-auto">
|
<pre className="mt-2 max-h-48 overflow-auto">
|
||||||
{this.state.error.toString()}
|
{this.state.error.toString()}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -814,11 +814,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
|
|
||||||
const sendableAttachedFiles = attachedFiles;
|
const sendableAttachedFiles = attachedFiles;
|
||||||
|
|
||||||
|
const knownAgentNames = React.useMemo(
|
||||||
|
() => new Set(agents.map((agent) => agent.name.toLowerCase())),
|
||||||
|
[agents]
|
||||||
|
);
|
||||||
|
const knownAgentNamesRef = React.useRef(knownAgentNames);
|
||||||
|
knownAgentNamesRef.current = knownAgentNames;
|
||||||
|
|
||||||
const hasInlineMentionForHighlight = React.useMemo(() => {
|
const hasInlineMentionForHighlight = React.useMemo(() => {
|
||||||
if (!message || !message.includes('@') || inputMode === 'shell') {
|
if (!message || !message.includes('@') || inputMode === 'shell') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
|
|
||||||
const mentionRegex = /@([^\s]+)/g;
|
const mentionRegex = /@([^\s]+)/g;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
while ((match = mentionRegex.exec(message)) !== null) {
|
while ((match = mentionRegex.exec(message)) !== null) {
|
||||||
@@ -839,7 +845,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}, [agents, inputMode, message]);
|
}, [inputMode, message, knownAgentNames]);
|
||||||
|
|
||||||
const highlightedComposerContent = React.useMemo(() => {
|
const highlightedComposerContent = React.useMemo(() => {
|
||||||
if (!hasInlineMentionForHighlight) {
|
if (!hasInlineMentionForHighlight) {
|
||||||
@@ -847,7 +853,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parts: Array<{ text: string; mentionKind: 'none' | 'file' | 'agent' }> = [];
|
const parts: Array<{ text: string; mentionKind: 'none' | 'file' | 'agent' }> = [];
|
||||||
const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
|
|
||||||
const mentionRegex = /@([^\s]+)/g;
|
const mentionRegex = /@([^\s]+)/g;
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
@@ -880,7 +885,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
}
|
}
|
||||||
|
|
||||||
return parts;
|
return parts;
|
||||||
}, [agents, hasInlineMentionForHighlight, message]);
|
}, [hasInlineMentionForHighlight, message, knownAgentNames]);
|
||||||
|
|
||||||
const sanitizeAttachmentsForSend = React.useCallback(
|
const sanitizeAttachmentsForSend = React.useCallback(
|
||||||
(files: AttachedFile[] | undefined): AttachedFile[] => (files ?? [])
|
(files: AttachedFile[] | undefined): AttachedFile[] => (files ?? [])
|
||||||
@@ -900,7 +905,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
|
|
||||||
const clientDirectory = opencodeClient.getDirectory() || '';
|
const clientDirectory = opencodeClient.getDirectory() || '';
|
||||||
const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, '');
|
const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, '');
|
||||||
const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
|
|
||||||
const seenPaths = new Set<string>();
|
const seenPaths = new Set<string>();
|
||||||
const attachments: AttachedFile[] = [];
|
const attachments: AttachedFile[] = [];
|
||||||
|
|
||||||
@@ -923,7 +927,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (knownAgentNames.has(mentionPath.toLowerCase())) {
|
if (knownAgentNamesRef.current.has(mentionPath.toLowerCase())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -970,7 +974,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
sanitizedText: rawText,
|
sanitizedText: rawText,
|
||||||
attachments,
|
attachments,
|
||||||
};
|
};
|
||||||
}, [agents, chatSearchDirectory]);
|
}, [chatSearchDirectory]);
|
||||||
const [autocompleteOverlayPosition, setAutocompleteOverlayPosition] = React.useState<AutocompleteOverlayPosition | null>(null);
|
const [autocompleteOverlayPosition, setAutocompleteOverlayPosition] = React.useState<AutocompleteOverlayPosition | null>(null);
|
||||||
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const prevWasAbortedRef = React.useRef(false);
|
const prevWasAbortedRef = React.useRef(false);
|
||||||
@@ -1670,7 +1674,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
const selectionStart = textarea?.selectionStart ?? message.length;
|
const selectionStart = textarea?.selectionStart ?? message.length;
|
||||||
const selectionEnd = textarea?.selectionEnd ?? message.length;
|
const selectionEnd = textarea?.selectionEnd ?? message.length;
|
||||||
const hasCollapsedSelection = selectionStart === selectionEnd;
|
const hasCollapsedSelection = selectionStart === selectionEnd;
|
||||||
const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
|
|
||||||
|
|
||||||
if (hasCollapsedSelection) {
|
if (hasCollapsedSelection) {
|
||||||
const probeIndex = e.key === 'Backspace' ? selectionStart - 1 : selectionStart;
|
const probeIndex = e.key === 'Backspace' ? selectionStart - 1 : selectionStart;
|
||||||
@@ -1688,7 +1691,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
const token = message.slice(tokenStart, tokenEnd);
|
const token = message.slice(tokenStart, tokenEnd);
|
||||||
const mentionContent = token.slice(1);
|
const mentionContent = token.slice(1);
|
||||||
const looksLikeFileMention = FILE_MENTION_TOKEN.test(token)
|
const looksLikeFileMention = FILE_MENTION_TOKEN.test(token)
|
||||||
&& !knownAgentNames.has(mentionContent.toLowerCase())
|
&& !knownAgentNamesRef.current.has(mentionContent.toLowerCase())
|
||||||
&& isConfirmedFilePath(mentionContent);
|
&& isConfirmedFilePath(mentionContent);
|
||||||
|
|
||||||
if (looksLikeFileMention) {
|
if (looksLikeFileMention) {
|
||||||
|
|||||||
@@ -56,8 +56,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
|
|
||||||
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
|
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
const { commands: commandsWithMetadata, loadCommands: refreshCommands } = useCommandsStore();
|
const commandsWithMetadata = useCommandsStore((s) => s.commands);
|
||||||
const { skills, loadSkills: refreshSkills } = useSkillsStore();
|
const refreshCommands = useCommandsStore((s) => s.loadCommands);
|
||||||
|
const skills = useSkillsStore((s) => s.skills);
|
||||||
|
const refreshSkills = useSkillsStore((s) => s.loadSkills);
|
||||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|||||||
@@ -4,6 +4,23 @@ import { cn } from '@/lib/utils';
|
|||||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||||
import { parseDiffToUnified } from './message/toolRenderers';
|
import { parseDiffToUnified } from './message/toolRenderers';
|
||||||
|
|
||||||
|
const DIFF_CUSTOM_STYLE: React.CSSProperties = {
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
fontSize: 'inherit',
|
||||||
|
background: 'transparent',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderRadius: 0,
|
||||||
|
overflow: 'visible',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
overflowWrap: 'anywhere',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIFF_CODE_TAG_PROPS = {
|
||||||
|
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } as React.CSSProperties,
|
||||||
|
};
|
||||||
|
|
||||||
interface DiffPreviewProps {
|
interface DiffPreviewProps {
|
||||||
diff: string;
|
diff: string;
|
||||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||||
@@ -46,21 +63,8 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, fil
|
|||||||
PreTag="div"
|
PreTag="div"
|
||||||
wrapLines
|
wrapLines
|
||||||
wrapLongLines
|
wrapLongLines
|
||||||
customStyle={{
|
customStyle={DIFF_CUSTOM_STYLE}
|
||||||
margin: 0,
|
codeTagProps={DIFF_CODE_TAG_PROPS}
|
||||||
padding: 0,
|
|
||||||
fontSize: 'inherit',
|
|
||||||
background: 'transparent',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
borderRadius: 0,
|
|
||||||
overflow: 'visible',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
overflowWrap: 'anywhere',
|
|
||||||
}}
|
|
||||||
codeTagProps={{
|
|
||||||
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{line.content}
|
{line.content}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
@@ -104,21 +108,8 @@ export const WritePreview: React.FC<WritePreviewProps> = ({ content, syntaxTheme
|
|||||||
PreTag="div"
|
PreTag="div"
|
||||||
wrapLines
|
wrapLines
|
||||||
wrapLongLines
|
wrapLongLines
|
||||||
customStyle={{
|
customStyle={DIFF_CUSTOM_STYLE}
|
||||||
margin: 0,
|
codeTagProps={DIFF_CODE_TAG_PROPS}
|
||||||
padding: 0,
|
|
||||||
fontSize: 'inherit',
|
|
||||||
background: 'transparent',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
borderRadius: 0,
|
|
||||||
overflow: 'visible',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
overflowWrap: 'anywhere',
|
|
||||||
}}
|
|
||||||
codeTagProps={{
|
|
||||||
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{line || ' '}
|
{line || ' '}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
|
|||||||
@@ -522,7 +522,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
|||||||
if (isImage && file.url) {
|
if (isImage && file.url) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={file.url || `${fileName}-${index}`}
|
||||||
className="relative aspect-video rounded-lg border border-border/40 bg-muted/10 overflow-hidden group"
|
className="relative aspect-video rounded-lg border border-border/40 bg-muted/10 overflow-hidden group"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -542,7 +542,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
|||||||
|
|
||||||
if (githubLinkKind && file.url) {
|
if (githubLinkKind && file.url) {
|
||||||
return (
|
return (
|
||||||
<Tooltip key={index}>
|
<Tooltip key={file.url || `${fileName}-${index}`}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -575,7 +575,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip key={index}>
|
<Tooltip key={file.url || `${fileName}-${index}`}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -646,7 +646,7 @@ export const ImageGallery = memo(({ urls, caption, onShowPopup }: ImageGalleryPr
|
|||||||
<div className={cn("grid gap-2", getGridCols())}>
|
<div className={cn("grid gap-2", getGridCols())}>
|
||||||
{urls.map((url, index) => (
|
{urls.map((url, index) => (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={url}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onShowPopup?.({
|
onClick={() => onShowPopup?.({
|
||||||
open: true,
|
open: true,
|
||||||
|
|||||||
@@ -506,7 +506,7 @@ interface TurnBlockProps {
|
|||||||
activeStreamingPhase?: StreamPhase | null;
|
activeStreamingPhase?: StreamPhase | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TurnBlock: React.FC<TurnBlockProps> = ({
|
const TurnBlock = React.memo(({
|
||||||
turn,
|
turn,
|
||||||
isLastTurn,
|
isLastTurn,
|
||||||
sessionIsWorking,
|
sessionIsWorking,
|
||||||
@@ -522,7 +522,7 @@ const TurnBlock: React.FC<TurnBlockProps> = ({
|
|||||||
onUserAnimationConsumed,
|
onUserAnimationConsumed,
|
||||||
activeStreamingMessageId,
|
activeStreamingMessageId,
|
||||||
activeStreamingPhase,
|
activeStreamingPhase,
|
||||||
}) => {
|
}: TurnBlockProps) => {
|
||||||
const turnUiState = turnUiStates.get(turn.turnId) ?? { isExpanded: defaultActivityExpanded };
|
const turnUiState = turnUiStates.get(turn.turnId) ?? { isExpanded: defaultActivityExpanded };
|
||||||
const handleToggleTurnGroup = React.useCallback(() => {
|
const handleToggleTurnGroup = React.useCallback(() => {
|
||||||
onToggleTurnGroup(turn.turnId);
|
onToggleTurnGroup(turn.turnId);
|
||||||
@@ -783,7 +783,7 @@ const TurnBlock: React.FC<TurnBlockProps> = ({
|
|||||||
return (
|
return (
|
||||||
<TurnItem turn={renderableTurn} stickyUserHeader={stickyUserHeader} renderMessage={renderMessage} />
|
<TurnItem turn={renderableTurn} stickyUserHeader={stickyUserHeader} renderMessage={renderMessage} />
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
TurnBlock.displayName = 'TurnBlock';
|
TurnBlock.displayName = 'TurnBlock';
|
||||||
|
|
||||||
@@ -800,7 +800,7 @@ interface UngroupedMessageRowProps {
|
|||||||
activeStreamingPhase?: StreamPhase | null;
|
activeStreamingPhase?: StreamPhase | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UngroupedMessageRow: React.FC<UngroupedMessageRowProps> = ({
|
const UngroupedMessageRow = React.memo(({
|
||||||
message,
|
message,
|
||||||
previousMessage,
|
previousMessage,
|
||||||
nextMessage,
|
nextMessage,
|
||||||
@@ -811,7 +811,7 @@ const UngroupedMessageRow: React.FC<UngroupedMessageRowProps> = ({
|
|||||||
onUserAnimationConsumed,
|
onUserAnimationConsumed,
|
||||||
activeStreamingMessageId,
|
activeStreamingMessageId,
|
||||||
activeStreamingPhase,
|
activeStreamingPhase,
|
||||||
}) => {
|
}: UngroupedMessageRowProps) => {
|
||||||
return (
|
return (
|
||||||
<MessageRow
|
<MessageRow
|
||||||
message={message}
|
message={message}
|
||||||
@@ -826,7 +826,7 @@ const UngroupedMessageRow: React.FC<UngroupedMessageRowProps> = ({
|
|||||||
activeStreamingPhase={message.info.id === activeStreamingMessageId ? activeStreamingPhase : null}
|
activeStreamingPhase={message.info.id === activeStreamingMessageId ? activeStreamingPhase : null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
UngroupedMessageRow.displayName = 'UngroupedMessageRow';
|
UngroupedMessageRow.displayName = 'UngroupedMessageRow';
|
||||||
|
|
||||||
@@ -859,7 +859,7 @@ const turnContainsMessageId = (turn: TurnRecord, messageId: string | null | unde
|
|||||||
return turn.assistantMessages.some((assistant) => assistant.info.id === messageId);
|
return turn.assistantMessages.some((assistant) => assistant.info.id === messageId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const MessageListEntry: React.FC<MessageListEntryProps> = ({
|
const MessageListEntry = React.memo(({
|
||||||
entry,
|
entry,
|
||||||
onMessageContentChange,
|
onMessageContentChange,
|
||||||
getAnimationHandlers,
|
getAnimationHandlers,
|
||||||
@@ -874,7 +874,7 @@ const MessageListEntry: React.FC<MessageListEntryProps> = ({
|
|||||||
onUserAnimationConsumed,
|
onUserAnimationConsumed,
|
||||||
activeStreamingMessageId,
|
activeStreamingMessageId,
|
||||||
activeStreamingPhase,
|
activeStreamingPhase,
|
||||||
}) => {
|
}: MessageListEntryProps) => {
|
||||||
if (entry.kind === 'ungrouped') {
|
if (entry.kind === 'ungrouped') {
|
||||||
return (
|
return (
|
||||||
<UngroupedMessageRow
|
<UngroupedMessageRow
|
||||||
@@ -911,7 +911,7 @@ const MessageListEntry: React.FC<MessageListEntryProps> = ({
|
|||||||
stickyUserHeader={stickyUserHeader}
|
stickyUserHeader={stickyUserHeader}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
MessageListEntry.displayName = 'MessageListEntry';
|
MessageListEntry.displayName = 'MessageListEntry';
|
||||||
|
|
||||||
|
|||||||
@@ -119,9 +119,10 @@ function useSessionGrouping(
|
|||||||
}, [parentChildMap, getStatusType]);
|
}, [parentChildMap, getStatusType]);
|
||||||
|
|
||||||
const processedSessions = React.useMemo(() => {
|
const processedSessions = React.useMemo(() => {
|
||||||
|
const sessionIds = new Set(sessions.map((s) => s.id));
|
||||||
const topLevel = sessions.filter((session) => {
|
const topLevel = sessions.filter((session) => {
|
||||||
const parentID = (session as { parentID?: string }).parentID;
|
const parentID = (session as { parentID?: string }).parentID;
|
||||||
return !parentID || !new Set(sessions.map((s) => s.id)).has(parentID);
|
return !parentID || !sessionIds.has(parentID);
|
||||||
});
|
});
|
||||||
|
|
||||||
const running: SessionWithStatus[] = [];
|
const running: SessionWithStatus[] = [];
|
||||||
|
|||||||
@@ -2057,20 +2057,20 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter models based on search query
|
type FlatModelItem = { model: ProviderModel; providerID: string; modelID: string; section: string };
|
||||||
const filterByQuery = (modelName: string, providerName: string, query: string) => {
|
|
||||||
if (!query.trim()) return true;
|
const modelSelectorData = React.useMemo(() => {
|
||||||
return (
|
const filterByQuery = (modelName: string, providerName: string, query: string) => {
|
||||||
matchesModelSearch(modelName, query) ||
|
if (!query.trim()) return true;
|
||||||
matchesModelSearch(providerName, query)
|
return (
|
||||||
);
|
matchesModelSearch(modelName, query) ||
|
||||||
};
|
matchesModelSearch(providerName, query)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderModelSelector = () => {
|
|
||||||
const normalizedDesktopQuery = desktopModelQuery.trim();
|
const normalizedDesktopQuery = desktopModelQuery.trim();
|
||||||
const forceExpandProviders = normalizedDesktopQuery.length > 0;
|
const forceExpandProviders = normalizedDesktopQuery.length > 0;
|
||||||
|
|
||||||
// Filter favorites
|
|
||||||
const filteredFavorites = favoriteModelsList.filter(({ model, providerID }) => {
|
const filteredFavorites = favoriteModelsList.filter(({ model, providerID }) => {
|
||||||
const provider = providers.find(p => p.id === providerID);
|
const provider = providers.find(p => p.id === providerID);
|
||||||
const providerName = provider?.name || providerID;
|
const providerName = provider?.name || providerID;
|
||||||
@@ -2078,7 +2078,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
return filterByQuery(modelName, providerName, desktopModelQuery);
|
return filterByQuery(modelName, providerName, desktopModelQuery);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter recents
|
|
||||||
const filteredRecents = recentModelsList.filter(({ model, providerID }) => {
|
const filteredRecents = recentModelsList.filter(({ model, providerID }) => {
|
||||||
const provider = providers.find(p => p.id === providerID);
|
const provider = providers.find(p => p.id === providerID);
|
||||||
const providerName = provider?.name || providerID;
|
const providerName = provider?.name || providerID;
|
||||||
@@ -2086,7 +2085,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
return filterByQuery(modelName, providerName, desktopModelQuery);
|
return filterByQuery(modelName, providerName, desktopModelQuery);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter providers and their models
|
|
||||||
const filteredProviders = visibleProviders
|
const filteredProviders = visibleProviders
|
||||||
.map((provider) => {
|
.map((provider) => {
|
||||||
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
||||||
@@ -2115,8 +2113,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
filteredRecents.length > 0 ||
|
filteredRecents.length > 0 ||
|
||||||
filteredProviders.length > 0;
|
filteredProviders.length > 0;
|
||||||
|
|
||||||
// Build flat list for keyboard navigation
|
|
||||||
type FlatModelItem = { model: ProviderModel; providerID: string; modelID: string; section: string };
|
|
||||||
const flatModelList: FlatModelItem[] = [];
|
const flatModelList: FlatModelItem[] = [];
|
||||||
|
|
||||||
filteredFavorites.forEach(({ model, providerID, modelID }) => {
|
filteredFavorites.forEach(({ model, providerID, modelID }) => {
|
||||||
@@ -2131,6 +2127,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { filteredFavorites, filteredRecents, filteredProviders, providerSections, flatModelList, hasResults, forceExpandProviders };
|
||||||
|
}, [desktopModelQuery, favoriteModelsList, recentModelsList, visibleProviders, providers, collapsedProviderSet, matchesModelSearch]);
|
||||||
|
|
||||||
|
const renderModelSelector = () => {
|
||||||
|
const { filteredFavorites, filteredRecents, filteredProviders, providerSections, flatModelList, hasResults, forceExpandProviders } = modelSelectorData;
|
||||||
|
|
||||||
const totalItems = flatModelList.length;
|
const totalItems = flatModelList.length;
|
||||||
|
|
||||||
// Check if currently highlighted model supports thinking variants
|
// Check if currently highlighted model supports thinking variants
|
||||||
|
|||||||
@@ -12,6 +12,36 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|||||||
import { DiffPreview, WritePreview } from './DiffPreview';
|
import { DiffPreview, WritePreview } from './DiffPreview';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
const PERMISSION_BASH_CUSTOM_STYLE: React.CSSProperties = {
|
||||||
|
margin: 0,
|
||||||
|
padding: '0.5rem',
|
||||||
|
fontSize: 'var(--text-meta)',
|
||||||
|
lineHeight: '1.25rem',
|
||||||
|
background: 'rgb(var(--muted) / 0.3)',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflowWrap: 'break-word',
|
||||||
|
overflow: 'visible',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PERMISSION_BASH_CODE_TAG_PROPS = {
|
||||||
|
style: {
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflowWrap: 'break-word',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PERMISSION_JSON_CUSTOM_STYLE: React.CSSProperties = {
|
||||||
|
margin: 0,
|
||||||
|
padding: '0.5rem',
|
||||||
|
fontSize: 'var(--text-meta)',
|
||||||
|
lineHeight: '1.25rem',
|
||||||
|
background: 'rgb(var(--muted) / 0.3)',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
};
|
||||||
|
|
||||||
interface PermissionCardProps {
|
interface PermissionCardProps {
|
||||||
permission: PermissionRequest;
|
permission: PermissionRequest;
|
||||||
onResponse?: (response: 'once' | 'always' | 'reject') => void;
|
onResponse?: (response: 'once' | 'always' | 'reject') => void;
|
||||||
@@ -66,7 +96,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [isResponding, setIsResponding] = React.useState(false);
|
const [isResponding, setIsResponding] = React.useState(false);
|
||||||
const [hasResponded, setHasResponded] = React.useState(false);
|
const [hasResponded, setHasResponded] = React.useState(false);
|
||||||
const respondToPermission = sessionActions.respondToPermission;;
|
const respondToPermission = sessionActions.respondToPermission;
|
||||||
const sessions = useSessions();
|
const sessions = useSessions();
|
||||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const isFromSubagent = React.useMemo(() => {
|
const isFromSubagent = React.useMemo(() => {
|
||||||
@@ -84,7 +114,9 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
await respondToPermission(permission.sessionID, permission.id, response);
|
await respondToPermission(permission.sessionID, permission.id, response);
|
||||||
setHasResponded(true);
|
setHasResponded(true);
|
||||||
onResponse?.(response);
|
onResponse?.(response);
|
||||||
} catch { /* ignored */ } finally {
|
} catch (error) {
|
||||||
|
console.error('[PermissionCard] Failed to respond to permission:', error);
|
||||||
|
} finally {
|
||||||
setIsResponding(false);
|
setIsResponding(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -140,25 +172,8 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
language="bash"
|
language="bash"
|
||||||
style={syntaxTheme}
|
style={syntaxTheme}
|
||||||
PreTag="div"
|
PreTag="div"
|
||||||
customStyle={{
|
customStyle={PERMISSION_BASH_CUSTOM_STYLE}
|
||||||
margin: 0,
|
codeTagProps={PERMISSION_BASH_CODE_TAG_PROPS}
|
||||||
padding: '0.5rem',
|
|
||||||
fontSize: 'var(--text-meta)',
|
|
||||||
lineHeight: '1.25rem',
|
|
||||||
background: 'rgb(var(--muted) / 0.3)',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
overflowWrap: 'break-word',
|
|
||||||
overflow: 'visible'
|
|
||||||
}}
|
|
||||||
codeTagProps={{
|
|
||||||
style: {
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
overflowWrap: 'break-word'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
wrapLongLines={true}
|
wrapLongLines={true}
|
||||||
>
|
>
|
||||||
{command}
|
{command}
|
||||||
@@ -235,14 +250,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
language="json"
|
language="json"
|
||||||
style={syntaxTheme}
|
style={syntaxTheme}
|
||||||
customStyle={{
|
customStyle={PERMISSION_JSON_CUSTOM_STYLE}
|
||||||
margin: 0,
|
|
||||||
padding: '0.5rem',
|
|
||||||
fontSize: 'var(--text-meta)',
|
|
||||||
lineHeight: '1.25rem',
|
|
||||||
background: 'rgb(var(--muted) / 0.3)',
|
|
||||||
borderRadius: '0.25rem'
|
|
||||||
}}
|
|
||||||
wrapLongLines={true}
|
wrapLongLines={true}
|
||||||
>
|
>
|
||||||
{JSON.stringify(headers, null, 2)}
|
{JSON.stringify(headers, null, 2)}
|
||||||
@@ -257,14 +265,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
language={typeof body === 'object' ? 'json' : 'text'}
|
language={typeof body === 'object' ? 'json' : 'text'}
|
||||||
style={syntaxTheme}
|
style={syntaxTheme}
|
||||||
customStyle={{
|
customStyle={PERMISSION_JSON_CUSTOM_STYLE}
|
||||||
margin: 0,
|
|
||||||
padding: '0.5rem',
|
|
||||||
fontSize: 'var(--text-meta)',
|
|
||||||
lineHeight: '1.25rem',
|
|
||||||
background: 'rgb(var(--muted) / 0.3)',
|
|
||||||
borderRadius: '0.25rem'
|
|
||||||
}}
|
|
||||||
wrapLongLines={true}
|
wrapLongLines={true}
|
||||||
>
|
>
|
||||||
{typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body)}
|
{typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body)}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [isResponding, setIsResponding] = React.useState(false);
|
const [isResponding, setIsResponding] = React.useState(false);
|
||||||
const [hasResponded, setHasResponded] = React.useState(false);
|
const [hasResponded, setHasResponded] = React.useState(false);
|
||||||
const respondToPermission = sessionActions.respondToPermission;;
|
const respondToPermission = sessionActions.respondToPermission;
|
||||||
|
|
||||||
const handleResponse = async (response: PermissionResponse) => {
|
const handleResponse = async (response: PermissionResponse) => {
|
||||||
setIsResponding(true);
|
setIsResponding(true);
|
||||||
@@ -26,7 +26,9 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
|||||||
await respondToPermission(permission.sessionID, permission.id, response);
|
await respondToPermission(permission.sessionID, permission.id, response);
|
||||||
setHasResponded(true);
|
setHasResponded(true);
|
||||||
onResponse?.(response);
|
onResponse?.(response);
|
||||||
} catch { /* ignored */ } finally {
|
} catch (error) {
|
||||||
|
console.error('[PermissionRequest] Failed to respond to permission:', error);
|
||||||
|
} finally {
|
||||||
setIsResponding(false);
|
setIsResponding(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
|||||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
const [filteredSkills, setFilteredSkills] = React.useState<SkillInfo[]>([]);
|
const [filteredSkills, setFilteredSkills] = React.useState<SkillInfo[]>([]);
|
||||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const { skills, loadSkills } = useSkillsStore();
|
const skills = useSkillsStore((s) => s.skills);
|
||||||
|
const loadSkills = useSkillsStore((s) => s.loadSkills);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// Always trigger loadSkills when autocomplete opens to ensure project context is fresh
|
// Always trigger loadSkills when autocomplete opens to ensure project context is fresh
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
|||||||
import { useContextStore } from '@/stores/contextStore';
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
import { formatEffortLabel, getAgentDisplayName, getModelDisplayName } from './mobileControlsUtils';
|
import { formatEffortLabel, getAgentDisplayName, getModelDisplayName } from './mobileControlsUtils';
|
||||||
|
|
||||||
|
const STATUS_CHIP_STYLE = {
|
||||||
|
height: '28px',
|
||||||
|
maxHeight: '28px',
|
||||||
|
minHeight: '28px',
|
||||||
|
};
|
||||||
|
|
||||||
interface StatusChipProps {
|
interface StatusChipProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -42,11 +48,7 @@ export const StatusChip: React.FC<StatusChipProps> = ({ onClick, className }) =>
|
|||||||
'focus:outline-none hover:bg-[var(--interactive-hover)]',
|
'focus:outline-none hover:bg-[var(--interactive-hover)]',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{
|
style={STATUS_CHIP_STYLE}
|
||||||
height: '28px',
|
|
||||||
maxHeight: '28px',
|
|
||||||
minHeight: '28px',
|
|
||||||
}}
|
|
||||||
title={fullLabel}
|
title={fullLabel}
|
||||||
>
|
>
|
||||||
<span className="shrink-0">{agentLabel}</span>
|
<span className="shrink-0">{agentLabel}</span>
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import { isVSCodeRuntime } from "@/lib/desktop";
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
|
const STATUS_ROW_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "status-row" };
|
||||||
|
|
||||||
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
|
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
|
||||||
in_progress: {
|
in_progress: {
|
||||||
textClassName: "text-foreground",
|
textClassName: "text-foreground",
|
||||||
@@ -292,7 +294,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("mb-1", !hasLeftAccessory && "chat-column")} style={{ containerType: "inline-size", containerName: "status-row" }}>
|
<div className={cn("mb-1", !hasLeftAccessory && "chat-column")} style={STATUS_ROW_CONTAINER_STYLE}>
|
||||||
<div className={cn("flex items-center justify-between py-0.5 gap-2 h-[1.2rem]", hasLeftAccessory && "px-0.5")}>
|
<div className={cn("flex items-center justify-between py-0.5 gap-2 h-[1.2rem]", hasLeftAccessory && "px-0.5")}>
|
||||||
{/* Left: Abort status or Working placeholder or leftAccessory */}
|
{/* Left: Abort status or Working placeholder or leftAccessory */}
|
||||||
<div className={cn("flex-1 flex items-center min-w-0", hasLeftAccessory ? "pl-1.5" : "overflow-hidden")}>
|
<div className={cn("flex-1 flex items-center min-w-0", hasLeftAccessory ? "pl-1.5" : "overflow-hidden")}>
|
||||||
|
|||||||
@@ -120,41 +120,15 @@ export const useChatTimelineController = ({
|
|||||||
|
|
||||||
const historySignalsRef = React.useRef(historySignals);
|
const historySignalsRef = React.useRef(historySignals);
|
||||||
|
|
||||||
React.useEffect(() => {
|
turnModelRef.current = turnWindowModel;
|
||||||
turnModelRef.current = turnWindowModel;
|
turnStartRef.current = turnStart;
|
||||||
}, [turnWindowModel]);
|
isPinnedRef.current = isPinned;
|
||||||
|
isLoadingOlderRef.current = isLoadingOlder;
|
||||||
React.useEffect(() => {
|
pendingRevealWorkRef.current = pendingRevealWork;
|
||||||
turnStartRef.current = turnStart;
|
historySignalsRef.current = historySignals;
|
||||||
}, [turnStart]);
|
sessionIdRef.current = sessionId;
|
||||||
|
messagesRef.current = messages;
|
||||||
React.useEffect(() => {
|
historyMetaRef.current = historyMeta;
|
||||||
isPinnedRef.current = isPinned;
|
|
||||||
}, [isPinned]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
isLoadingOlderRef.current = isLoadingOlder;
|
|
||||||
}, [isLoadingOlder]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
pendingRevealWorkRef.current = pendingRevealWork;
|
|
||||||
}, [pendingRevealWork]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
historySignalsRef.current = historySignals;
|
|
||||||
}, [historySignals]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
sessionIdRef.current = sessionId;
|
|
||||||
}, [sessionId]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
messagesRef.current = messages;
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
historyMetaRef.current = historyMeta;
|
|
||||||
}, [historyMeta]);
|
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (initializedSessionRef.current === sessionId) {
|
if (initializedSessionRef.current === sessionId) {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const useStreamingTextThrottle = ({
|
|||||||
}: UseStreamingTextThrottleInput): string => {
|
}: UseStreamingTextThrottleInput): string => {
|
||||||
const [throttledText, setThrottledText] = React.useState(text);
|
const [throttledText, setThrottledText] = React.useState(text);
|
||||||
const latestTextRef = React.useRef(text);
|
const latestTextRef = React.useRef(text);
|
||||||
|
const throttledTextRef = React.useRef(throttledText);
|
||||||
|
|
||||||
const stateRef = React.useRef<StreamingThrottleState>({
|
const stateRef = React.useRef<StreamingThrottleState>({
|
||||||
timer: null,
|
timer: null,
|
||||||
@@ -47,6 +48,10 @@ export const useStreamingTextThrottle = ({
|
|||||||
latestTextRef.current = text;
|
latestTextRef.current = text;
|
||||||
}, [text]);
|
}, [text]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
throttledTextRef.current = throttledText;
|
||||||
|
}, [throttledText]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const state = stateRef.current;
|
const state = stateRef.current;
|
||||||
clearTimer(state);
|
clearTimer(state);
|
||||||
@@ -58,7 +63,8 @@ export const useStreamingTextThrottle = ({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const state = stateRef.current;
|
const state = stateRef.current;
|
||||||
state.pendingText = text;
|
state.pendingText = text;
|
||||||
const stableText = isStreaming && throttledText.length > text.length ? throttledText : text;
|
const currentThrottled = throttledTextRef.current;
|
||||||
|
const stableText = isStreaming && currentThrottled.length > text.length ? currentThrottled : text;
|
||||||
|
|
||||||
if (!isStreaming) {
|
if (!isStreaming) {
|
||||||
clearTimer(state);
|
clearTimer(state);
|
||||||
@@ -92,7 +98,7 @@ export const useStreamingTextThrottle = ({
|
|||||||
return () => {
|
return () => {
|
||||||
clearTimer(state);
|
clearTimer(state);
|
||||||
};
|
};
|
||||||
}, [isStreaming, text, throttleMs, throttledText]);
|
}, [isStreaming, text, throttleMs]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const state = stateRef.current;
|
const state = stateRef.current;
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
|||||||
import { useSessions } from '@/sync/sync-context';
|
import { useSessions } from '@/sync/sync-context';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' };
|
||||||
|
const MESSAGE_FOOTER_CONTAINER_STYLE = { containerType: 'inline-size' as const, containerName: 'message-footer' };
|
||||||
|
|
||||||
type SubtaskPartLike = Part & {
|
type SubtaskPartLike = Part & {
|
||||||
type: 'subtask';
|
type: 'subtask';
|
||||||
description?: unknown;
|
description?: unknown;
|
||||||
@@ -320,7 +323,7 @@ const writeRevealedToolIds = (messageId: string, value: Set<string>): void => {
|
|||||||
revealedToolIdsByMessage.set(messageId, new Set(value));
|
revealedToolIdsByMessage.set(messageId, new Set(value));
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserMessageBody: React.FC<{
|
const UserMessageBody = React.memo(({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }: {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
parts: Part[];
|
parts: Part[];
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
@@ -334,7 +337,7 @@ const UserMessageBody: React.FC<{
|
|||||||
onFork?: () => void;
|
onFork?: () => void;
|
||||||
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
|
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
|
||||||
stickyUserHeaderEnabled?: boolean;
|
stickyUserHeaderEnabled?: boolean;
|
||||||
}> = ({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
||||||
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
||||||
@@ -519,7 +522,7 @@ const UserMessageBody: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative w-full group/message"
|
className="relative w-full group/message"
|
||||||
style={{ contain: 'layout', transform: 'translateZ(0)' }}
|
style={CONTAIN_LAYOUT_STYLE}
|
||||||
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -572,9 +575,9 @@ const UserMessageBody: React.FC<{
|
|||||||
{actionsBlock}
|
{actionsBlock}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
const AssistantMessageBody = React.memo(({
|
||||||
sessionId,
|
sessionId,
|
||||||
messageId,
|
messageId,
|
||||||
parts,
|
parts,
|
||||||
@@ -599,7 +602,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
showReasoningTraces = false,
|
showReasoningTraces = false,
|
||||||
turnGroupingContext,
|
turnGroupingContext,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
}) => {
|
}: Omit<MessageBodyProps, 'isUser'>) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const streamPhase = _streamPhase;
|
const streamPhase = _streamPhase;
|
||||||
void _allowAnimation;
|
void _allowAnimation;
|
||||||
@@ -1564,13 +1567,10 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'relative w-full group/message'
|
'relative w-full group/message'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={CONTAIN_LAYOUT_STYLE}
|
||||||
contain: 'layout',
|
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
||||||
transform: 'translateZ(0)',
|
>
|
||||||
}}
|
<TextSelectionMenu containerRef={messageContentRef} />
|
||||||
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
|
||||||
>
|
|
||||||
<TextSelectionMenu containerRef={messageContentRef} />
|
|
||||||
<SaveProjectPlanDialog
|
<SaveProjectPlanDialog
|
||||||
open={isPlanDialogOpen}
|
open={isPlanDialogOpen}
|
||||||
onOpenChange={setIsPlanDialogOpen}
|
onOpenChange={setIsPlanDialogOpen}
|
||||||
@@ -1601,7 +1601,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
{shouldShowFooter && (
|
{shouldShowFooter && (
|
||||||
<div
|
<div
|
||||||
className="mt-2 mb-1 flex items-center justify-start gap-1.5"
|
className="mt-2 mb-1 flex items-center justify-start gap-1.5"
|
||||||
style={{ containerType: 'inline-size', containerName: 'message-footer' }}
|
style={MESSAGE_FOOTER_CONTAINER_STYLE}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{footerButtons}
|
{footerButtons}
|
||||||
@@ -1642,9 +1642,9 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
const MessageBody: React.FC<MessageBodyProps> = ({ isUser, ...props }) => {
|
const MessageBody = React.memo(({ isUser, ...props }: MessageBodyProps) => {
|
||||||
|
|
||||||
if (isUser) {
|
if (isUser) {
|
||||||
return (
|
return (
|
||||||
@@ -1667,6 +1667,6 @@ const MessageBody: React.FC<MessageBodyProps> = ({ isUser, ...props }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return <AssistantMessageBody {...props} />;
|
return <AssistantMessageBody {...props} />;
|
||||||
};
|
});
|
||||||
|
|
||||||
export default MessageBody;
|
export default MessageBody;
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
|
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
|
||||||
const [selectedText, setSelectedText] = React.useState('');
|
const [selectedText, setSelectedText] = React.useState('');
|
||||||
const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState('');
|
const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState('');
|
||||||
const [isDragging, setIsDragging] = React.useState(false);
|
const isDraggingRef = React.useRef(false);
|
||||||
const [isOpening, setIsOpening] = React.useState(false);
|
const [isOpening, setIsOpening] = React.useState(false);
|
||||||
const [isAddingToNotes, setIsAddingToNotes] = React.useState(false);
|
const [isAddingToNotes, setIsAddingToNotes] = React.useState(false);
|
||||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -351,7 +351,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
|
|
||||||
if (!selection || !container) {
|
if (!selection || !container) {
|
||||||
if (!isDragging) {
|
if (!isDraggingRef.current) {
|
||||||
hideMenu();
|
hideMenu();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -361,7 +361,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
|
|
||||||
// Only show if we have text and the selection is within our container
|
// Only show if we have text and the selection is within our container
|
||||||
if (!text) {
|
if (!text) {
|
||||||
if (!isDragging) {
|
if (!isDraggingRef.current) {
|
||||||
hideMenu();
|
hideMenu();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -371,7 +371,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
const range = selection.getRangeAt(0);
|
const range = selection.getRangeAt(0);
|
||||||
|
|
||||||
if (!container.contains(range.commonAncestorContainer)) {
|
if (!container.contains(range.commonAncestorContainer)) {
|
||||||
if (!isDragging) {
|
if (!isDraggingRef.current) {
|
||||||
hideMenu();
|
hideMenu();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -388,10 +388,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Only show menu if we're not currently dragging
|
// Only show menu if we're not currently dragging
|
||||||
if (!isDragging) {
|
if (!isDraggingRef.current) {
|
||||||
showMenu();
|
showMenu();
|
||||||
}
|
}
|
||||||
}, [containerRef, hideMenu, showMenu, isDragging]);
|
}, [containerRef, hideMenu, showMenu]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -399,13 +399,13 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
|
|
||||||
// Track when dragging starts
|
// Track when dragging starts
|
||||||
const handleMouseDown = () => {
|
const handleMouseDown = () => {
|
||||||
setIsDragging(true);
|
isDraggingRef.current = true;
|
||||||
hideMenu();
|
hideMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track when dragging stops
|
// Track when dragging stops
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
setIsDragging(false);
|
isDraggingRef.current = false;
|
||||||
// Check if we have a pending selection to show
|
// Check if we have a pending selection to show
|
||||||
if (pendingSelectionRef.current) {
|
if (pendingSelectionRef.current) {
|
||||||
// Small delay to ensure selection is finalized
|
// Small delay to ensure selection is finalized
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ const MERMAID_DIALOG_HEADER_HEIGHT = 40;
|
|||||||
const MERMAID_ASPECT_RETRY_DELAY_MS = 120;
|
const MERMAID_ASPECT_RETRY_DELAY_MS = 120;
|
||||||
const MERMAID_ASPECT_MAX_RETRIES = 3;
|
const MERMAID_ASPECT_MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
const DIALOG_CODE_TAG_PROPS = { style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } };
|
||||||
|
|
||||||
|
const MERMAID_CONTROLS = { download: false, copy: false, fullscreen: false, panZoom: true };
|
||||||
|
|
||||||
type PierreThemeConfig = {
|
type PierreThemeConfig = {
|
||||||
theme: { light: string; dark: string };
|
theme: { light: string; dark: string };
|
||||||
themeType: 'light' | 'dark';
|
themeType: 'light' | 'dark';
|
||||||
@@ -966,12 +970,7 @@ const MermaidPreviewDialog: React.FC<{
|
|||||||
variant="tool"
|
variant="tool"
|
||||||
allowMermaidWheelZoom
|
allowMermaidWheelZoom
|
||||||
className="markdown-mermaid-fullscreen h-full [&_[data-markdown='mermaid-block']_button]:hidden"
|
className="markdown-mermaid-fullscreen h-full [&_[data-markdown='mermaid-block']_button]:hidden"
|
||||||
mermaidControls={{
|
mermaidControls={MERMAID_CONTROLS}
|
||||||
download: false,
|
|
||||||
copy: false,
|
|
||||||
fullscreen: false,
|
|
||||||
panZoom: true,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1059,7 +1058,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
|||||||
language="bash"
|
language="bash"
|
||||||
PreTag="div"
|
PreTag="div"
|
||||||
customStyle={toolDisplayStyles.getPopupStyles()}
|
customStyle={toolDisplayStyles.getPopupStyles()}
|
||||||
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
|
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||||
wrapLongLines
|
wrapLongLines
|
||||||
>
|
>
|
||||||
{getInputValue('command')!}
|
{getInputValue('command')!}
|
||||||
@@ -1129,7 +1128,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
|||||||
PreTag="div"
|
PreTag="div"
|
||||||
wrapLongLines
|
wrapLongLines
|
||||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||||
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
|
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||||
>
|
>
|
||||||
{popup.content}
|
{popup.content}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
@@ -1184,7 +1183,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
|||||||
PreTag="div"
|
PreTag="div"
|
||||||
wrapLongLines
|
wrapLongLines
|
||||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||||
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
|
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||||
>
|
>
|
||||||
{popup.content}
|
{popup.content}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
@@ -1215,7 +1214,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
|||||||
PreTag="div"
|
PreTag="div"
|
||||||
wrapLongLines
|
wrapLongLines
|
||||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||||
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
|
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||||
>
|
>
|
||||||
{popup.content}
|
{popup.content}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
|
|||||||
@@ -738,10 +738,10 @@ export const StaticToolRow = React.memo(StaticToolRowInner, (prev, next) => {
|
|||||||
/**
|
/**
|
||||||
* Inline reasoning text block — rendered as dimmed italic markdown.
|
* Inline reasoning text block — rendered as dimmed italic markdown.
|
||||||
*/
|
*/
|
||||||
const InlineReasoningBlock: React.FC<{
|
const InlineReasoningBlock = React.memo(({ activity, onContentChange }: {
|
||||||
activity: TurnActivityPart;
|
activity: TurnActivityPart;
|
||||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||||
}> = ({ activity, onContentChange }) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<ReasoningPart
|
<ReasoningPart
|
||||||
part={activity.part}
|
part={activity.part}
|
||||||
@@ -749,15 +749,15 @@ const InlineReasoningBlock: React.FC<{
|
|||||||
onContentChange={onContentChange}
|
onContentChange={onContentChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline justification text block — rendered as normal assistant text between tools.
|
* Inline justification text block — rendered as normal assistant text between tools.
|
||||||
*/
|
*/
|
||||||
const InlineJustificationBlock: React.FC<{
|
const InlineJustificationBlock = React.memo(({ activity, onContentChange }: {
|
||||||
activity: TurnActivityPart;
|
activity: TurnActivityPart;
|
||||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||||
}> = ({ activity, onContentChange }) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<JustificationBlock
|
<JustificationBlock
|
||||||
part={activity.part}
|
part={activity.part}
|
||||||
@@ -765,7 +765,7 @@ const InlineJustificationBlock: React.FC<{
|
|||||||
onContentChange={onContentChange}
|
onContentChange={onContentChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||||
parts,
|
parts,
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onContentChange?.('structural');
|
onContentChange?.('structural');
|
||||||
}, [onContentChange, isExpanded, text]);
|
}, [onContentChange, text]);
|
||||||
|
|
||||||
if (!text || text.trim().length === 0) {
|
if (!text || text.trim().length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -191,11 +191,11 @@ type ReasoningPartProps = {
|
|||||||
messageId: string;
|
messageId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ReasoningPart: React.FC<ReasoningPartProps> = ({
|
const ReasoningPart = React.memo(({
|
||||||
part,
|
part,
|
||||||
onContentChange,
|
onContentChange,
|
||||||
messageId,
|
messageId,
|
||||||
}) => {
|
}: ReasoningPartProps) => {
|
||||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||||
const partWithText = part as PartWithText;
|
const partWithText = part as PartWithText;
|
||||||
const rawText = partWithText.text || partWithText.content || '';
|
const rawText = partWithText.text || partWithText.content || '';
|
||||||
@@ -225,7 +225,7 @@ const ReasoningPart: React.FC<ReasoningPartProps> = ({
|
|||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const formatReasoningText = (text: string): string => cleanReasoningText(text);
|
export const formatReasoningText = (text: string): string => cleanReasoningText(text);
|
||||||
|
|||||||
@@ -758,17 +758,8 @@ const ToolScrollableTextOutput: React.FC<{
|
|||||||
style={syntaxTheme}
|
style={syntaxTheme}
|
||||||
language={outputLanguage}
|
language={outputLanguage}
|
||||||
PreTag="div"
|
PreTag="div"
|
||||||
customStyle={{
|
customStyle={TOOL_COLLAPSED_CUSTOM_STYLE}
|
||||||
...toolDisplayStyles.getCollapsedStyles(),
|
codeTagProps={CODE_TAG_PROPS}
|
||||||
padding: 0,
|
|
||||||
overflow: 'visible',
|
|
||||||
}}
|
|
||||||
codeTagProps={{
|
|
||||||
style: {
|
|
||||||
background: 'transparent',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
wrapLongLines
|
wrapLongLines
|
||||||
>
|
>
|
||||||
{renderedOutput}
|
{renderedOutput}
|
||||||
@@ -1233,6 +1224,19 @@ const TOOL_DIFF_METRICS = {
|
|||||||
fileGap: 0,
|
fileGap: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TOOL_COLLAPSED_CUSTOM_STYLE: React.CSSProperties = {
|
||||||
|
...toolDisplayStyles.getCollapsedStyles(),
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'visible',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CODE_TAG_PROPS = { style: { background: 'transparent', backgroundColor: 'transparent' } };
|
||||||
|
|
||||||
|
const TOOL_ERROR_ICON_STYLE: React.CSSProperties = { color: 'var(--status-error)' };
|
||||||
|
const TOOL_NORMAL_ICON_STYLE: React.CSSProperties = { color: 'var(--tools-icon)' };
|
||||||
|
const TOOL_ERROR_TITLE_STYLE: React.CSSProperties = { color: 'var(--status-error)' };
|
||||||
|
const TOOL_NORMAL_TITLE_STYLE: React.CSSProperties = { color: 'var(--tools-title)' };
|
||||||
|
|
||||||
type DiffPatchEntry = {
|
type DiffPatchEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -1370,24 +1374,29 @@ const getDiffPatchEntries = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, pierreTheme, pierreThemeType, diffViewMode }) => {
|
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, pierreTheme, pierreThemeType, diffViewMode }) => {
|
||||||
|
const options = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
diffStyle: diffViewMode === 'side-by-side' ? 'split' as const : 'unified' as const,
|
||||||
|
diffIndicators: 'none' as const,
|
||||||
|
hunkSeparators: 'line-info-basic' as const,
|
||||||
|
lineDiffType: 'none' as const,
|
||||||
|
disableFileHeader: true,
|
||||||
|
maxLineDiffLength: 1000,
|
||||||
|
expansionLineCount: 20,
|
||||||
|
overflow: 'wrap' as const,
|
||||||
|
theme: pierreTheme,
|
||||||
|
themeType: pierreThemeType,
|
||||||
|
unsafeCSS: TOOL_DIFF_UNSAFE_CSS,
|
||||||
|
}),
|
||||||
|
[diffViewMode, pierreTheme, pierreThemeType]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="typography-code px-1 pb-1 pt-0">
|
<div className="typography-code px-1 pb-1 pt-0">
|
||||||
<PatchDiff
|
<PatchDiff
|
||||||
patch={diff}
|
patch={diff}
|
||||||
metrics={TOOL_DIFF_METRICS}
|
metrics={TOOL_DIFF_METRICS}
|
||||||
options={{
|
options={options}
|
||||||
diffStyle: diffViewMode === 'side-by-side' ? 'split' : 'unified',
|
|
||||||
diffIndicators: 'none',
|
|
||||||
hunkSeparators: 'line-info-basic',
|
|
||||||
lineDiffType: 'none',
|
|
||||||
disableFileHeader: true,
|
|
||||||
maxLineDiffLength: 1000,
|
|
||||||
expansionLineCount: 20,
|
|
||||||
overflow: 'wrap',
|
|
||||||
theme: pierreTheme,
|
|
||||||
themeType: pierreThemeType,
|
|
||||||
unsafeCSS: TOOL_DIFF_UNSAFE_CSS,
|
|
||||||
}}
|
|
||||||
className="block w-full"
|
className="block w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1797,14 +1806,17 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
|
|
||||||
const shouldNotifyStructuralChange = isFinalized || isTaskTool;
|
const shouldNotifyStructuralChange = isFinalized || isTaskTool;
|
||||||
|
|
||||||
|
const onContentChangeRef = React.useRef(onContentChange);
|
||||||
|
onContentChangeRef.current = onContentChange;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!shouldNotifyStructuralChange) {
|
if (!shouldNotifyStructuralChange) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof isExpanded === 'boolean') {
|
if (typeof isExpanded === 'boolean') {
|
||||||
onContentChange?.('structural');
|
onContentChangeRef.current?.('structural');
|
||||||
}
|
}
|
||||||
}, [isExpanded, onContentChange, shouldNotifyStructuralChange]);
|
}, [isExpanded, shouldNotifyStructuralChange]);
|
||||||
|
|
||||||
const stateWithData = state as ToolStateWithMetadata;
|
const stateWithData = state as ToolStateWithMetadata;
|
||||||
const metadata = stateWithData.metadata;
|
const metadata = stateWithData.metadata;
|
||||||
@@ -2425,6 +2437,9 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
handleMainClick(event);
|
handleMainClick(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const iconStyle = !isTaskTool && isError ? TOOL_ERROR_ICON_STYLE : TOOL_NORMAL_ICON_STYLE;
|
||||||
|
const titleStyle = !isTaskTool && isError ? TOOL_ERROR_TITLE_STYLE : TOOL_NORMAL_TITLE_STYLE;
|
||||||
|
|
||||||
if (!shouldTreatAsFinalized && !isActive && !isTaskTool) {
|
if (!shouldTreatAsFinalized && !isActive && !isTaskTool) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -2455,7 +2470,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
isExpanded && 'opacity-0',
|
isExpanded && 'opacity-0',
|
||||||
!isExpanded && 'group-hover/tool:opacity-0'
|
!isExpanded && 'group-hover/tool:opacity-0'
|
||||||
)}
|
)}
|
||||||
style={!isTaskTool && isError ? { color: 'var(--status-error)' } : { color: 'var(--tools-icon)' }}
|
style={iconStyle}
|
||||||
>
|
>
|
||||||
{getToolIcon(normalizedPartTool || part.tool)}
|
{getToolIcon(normalizedPartTool || part.tool)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2476,7 +2491,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
active={Boolean(isActive && !isError)}
|
active={Boolean(isActive && !isError)}
|
||||||
minDurationMs={300}
|
minDurationMs={300}
|
||||||
className="typography-meta font-medium flex-shrink-0"
|
className="typography-meta font-medium flex-shrink-0"
|
||||||
style={!isTaskTool && isError ? { color: 'var(--status-error)' } : { color: 'var(--tools-title)' }}
|
style={titleStyle}
|
||||||
title={displayName}
|
title={displayName}
|
||||||
>
|
>
|
||||||
{displayName}
|
{displayName}
|
||||||
@@ -2490,7 +2505,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
active={Boolean(isActive && !isError)}
|
active={Boolean(isActive && !isError)}
|
||||||
minDurationMs={300}
|
minDurationMs={300}
|
||||||
className="typography-meta font-medium flex-shrink-0"
|
className="typography-meta font-medium flex-shrink-0"
|
||||||
style={!isTaskTool && isError ? { color: 'var(--status-error)' } : { color: 'var(--tools-title)' }}
|
style={titleStyle}
|
||||||
title={displayName}
|
title={displayName}
|
||||||
>
|
>
|
||||||
{displayName}
|
{displayName}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Part } from '@opencode-ai/sdk/v2';
|
|||||||
import type { AgentMentionInfo } from '../types';
|
import type { AgentMentionInfo } from '../types';
|
||||||
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { RiArrowUpSLine } from '@remixicon/react';
|
||||||
|
|
||||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||||
|
|
||||||
@@ -33,14 +34,12 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
|
|||||||
};
|
};
|
||||||
|
|
||||||
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
|
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
|
||||||
const CLAMP_LINES = 2;
|
|
||||||
const partWithText = part as PartWithText;
|
const partWithText = part as PartWithText;
|
||||||
const rawText = partWithText.text;
|
const rawText = partWithText.text;
|
||||||
const textContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
|
const textContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
|
||||||
|
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
const [isTruncated, setIsTruncated] = React.useState(false);
|
const [isTruncated, setIsTruncated] = React.useState(false);
|
||||||
const [collapseZoneHeight, setCollapseZoneHeight] = React.useState<number>(0);
|
|
||||||
const userMessageRenderingMode = useUIStore((state) => state.userMessageRenderingMode);
|
const userMessageRenderingMode = useUIStore((state) => state.userMessageRenderingMode);
|
||||||
const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode);
|
const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode);
|
||||||
const textRef = React.useRef<HTMLDivElement>(null);
|
const textRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -67,13 +66,6 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
if (!isExpanded) {
|
if (!isExpanded) {
|
||||||
setIsTruncated(el.scrollHeight > el.clientHeight);
|
setIsTruncated(el.scrollHeight > el.clientHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = window.getComputedStyle(el);
|
|
||||||
const lineHeight = parseFloat(styles.lineHeight);
|
|
||||||
const fontSize = parseFloat(styles.fontSize);
|
|
||||||
const fallbackLineHeight = isFinite(fontSize) ? fontSize * 1.4 : 20;
|
|
||||||
const resolvedLineHeight = isFinite(lineHeight) ? lineHeight : fallbackLineHeight;
|
|
||||||
setCollapseZoneHeight(Math.max(1, Math.round(resolvedLineHeight * CLAMP_LINES)));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
checkTruncation();
|
checkTruncation();
|
||||||
@@ -84,7 +76,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
return () => resizeObserver.disconnect();
|
return () => resizeObserver.disconnect();
|
||||||
}, [textContent, isExpanded]);
|
}, [textContent, isExpanded]);
|
||||||
|
|
||||||
const handleClick = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
const handleClick = React.useCallback(() => {
|
||||||
const element = textRef.current;
|
const element = textRef.current;
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return;
|
return;
|
||||||
@@ -94,18 +86,15 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isExpanded) {
|
if (!isExpanded && isTruncated) {
|
||||||
if (isTruncated) {
|
setIsExpanded(true);
|
||||||
setIsExpanded(true);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, [hasActiveSelectionInElement, isExpanded, isTruncated]);
|
||||||
|
|
||||||
const clickY = event.clientY - element.getBoundingClientRect().top;
|
const handleCollapse = React.useCallback((event: React.MouseEvent) => {
|
||||||
if (clickY <= collapseZoneHeight) {
|
event.stopPropagation();
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
}
|
}, []);
|
||||||
}, [collapseZoneHeight, hasActiveSelectionInElement, isExpanded, isTruncated]);
|
|
||||||
|
|
||||||
const processedMarkdownContent = React.useMemo(() => {
|
const processedMarkdownContent = React.useMemo(() => {
|
||||||
let content = textContent;
|
let content = textContent;
|
||||||
@@ -153,9 +142,20 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" key={part.id || `${messageId}-user-text`}>
|
<div className="relative" key={part.id || `${messageId}-user-text`}>
|
||||||
|
{isExpanded && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCollapse}
|
||||||
|
className="absolute top-0 right-0 flex items-center justify-center rounded-sm p-0.5 text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)] hover:bg-[var(--interactive-hover)] transition-colors"
|
||||||
|
aria-label="Collapse"
|
||||||
|
>
|
||||||
|
<RiArrowUpSLine className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"break-words font-sans typography-markdown",
|
"break-words font-sans typography-markdown",
|
||||||
|
isExpanded && "pb-3",
|
||||||
normalizedRenderingMode === 'plain' && 'whitespace-pre-wrap',
|
normalizedRenderingMode === 'plain' && 'whitespace-pre-wrap',
|
||||||
!isExpanded && "line-clamp-2",
|
!isExpanded && "line-clamp-2",
|
||||||
isTruncated && !isExpanded && "cursor-pointer"
|
isTruncated && !isExpanded && "cursor-pointer"
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ export function WorkingPlaceholder({
|
|||||||
}: WorkingPlaceholderProps) {
|
}: WorkingPlaceholderProps) {
|
||||||
const [displayedText, setDisplayedText] = React.useState<string | null>(null);
|
const [displayedText, setDisplayedText] = React.useState<string | null>(null);
|
||||||
const [displayedPermission, setDisplayedPermission] = React.useState<boolean>(false);
|
const [displayedPermission, setDisplayedPermission] = React.useState<boolean>(false);
|
||||||
|
const displayedTextRef = React.useRef(displayedText);
|
||||||
|
const displayedPermissionRef = React.useRef(displayedPermission);
|
||||||
|
displayedTextRef.current = displayedText;
|
||||||
|
displayedPermissionRef.current = displayedPermission;
|
||||||
|
|
||||||
const statusShownAtRef = React.useRef<number>(0);
|
const statusShownAtRef = React.useRef<number>(0);
|
||||||
const queuedStatusRef = React.useRef<{ text: string; permission: boolean } | null>(null);
|
const queuedStatusRef = React.useRef<{ text: string; permission: boolean } | null>(null);
|
||||||
@@ -141,12 +145,12 @@ export function WorkingPlaceholder({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!displayedText) {
|
if (!displayedTextRef.current) {
|
||||||
showStatus(incomingText, incomingPermission);
|
showStatus(incomingText, incomingPermission);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (incomingText === displayedText && incomingPermission === displayedPermission) {
|
if (incomingText === displayedTextRef.current && incomingPermission === displayedPermissionRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,8 +173,6 @@ export function WorkingPlaceholder({
|
|||||||
isGenericStatus,
|
isGenericStatus,
|
||||||
isWaitingForPermission,
|
isWaitingForPermission,
|
||||||
retryInfo,
|
retryInfo,
|
||||||
displayedText,
|
|
||||||
displayedPermission,
|
|
||||||
clearTimers,
|
clearTimers,
|
||||||
showStatus,
|
showStatus,
|
||||||
scheduleQueueProcess,
|
scheduleQueueProcess,
|
||||||
|
|||||||
@@ -243,6 +243,8 @@ export const renderListOutput = (output: string, options?: { unstyled?: boolean
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const GREP_DOT_STYLE = { backgroundColor: 'var(--status-info)', opacity: 0.6 };
|
||||||
|
|
||||||
export const renderGrepOutput = (output: string, isMobile: boolean, options?: { unstyled?: boolean }) => {
|
export const renderGrepOutput = (output: string, isMobile: boolean, options?: { unstyled?: boolean }) => {
|
||||||
try {
|
try {
|
||||||
const lines = output.trim().split('\n').filter(Boolean);
|
const lines = output.trim().split('\n').filter(Boolean);
|
||||||
@@ -287,7 +289,7 @@ export const renderGrepOutput = (output: string, isMobile: boolean, options?: {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div key={idx} className={cn('flex items-start gap-2 min-w-0', isMobile ? 'typography-micro' : 'typography-code')}>
|
<div key={idx} className={cn('flex items-start gap-2 min-w-0', isMobile ? 'typography-micro' : 'typography-code')}>
|
||||||
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0 mt-1.5" style={{ backgroundColor: 'var(--status-info)', opacity: 0.6 }} />
|
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0 mt-1.5" style={GREP_DOT_STYLE} />
|
||||||
<div className="flex gap-2 min-w-0 flex-1">
|
<div className="flex gap-2 min-w-0 flex-1">
|
||||||
{match.lineNum && (
|
{match.lineNum && (
|
||||||
<span className="text-muted-foreground font-mono whitespace-nowrap">
|
<span className="text-muted-foreground font-mono whitespace-nowrap">
|
||||||
@@ -312,6 +314,8 @@ export const renderGrepOutput = (output: string, isMobile: boolean, options?: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const GLOB_DOT_STYLE = { backgroundColor: 'var(--status-info)', opacity: 0.6 };
|
||||||
|
|
||||||
export const renderGlobOutput = (output: string, isMobile: boolean, options?: { unstyled?: boolean }) => {
|
export const renderGlobOutput = (output: string, isMobile: boolean, options?: { unstyled?: boolean }) => {
|
||||||
try {
|
try {
|
||||||
const paths = output.trim().split('\n').filter(Boolean);
|
const paths = output.trim().split('\n').filter(Boolean);
|
||||||
@@ -350,7 +354,7 @@ export const renderGlobOutput = (output: string, isMobile: boolean, options?: {
|
|||||||
<div className={cn('pl-4 grid gap-1', isMobile ? 'grid-cols-1' : 'grid-cols-2')}>
|
<div className={cn('pl-4 grid gap-1', isMobile ? 'grid-cols-1' : 'grid-cols-2')}>
|
||||||
{groups[dir].sort().map((filename) => (
|
{groups[dir].sort().map((filename) => (
|
||||||
<div key={filename} className={cn('flex items-center gap-2 min-w-0', isMobile ? 'typography-micro' : 'typography-code')}>
|
<div key={filename} className={cn('flex items-center gap-2 min-w-0', isMobile ? 'typography-micro' : 'typography-code')}>
|
||||||
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ backgroundColor: 'var(--status-info)', opacity: 0.6 }} />
|
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={GLOB_DOT_STYLE} />
|
||||||
<span className="text-foreground font-mono truncate">{filename}</span>
|
<span className="text-foreground font-mono truncate">{filename}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -388,12 +392,11 @@ export const renderTodoOutput = (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const todosByStatus = {
|
const todosByStatus = todos.reduce((acc, t) => {
|
||||||
in_progress: todos.filter((t) => t.status === 'in_progress'),
|
const status = t.status as keyof typeof acc;
|
||||||
pending: todos.filter((t) => t.status === 'pending'),
|
if (status in acc) acc[status].push(t);
|
||||||
completed: todos.filter((t) => t.status === 'completed'),
|
return acc;
|
||||||
cancelled: todos.filter((t) => t.status === 'cancelled'),
|
}, { in_progress: [] as Todo[], pending: [] as Todo[], completed: [] as Todo[], cancelled: [] as Todo[] });
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityDot = (priority?: string) => {
|
const getPriorityDot = (priority?: string) => {
|
||||||
const baseClasses = 'w-2 h-2 rounded-full flex-shrink-0 mt-1';
|
const baseClasses = 'w-2 h-2 rounded-full flex-shrink-0 mt-1';
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
|
import { useMobileKeyboardManager } from '@/hooks/useMobileKeyboardManager';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { isDesktopShell } from '@/lib/desktop';
|
import { isDesktopShell } from '@/lib/desktop';
|
||||||
@@ -353,254 +354,7 @@ export const MainLayout: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
useMobileKeyboardManager(isMobile);
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = document.documentElement;
|
|
||||||
|
|
||||||
let stickyKeyboardInset = 0;
|
|
||||||
let ignoreOpenUntilZero = false;
|
|
||||||
let previousHeight = 0;
|
|
||||||
let maxObservedLayoutHeight = 0;
|
|
||||||
let previousOrientation = '';
|
|
||||||
let keyboardAvoidTarget: HTMLElement | null = null;
|
|
||||||
|
|
||||||
const setKeyboardOpen = useUIStore.getState().setKeyboardOpen;
|
|
||||||
const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent;
|
|
||||||
const isAndroid = /Android/i.test(userAgent);
|
|
||||||
const isIOS = /iPad|iPhone|iPod/.test(userAgent);
|
|
||||||
|
|
||||||
const clearKeyboardAvoidTarget = () => {
|
|
||||||
if (!keyboardAvoidTarget) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
keyboardAvoidTarget.style.setProperty('--oc-keyboard-avoid-offset', '0px');
|
|
||||||
keyboardAvoidTarget.removeAttribute('data-keyboard-avoid-active');
|
|
||||||
keyboardAvoidTarget = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveKeyboardAvoidTarget = (active: HTMLElement | null) => {
|
|
||||||
if (!active) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const explicitTargetId = active.getAttribute('data-keyboard-avoid-target-id');
|
|
||||||
if (explicitTargetId) {
|
|
||||||
const explicitTarget = document.getElementById(explicitTargetId);
|
|
||||||
if (explicitTarget instanceof HTMLElement) {
|
|
||||||
return explicitTarget;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const markedTarget = active.closest('[data-keyboard-avoid]') as HTMLElement | null;
|
|
||||||
if (markedTarget) {
|
|
||||||
// data-keyboard-avoid="none" opts out of translateY avoidance entirely.
|
|
||||||
// Used by components with their own scroll (e.g. CodeMirror).
|
|
||||||
if (markedTarget.getAttribute('data-keyboard-avoid') === 'none') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return markedTarget;
|
|
||||||
}
|
|
||||||
if (active.classList.contains('overlay-scrollbar-container')) {
|
|
||||||
const parent = active.parentElement;
|
|
||||||
if (parent instanceof HTMLElement) {
|
|
||||||
return parent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return active;
|
|
||||||
};
|
|
||||||
|
|
||||||
const forceKeyboardClosed = () => {
|
|
||||||
stickyKeyboardInset = 0;
|
|
||||||
ignoreOpenUntilZero = true;
|
|
||||||
root.style.setProperty('--oc-keyboard-inset', '0px');
|
|
||||||
setKeyboardOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
let rafId = 0;
|
|
||||||
|
|
||||||
const updateVisualViewport = () => {
|
|
||||||
const viewport = window.visualViewport;
|
|
||||||
|
|
||||||
const height = viewport ? Math.round(viewport.height) : window.innerHeight;
|
|
||||||
const offsetTop = viewport ? Math.max(0, Math.round(viewport.offsetTop)) : 0;
|
|
||||||
const orientation = window.innerWidth >= window.innerHeight ? 'landscape' : 'portrait';
|
|
||||||
|
|
||||||
root.style.setProperty('--oc-visual-viewport-offset-top', `${offsetTop}px`);
|
|
||||||
root.style.setProperty('--oc-visual-viewport-height', `${height}px`);
|
|
||||||
|
|
||||||
const active = document.activeElement as HTMLElement | null;
|
|
||||||
const tagName = active?.tagName;
|
|
||||||
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
|
|
||||||
const isTextTarget = isInput || Boolean(active?.isContentEditable);
|
|
||||||
|
|
||||||
const layoutHeight = Math.round(root.clientHeight || window.innerHeight);
|
|
||||||
if (previousOrientation !== orientation) {
|
|
||||||
previousOrientation = orientation;
|
|
||||||
maxObservedLayoutHeight = layoutHeight;
|
|
||||||
} else if (layoutHeight > maxObservedLayoutHeight || maxObservedLayoutHeight === 0) {
|
|
||||||
maxObservedLayoutHeight = layoutHeight;
|
|
||||||
}
|
|
||||||
const viewportSum = height + offsetTop;
|
|
||||||
const rawInset = Math.max(0, layoutHeight - viewportSum);
|
|
||||||
const rawAndroidResizeInset = isAndroid
|
|
||||||
? Math.max(0, maxObservedLayoutHeight - layoutHeight)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const openThreshold = isTextTarget ? 120 : 180;
|
|
||||||
const measuredInset = rawInset >= openThreshold ? rawInset : 0;
|
|
||||||
const androidResizeInset = isTextTarget && rawAndroidResizeInset >= openThreshold
|
|
||||||
? rawAndroidResizeInset
|
|
||||||
: 0;
|
|
||||||
const effectiveMeasuredInset = Math.max(measuredInset, androidResizeInset);
|
|
||||||
|
|
||||||
if (ignoreOpenUntilZero) {
|
|
||||||
if (effectiveMeasuredInset === 0) {
|
|
||||||
ignoreOpenUntilZero = false;
|
|
||||||
}
|
|
||||||
stickyKeyboardInset = 0;
|
|
||||||
} else if (stickyKeyboardInset === 0) {
|
|
||||||
if (effectiveMeasuredInset > 0 && isTextTarget) {
|
|
||||||
stickyKeyboardInset = effectiveMeasuredInset;
|
|
||||||
setKeyboardOpen(true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const closingByHeight = !isTextTarget && height > previousHeight + 6;
|
|
||||||
|
|
||||||
if (effectiveMeasuredInset === 0) {
|
|
||||||
stickyKeyboardInset = 0;
|
|
||||||
setKeyboardOpen(false);
|
|
||||||
} else if (closingByHeight) {
|
|
||||||
forceKeyboardClosed();
|
|
||||||
} else if (effectiveMeasuredInset > 0 && isTextTarget) {
|
|
||||||
stickyKeyboardInset = effectiveMeasuredInset;
|
|
||||||
setKeyboardOpen(true);
|
|
||||||
} else if (effectiveMeasuredInset > stickyKeyboardInset) {
|
|
||||||
stickyKeyboardInset = effectiveMeasuredInset;
|
|
||||||
setKeyboardOpen(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
root.style.setProperty('--oc-keyboard-inset', `${stickyKeyboardInset}px`);
|
|
||||||
previousHeight = height;
|
|
||||||
|
|
||||||
const keyboardHomeIndicator = isIOS && stickyKeyboardInset > 0 ? 34 : 0;
|
|
||||||
root.style.setProperty('--oc-keyboard-home-indicator', `${keyboardHomeIndicator}px`);
|
|
||||||
|
|
||||||
const avoidTarget = isTextTarget ? resolveKeyboardAvoidTarget(active) : null;
|
|
||||||
|
|
||||||
if (!isMobile || !avoidTarget || !active) {
|
|
||||||
clearKeyboardAvoidTarget();
|
|
||||||
} else {
|
|
||||||
if (avoidTarget !== keyboardAvoidTarget) {
|
|
||||||
clearKeyboardAvoidTarget();
|
|
||||||
keyboardAvoidTarget = avoidTarget;
|
|
||||||
}
|
|
||||||
const viewportBottom = offsetTop + height;
|
|
||||||
const rect = active.getBoundingClientRect();
|
|
||||||
const overlap = rect.bottom - viewportBottom;
|
|
||||||
const clearance = 8;
|
|
||||||
const keyboardInset = Math.max(stickyKeyboardInset, effectiveMeasuredInset);
|
|
||||||
const avoidOffset = overlap > clearance && keyboardInset > 0
|
|
||||||
? Math.min(overlap, keyboardInset)
|
|
||||||
: 0;
|
|
||||||
const target = keyboardAvoidTarget;
|
|
||||||
if (target) {
|
|
||||||
target.style.setProperty('--oc-keyboard-avoid-offset', `${avoidOffset}px`);
|
|
||||||
target.setAttribute('data-keyboard-avoid-active', 'true');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMobile && isTextTarget) {
|
|
||||||
const scroller = document.scrollingElement;
|
|
||||||
if (scroller && scroller.scrollTop !== 0) {
|
|
||||||
scroller.scrollTop = 0;
|
|
||||||
}
|
|
||||||
if (window.scrollY !== 0) {
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleVisualViewportUpdate = () => {
|
|
||||||
if (rafId) return;
|
|
||||||
rafId = requestAnimationFrame(() => {
|
|
||||||
rafId = 0;
|
|
||||||
updateVisualViewport();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
updateVisualViewport();
|
|
||||||
|
|
||||||
const viewport = window.visualViewport;
|
|
||||||
viewport?.addEventListener('resize', scheduleVisualViewportUpdate);
|
|
||||||
viewport?.addEventListener('scroll', scheduleVisualViewportUpdate);
|
|
||||||
window.addEventListener('resize', scheduleVisualViewportUpdate);
|
|
||||||
window.addEventListener('orientationchange', scheduleVisualViewportUpdate);
|
|
||||||
const isTextInputTarget = (element: HTMLElement | null) => {
|
|
||||||
if (!element) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const tagName = element.tagName;
|
|
||||||
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
|
|
||||||
return isInput || element.isContentEditable;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFocusIn = (event: FocusEvent) => {
|
|
||||||
const target = event.target as HTMLElement | null;
|
|
||||||
if (isTextInputTarget(target)) {
|
|
||||||
ignoreOpenUntilZero = false;
|
|
||||||
}
|
|
||||||
scheduleVisualViewportUpdate();
|
|
||||||
};
|
|
||||||
document.addEventListener('focusin', handleFocusIn, true);
|
|
||||||
|
|
||||||
const handleFocusOut = (event: FocusEvent) => {
|
|
||||||
const target = event.target as HTMLElement | null;
|
|
||||||
if (!isTextInputTarget(target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const related = event.relatedTarget as HTMLElement | null;
|
|
||||||
if (isTextInputTarget(related)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
if (isTextInputTarget(document.activeElement as HTMLElement | null)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentViewport = window.visualViewport;
|
|
||||||
const height = currentViewport ? Math.round(currentViewport.height) : window.innerHeight;
|
|
||||||
const offsetTop = currentViewport ? Math.max(0, Math.round(currentViewport.offsetTop)) : 0;
|
|
||||||
const layoutHeight = Math.round(root.clientHeight || window.innerHeight);
|
|
||||||
const viewportSum = height + offsetTop;
|
|
||||||
const rawInset = Math.max(0, layoutHeight - viewportSum);
|
|
||||||
|
|
||||||
if (rawInset > 0) {
|
|
||||||
updateVisualViewport();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
forceKeyboardClosed();
|
|
||||||
updateVisualViewport();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('focusout', handleFocusOut, true);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (rafId) cancelAnimationFrame(rafId);
|
|
||||||
viewport?.removeEventListener('resize', scheduleVisualViewportUpdate);
|
|
||||||
viewport?.removeEventListener('scroll', scheduleVisualViewportUpdate);
|
|
||||||
window.removeEventListener('resize', scheduleVisualViewportUpdate);
|
|
||||||
window.removeEventListener('orientationchange', scheduleVisualViewportUpdate);
|
|
||||||
document.removeEventListener('focusin', handleFocusIn, true);
|
|
||||||
document.removeEventListener('focusout', handleFocusOut, true);
|
|
||||||
clearKeyboardAvoidTarget();
|
|
||||||
};
|
|
||||||
}, [isMobile]);
|
|
||||||
|
|
||||||
const secondaryView = React.useMemo(() => {
|
const secondaryView = React.useMemo(() => {
|
||||||
switch (activeMainTab) {
|
switch (activeMainTab) {
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, cl
|
|||||||
style={{ width: 'var(--oc-left-sidebar-width)', overflowX: 'hidden' }}
|
style={{ width: 'var(--oc-left-sidebar-width)', overflowX: 'hidden' }}
|
||||||
aria-hidden={!isOpen}
|
aria-hidden={!isOpen}
|
||||||
>
|
>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<ErrorBoundary>{children}</ErrorBoundary>
|
<ErrorBoundary>{children}</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -374,6 +374,11 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
const canDelete = Boolean(files.delete);
|
const canDelete = Boolean(files.delete);
|
||||||
const canReveal = Boolean(files.revealPath);
|
const canReveal = Boolean(files.revealPath);
|
||||||
|
|
||||||
|
const fileRowPermissions = React.useMemo(
|
||||||
|
() => ({ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }),
|
||||||
|
[canRename, canCreateFile, canCreateFolder, canDelete, canReveal]
|
||||||
|
);
|
||||||
|
|
||||||
const handleRevealPath = React.useCallback((targetPath: string) => {
|
const handleRevealPath = React.useCallback((targetPath: string) => {
|
||||||
if (!files.revealPath) return;
|
if (!files.revealPath) return;
|
||||||
void files.revealPath(targetPath).catch(() => {
|
void files.revealPath(targetPath).catch(() => {
|
||||||
@@ -787,7 +792,7 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
status={!isDir ? getFileStatus(node.path) : undefined}
|
status={!isDir ? getFileStatus(node.path) : undefined}
|
||||||
badge={isDir ? getFolderBadge(node.path) : undefined}
|
badge={isDir ? getFolderBadge(node.path) : undefined}
|
||||||
permissions={{ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }}
|
permissions={fileRowPermissions}
|
||||||
downloadFile={files.downloadFile}
|
downloadFile={files.downloadFile}
|
||||||
contextMenuPath={contextMenuPath}
|
contextMenuPath={contextMenuPath}
|
||||||
setContextMenuPath={setContextMenuPath}
|
setContextMenuPath={setContextMenuPath}
|
||||||
|
|||||||
@@ -743,7 +743,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{setupCommands.map((command, index) => (
|
{setupCommands.map((command, index) => (
|
||||||
<div key={index} className="flex gap-1.5">
|
<div key={`${command}-${index}`} className="flex gap-1.5">
|
||||||
<Input
|
<Input
|
||||||
value={command}
|
value={command}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { NumberInput } from '@/components/ui/number-input';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { useAgentsStore, type AgentConfig, type AgentScope } from '@/stores/useAgentsStore';
|
import { useAgentsStore, type AgentConfig, type AgentScope } from '@/stores/useAgentsStore';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { useDirectorySync } from '@/sync/sync-context';
|
import { useDirectorySync } from '@/sync/sync-context';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
@@ -184,7 +185,23 @@ const buildPermissionConfigWithGlobal = (
|
|||||||
export const AgentsPage: React.FC = () => {
|
export const AgentsPage: React.FC = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
const { selectedAgentName, getAgentByName, createAgent, updateAgent, agents, agentDraft, setAgentDraft } = useAgentsStore();
|
const {
|
||||||
|
selectedAgentName,
|
||||||
|
getAgentByName,
|
||||||
|
createAgent,
|
||||||
|
updateAgent,
|
||||||
|
agents,
|
||||||
|
agentDraft,
|
||||||
|
setAgentDraft,
|
||||||
|
} = useAgentsStore(useShallow((s) => ({
|
||||||
|
selectedAgentName: s.selectedAgentName,
|
||||||
|
getAgentByName: s.getAgentByName,
|
||||||
|
createAgent: s.createAgent,
|
||||||
|
updateAgent: s.updateAgent,
|
||||||
|
agents: s.agents,
|
||||||
|
agentDraft: s.agentDraft,
|
||||||
|
setAgentDraft: s.setAgentDraft,
|
||||||
|
})));
|
||||||
|
|
||||||
const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null;
|
const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null;
|
||||||
const isNewAgent = Boolean(agentDraft && agentDraft.name === selectedAgentName && !selectedAgent);
|
const isNewAgent = Boolean(agentDraft && agentDraft.name === selectedAgentName && !selectedAgent);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiRobot2Line, RiRobotLine, RiRestartLine, RiEditLine } from '@remixicon/react';
|
import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiRobot2Line, RiRobotLine, RiRestartLine, RiEditLine } from '@remixicon/react';
|
||||||
import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
|
import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { Agent } from '@opencode-ai/sdk/v2';
|
import type { Agent } from '@opencode-ai/sdk/v2';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
@@ -116,7 +117,15 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
|||||||
createAgent,
|
createAgent,
|
||||||
deleteAgent,
|
deleteAgent,
|
||||||
loadAgents,
|
loadAgents,
|
||||||
} = useAgentsStore();
|
} = useAgentsStore(useShallow((s) => ({
|
||||||
|
selectedAgentName: s.selectedAgentName,
|
||||||
|
agents: s.agents,
|
||||||
|
setSelectedAgent: s.setSelectedAgent,
|
||||||
|
setAgentDraft: s.setAgentDraft,
|
||||||
|
createAgent: s.createAgent,
|
||||||
|
deleteAgent: s.deleteAgent,
|
||||||
|
loadAgents: s.loadAgents,
|
||||||
|
})));
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
loadAgents();
|
loadAgents();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
@@ -73,7 +74,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||||||
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false);
|
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false);
|
||||||
const [searchQuery, setSearchQuery] = React.useState('');
|
const [searchQuery, setSearchQuery] = React.useState('');
|
||||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = React.useRef<(HTMLElement | null)[]>([]);
|
||||||
|
|
||||||
const allowedProviderSet = React.useMemo(() => {
|
const allowedProviderSet = React.useMemo(() => {
|
||||||
if (!Array.isArray(allowedProviderIds) || allowedProviderIds.length === 0) {
|
if (!Array.isArray(allowedProviderIds) || allowedProviderIds.length === 0) {
|
||||||
@@ -176,14 +177,14 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||||||
const showProviderLogo = keyPrefix === 'fav' || keyPrefix === 'recent';
|
const showProviderLogo = keyPrefix === 'fav' || keyPrefix === 'recent';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<DropdownMenuItem
|
||||||
key={`${keyPrefix}-${provID}-${modID}`}
|
key={`${keyPrefix}-${provID}-${modID}`}
|
||||||
ref={(el) => { itemRefs.current[flatIndex] = el; }}
|
ref={(el) => { itemRefs.current[flatIndex] = el; }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"typography-meta group flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer",
|
"group flex items-center gap-2",
|
||||||
isHighlighted ? "bg-interactive-selection" : "hover:bg-interactive-hover/50"
|
isHighlighted && "bg-interactive-selection"
|
||||||
)}
|
)}
|
||||||
onClick={() => handleProviderAndModelChange(provID, modID)}
|
onSelect={() => handleProviderAndModelChange(provID, modID)}
|
||||||
onMouseEnter={() => setSelectedIndex(flatIndex)}
|
onMouseEnter={() => setSelectedIndex(flatIndex)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||||
@@ -223,7 +224,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -613,19 +614,18 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||||||
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(100dvh-12rem))] flex-1">
|
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(100dvh-12rem))] flex-1">
|
||||||
<div className="p-1">
|
<div className="p-1">
|
||||||
{/* Not selected option */}
|
{/* Not selected option */}
|
||||||
<div
|
<DropdownMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
"typography-meta flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer",
|
"flex items-center gap-2",
|
||||||
"hover:bg-interactive-hover/50"
|
|
||||||
)}
|
)}
|
||||||
onClick={() => handleProviderAndModelChange('', '')}
|
onSelect={() => handleProviderAndModelChange('', '')}
|
||||||
>
|
>
|
||||||
<RiCloseLine className="h-3.5 w-3.5 text-muted-foreground" />
|
<RiCloseLine className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<span className="text-muted-foreground">{placeholder || t('settings.agents.modelSelector.notSelected')}</span>
|
<span className="text-muted-foreground">{placeholder || t('settings.agents.modelSelector.notSelected')}</span>
|
||||||
{!providerId && !modelId && (
|
{!providerId && !modelId && (
|
||||||
<RiCheckLine className="h-4 w-4 text-primary ml-auto" />
|
<RiCheckLine className="h-4 w-4 text-primary ml-auto" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore';
|
import { useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { RiTerminalBoxLine, RiUser3Line, RiFolderLine } from '@remixicon/react';
|
import { RiTerminalBoxLine, RiUser3Line, RiFolderLine } from '@remixicon/react';
|
||||||
import { ModelSelector } from '../agents/ModelSelector';
|
import { ModelSelector } from '../agents/ModelSelector';
|
||||||
import { AgentSelector } from './AgentSelector';
|
import { AgentSelector } from './AgentSelector';
|
||||||
@@ -19,7 +20,23 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
|
|
||||||
export const CommandsPage: React.FC = () => {
|
export const CommandsPage: React.FC = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { selectedCommandName, getCommandByName, createCommand, updateCommand, commands, commandDraft, setCommandDraft } = useCommandsStore();
|
const {
|
||||||
|
selectedCommandName,
|
||||||
|
getCommandByName,
|
||||||
|
createCommand,
|
||||||
|
updateCommand,
|
||||||
|
commands,
|
||||||
|
commandDraft,
|
||||||
|
setCommandDraft,
|
||||||
|
} = useCommandsStore(useShallow((s) => ({
|
||||||
|
selectedCommandName: s.selectedCommandName,
|
||||||
|
getCommandByName: s.getCommandByName,
|
||||||
|
createCommand: s.createCommand,
|
||||||
|
updateCommand: s.updateCommand,
|
||||||
|
commands: s.commands,
|
||||||
|
commandDraft: s.commandDraft,
|
||||||
|
setCommandDraft: s.setCommandDraft,
|
||||||
|
})));
|
||||||
|
|
||||||
const selectedCommand = selectedCommandName ? getCommandByName(selectedCommandName) : null;
|
const selectedCommand = selectedCommandName ? getCommandByName(selectedCommandName) : null;
|
||||||
const isNewCommand = Boolean(commandDraft && commandDraft.name === selectedCommandName && !selectedCommand);
|
const isNewCommand = Boolean(commandDraft && commandDraft.name === selectedCommandName && !selectedCommand);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { RiAddLine, RiTerminalBoxLine, RiMore2Line, RiDeleteBinLine, RiFileCopyLine, RiRestartLine, RiEditLine } from '@remixicon/react';
|
import { RiAddLine, RiTerminalBoxLine, RiMore2Line, RiDeleteBinLine, RiFileCopyLine, RiRestartLine, RiEditLine } from '@remixicon/react';
|
||||||
import { useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
|
import { useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
|
||||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
|
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
|
||||||
@@ -46,8 +47,17 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
|||||||
createCommand,
|
createCommand,
|
||||||
deleteCommand,
|
deleteCommand,
|
||||||
loadCommands,
|
loadCommands,
|
||||||
} = useCommandsStore();
|
} = useCommandsStore(useShallow((s) => ({
|
||||||
const { skills, loadSkills } = useSkillsStore();
|
selectedCommandName: s.selectedCommandName,
|
||||||
|
commands: s.commands,
|
||||||
|
setSelectedCommand: s.setSelectedCommand,
|
||||||
|
setCommandDraft: s.setCommandDraft,
|
||||||
|
createCommand: s.createCommand,
|
||||||
|
deleteCommand: s.deleteCommand,
|
||||||
|
loadCommands: s.loadCommands,
|
||||||
|
})));
|
||||||
|
const skills = useSkillsStore((s) => s.skills);
|
||||||
|
const loadSkills = useSkillsStore((s) => s.loadSkills);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
loadCommands();
|
loadCommands();
|
||||||
|
|||||||
@@ -58,12 +58,10 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
|||||||
importData,
|
importData,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const {
|
const getProfileById = useGitIdentitiesStore((s) => s.getProfileById);
|
||||||
getProfileById,
|
const createProfile = useGitIdentitiesStore((s) => s.createProfile);
|
||||||
createProfile,
|
const updateProfile = useGitIdentitiesStore((s) => s.updateProfile);
|
||||||
updateProfile,
|
const deleteProfile = useGitIdentitiesStore((s) => s.deleteProfile);
|
||||||
deleteProfile,
|
|
||||||
} = useGitIdentitiesStore();
|
|
||||||
|
|
||||||
const selectedProfile = React.useMemo(() =>
|
const selectedProfile = React.useMemo(() =>
|
||||||
profileId && profileId !== 'new' && !importData ? getProfileById(profileId) : null,
|
profileId && profileId !== 'new' && !importData ? getProfileById(profileId) : null,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
RiShieldKeyholeLine,
|
RiShieldKeyholeLine,
|
||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
import { useGitIdentitiesStore, type GitIdentityProfile, type DiscoveredGitCredential } from '@/stores/useGitIdentitiesStore';
|
import { useGitIdentitiesStore, type GitIdentityProfile, type DiscoveredGitCredential } from '@/stores/useGitIdentitiesStore';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { GitSettings } from '@/components/sections/openchamber/GitSettings';
|
import { GitSettings } from '@/components/sections/openchamber/GitSettings';
|
||||||
import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings';
|
import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings';
|
||||||
import { GitIdentityEditorDialog } from './GitIdentityEditorDialog';
|
import { GitIdentityEditorDialog } from './GitIdentityEditorDialog';
|
||||||
@@ -66,7 +67,18 @@ export const GitPage: React.FC = () => {
|
|||||||
loadDefaultGitIdentityId,
|
loadDefaultGitIdentityId,
|
||||||
setDefaultGitIdentityId,
|
setDefaultGitIdentityId,
|
||||||
getUnimportedCredentials,
|
getUnimportedCredentials,
|
||||||
} = useGitIdentitiesStore();
|
} = useGitIdentitiesStore(useShallow((s) => ({
|
||||||
|
profiles: s.profiles,
|
||||||
|
globalIdentity: s.globalIdentity,
|
||||||
|
defaultGitIdentityId: s.defaultGitIdentityId,
|
||||||
|
deleteProfile: s.deleteProfile,
|
||||||
|
loadProfiles: s.loadProfiles,
|
||||||
|
loadGlobalIdentity: s.loadGlobalIdentity,
|
||||||
|
loadDiscoveredCredentials: s.loadDiscoveredCredentials,
|
||||||
|
loadDefaultGitIdentityId: s.loadDefaultGitIdentityId,
|
||||||
|
setDefaultGitIdentityId: s.setDefaultGitIdentityId,
|
||||||
|
getUnimportedCredentials: s.getUnimportedCredentials,
|
||||||
|
})));
|
||||||
|
|
||||||
const [editorOpen, setEditorOpen] = React.useState(false);
|
const [editorOpen, setEditorOpen] = React.useState(false);
|
||||||
const [editorProfileId, setEditorProfileId] = React.useState<string | null>(null);
|
const [editorProfileId, setEditorProfileId] = React.useState<string | null>(null);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type McpDraft,
|
type McpDraft,
|
||||||
type McpScope,
|
type McpScope,
|
||||||
} from '@/stores/useMcpConfigStore';
|
} from '@/stores/useMcpConfigStore';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import {
|
import {
|
||||||
parseImportedMcpSnippet,
|
parseImportedMcpSnippet,
|
||||||
applyImportedMcpToDraft,
|
applyImportedMcpToDraft,
|
||||||
@@ -607,7 +608,17 @@ export const McpPage: React.FC = () => {
|
|||||||
createMcp,
|
createMcp,
|
||||||
updateMcp,
|
updateMcp,
|
||||||
deleteMcp,
|
deleteMcp,
|
||||||
} = useMcpConfigStore();
|
} = useMcpConfigStore(useShallow((s) => ({
|
||||||
|
selectedMcpName: s.selectedMcpName,
|
||||||
|
mcpServers: s.mcpServers,
|
||||||
|
mcpDraft: s.mcpDraft,
|
||||||
|
setMcpDraft: s.setMcpDraft,
|
||||||
|
setSelectedMcp: s.setSelectedMcp,
|
||||||
|
getMcpByName: s.getMcpByName,
|
||||||
|
createMcp: s.createMcp,
|
||||||
|
updateMcp: s.updateMcp,
|
||||||
|
deleteMcp: s.deleteMcp,
|
||||||
|
})));
|
||||||
|
|
||||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
const isVSCodeAuthRuntime = React.useMemo(() => isVSCodeRuntime(), []);
|
const isVSCodeAuthRuntime = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { RiAddLine, RiDeleteBinLine, RiMore2Line, RiPlugLine, RiRefreshLine, RiServerLine, RiGlobalLine } from '@remixicon/react';
|
import { RiAddLine, RiDeleteBinLine, RiMore2Line, RiPlugLine, RiRefreshLine, RiServerLine, RiGlobalLine } from '@remixicon/react';
|
||||||
import { useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore';
|
import { useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { useMcpStore } from '@/stores/useMcpStore';
|
import { useMcpStore } from '@/stores/useMcpStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { isMobileDeviceViaCSS } from '@/lib/device';
|
import { isMobileDeviceViaCSS } from '@/lib/device';
|
||||||
@@ -64,7 +65,14 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
|||||||
const bgClass = 'bg-background';
|
const bgClass = 'bg-background';
|
||||||
|
|
||||||
const { mcpServers, selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } =
|
const { mcpServers, selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } =
|
||||||
useMcpConfigStore();
|
useMcpConfigStore(useShallow((s) => ({
|
||||||
|
mcpServers: s.mcpServers,
|
||||||
|
selectedMcpName: s.selectedMcpName,
|
||||||
|
setSelectedMcp: s.setSelectedMcp,
|
||||||
|
setMcpDraft: s.setMcpDraft,
|
||||||
|
loadMcpConfigs: s.loadMcpConfigs,
|
||||||
|
deleteMcp: s.deleteMcp,
|
||||||
|
})));
|
||||||
|
|
||||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null));
|
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RiDiscordFill, RiDownloadLine, RiGithubFill, RiLoaderLine, RiTwitterXFill } from '@remixicon/react';
|
import { RiDiscordFill, RiDownloadLine, RiGithubFill, RiLoaderLine, RiTwitterXFill } from '@remixicon/react';
|
||||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
@@ -16,7 +17,19 @@ export const AboutSettings: React.FC = () => {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
||||||
const [showChecking, setShowChecking] = React.useState(false);
|
const [showChecking, setShowChecking] = React.useState(false);
|
||||||
const updateStore = useUpdateStore();
|
const updateStore = useUpdateStore(useShallow((s) => ({
|
||||||
|
info: s.info,
|
||||||
|
checking: s.checking,
|
||||||
|
available: s.available,
|
||||||
|
error: s.error,
|
||||||
|
downloading: s.downloading,
|
||||||
|
downloaded: s.downloaded,
|
||||||
|
progress: s.progress,
|
||||||
|
runtimeType: s.runtimeType,
|
||||||
|
checkForUpdates: s.checkForUpdates,
|
||||||
|
downloadUpdate: s.downloadUpdate,
|
||||||
|
restartToUpdate: s.restartToUpdate,
|
||||||
|
})));
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
|
|
||||||
const currentVersion = updateStore.info?.currentVersion || 'unknown';
|
const currentVersion = updateStore.info?.currentVersion || 'unknown';
|
||||||
|
|||||||
@@ -107,11 +107,11 @@ export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-fit min-w-20">
|
<DropdownMenuContent align="end" className="w-fit min-w-20">
|
||||||
{actions.map((action, index) => {
|
{actions.map((action) => {
|
||||||
const Icon = action.icon;
|
const Icon = action.icon;
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={index}
|
key={action.label}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
action.onClick();
|
action.onClick();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
|
import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { RiAddLine, RiBookOpenLine, RiDeleteBinLine, RiFileLine, RiFolderLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
|
import { RiAddLine, RiBookOpenLine, RiDeleteBinLine, RiFileLine, RiFolderLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import {
|
import {
|
||||||
@@ -39,17 +40,27 @@ const SkillsCatalogStandalone: React.FC = () => (
|
|||||||
|
|
||||||
const SkillsInstalledPage: React.FC = () => {
|
const SkillsInstalledPage: React.FC = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const {
|
const {
|
||||||
selectedSkillName,
|
selectedSkillName,
|
||||||
getSkillByName,
|
getSkillByName,
|
||||||
getSkillDetail,
|
getSkillDetail,
|
||||||
createSkill,
|
createSkill,
|
||||||
updateSkill,
|
updateSkill,
|
||||||
skills,
|
skills,
|
||||||
skillDraft,
|
skillDraft,
|
||||||
setSkillDraft,
|
setSkillDraft,
|
||||||
setSelectedSkill,
|
setSelectedSkill,
|
||||||
} = useSkillsStore();
|
} = useSkillsStore(useShallow((s) => ({
|
||||||
|
selectedSkillName: s.selectedSkillName,
|
||||||
|
getSkillByName: s.getSkillByName,
|
||||||
|
getSkillDetail: s.getSkillDetail,
|
||||||
|
createSkill: s.createSkill,
|
||||||
|
updateSkill: s.updateSkill,
|
||||||
|
skills: s.skills,
|
||||||
|
skillDraft: s.skillDraft,
|
||||||
|
setSkillDraft: s.setSkillDraft,
|
||||||
|
setSelectedSkill: s.setSelectedSkill,
|
||||||
|
})));
|
||||||
|
|
||||||
const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName) : null;
|
const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName) : null;
|
||||||
const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill);
|
const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { RiAddLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiEditLine, RiBookOpenLine } from '@remixicon/react';
|
import { RiAddLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiEditLine, RiBookOpenLine } from '@remixicon/react';
|
||||||
import { useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore';
|
import { useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
|
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
|
||||||
@@ -45,7 +46,15 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
|||||||
createSkill,
|
createSkill,
|
||||||
deleteSkill,
|
deleteSkill,
|
||||||
getSkillDetail,
|
getSkillDetail,
|
||||||
} = useSkillsStore();
|
} = useSkillsStore(useShallow((s) => ({
|
||||||
|
selectedSkillName: s.selectedSkillName,
|
||||||
|
skills: s.skills,
|
||||||
|
setSelectedSkill: s.setSelectedSkill,
|
||||||
|
setSkillDraft: s.setSkillDraft,
|
||||||
|
createSkill: s.createSkill,
|
||||||
|
deleteSkill: s.deleteSkill,
|
||||||
|
getSkillDetail: s.getSkillDetail,
|
||||||
|
})));
|
||||||
|
|
||||||
// Skills are loaded by the Settings shell when this page is active.
|
// Skills are loaded by the Settings shell when this page is active.
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ interface AddCatalogDialogProps {
|
|||||||
|
|
||||||
export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpenChange }) => {
|
export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpenChange }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { scanRepo, loadCatalog, isScanning } = useSkillsCatalogStore();
|
const scanRepo = useSkillsCatalogStore((s) => s.scanRepo);
|
||||||
|
const loadCatalog = useSkillsCatalogStore((s) => s.loadCatalog);
|
||||||
|
const isScanning = useSkillsCatalogStore((s) => s.isScanning);
|
||||||
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
|
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
|
||||||
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
|
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ type IdentityOption = { id: string; name: string };
|
|||||||
|
|
||||||
export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ open, onOpenChange }) => {
|
export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ open, onOpenChange }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { scanRepo, installSkills, isScanning, isInstalling } = useSkillsCatalogStore();
|
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 installedSkills = useSkillsStore((s) => s.skills);
|
||||||
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
|
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
|
||||||
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
|
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ interface InstallSkillDialogProps {
|
|||||||
|
|
||||||
export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, onOpenChange, item }) => {
|
export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, onOpenChange, item }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { installSkills, isInstalling } = useSkillsCatalogStore();
|
const installSkills = useSkillsCatalogStore((s) => s.installSkills);
|
||||||
|
const isInstalling = useSkillsCatalogStore((s) => s.isInstalling);
|
||||||
const [scope, setScope] = React.useState<'user' | 'project'>('user');
|
const [scope, setScope] = React.useState<'user' | 'project'>('user');
|
||||||
const [targetSource, setTargetSource] = React.useState<'opencode' | 'agents'>('opencode');
|
const [targetSource, setTargetSource] = React.useState<'opencode' | 'agents'>('opencode');
|
||||||
const projects = useProjectsStore((s) => s.projects);
|
const projects = useProjectsStore((s) => s.projects);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { RiAddLine, RiDeleteBinLine, RiRefreshLine, RiDownloadLine, RiStarLine, RiSearchLine } from '@remixicon/react';
|
import { RiAddLine, RiDeleteBinLine, RiRefreshLine, RiDownloadLine, RiStarLine, RiSearchLine } from '@remixicon/react';
|
||||||
|
|
||||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { SkillsCatalogItem } from '@/lib/api/types';
|
import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||||
|
|
||||||
@@ -81,7 +82,21 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
|||||||
loadedSourceIds,
|
loadedSourceIds,
|
||||||
clawdhubHasMoreBySource,
|
clawdhubHasMoreBySource,
|
||||||
lastCatalogError,
|
lastCatalogError,
|
||||||
} = useSkillsCatalogStore();
|
} = useSkillsCatalogStore(useShallow((s) => ({
|
||||||
|
sources: s.sources,
|
||||||
|
itemsBySource: s.itemsBySource,
|
||||||
|
selectedSourceId: s.selectedSourceId,
|
||||||
|
setSelectedSource: s.setSelectedSource,
|
||||||
|
loadCatalog: s.loadCatalog,
|
||||||
|
loadSource: s.loadSource,
|
||||||
|
loadMoreClawdHub: s.loadMoreClawdHub,
|
||||||
|
isLoadingCatalog: s.isLoadingCatalog,
|
||||||
|
isLoadingSource: s.isLoadingSource,
|
||||||
|
isLoadingMore: s.isLoadingMore,
|
||||||
|
loadedSourceIds: s.loadedSourceIds,
|
||||||
|
clawdhubHasMoreBySource: s.clawdhubHasMoreBySource,
|
||||||
|
lastCatalogError: s.lastCatalogError,
|
||||||
|
})));
|
||||||
|
|
||||||
const [search, setSearch] = React.useState('');
|
const [search, setSearch] = React.useState('');
|
||||||
const [addCatalogOpen, setAddCatalogOpen] = React.useState(false);
|
const [addCatalogOpen, setAddCatalogOpen] = React.useState(false);
|
||||||
|
|||||||
@@ -38,8 +38,11 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore();
|
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||||
const { addProject, getActiveProject } = useProjectsStore();
|
const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
|
||||||
|
const isHomeReady = useDirectoryStore((s) => s.isHomeReady);
|
||||||
|
const addProject = useProjectsStore((s) => s.addProject);
|
||||||
|
const getActiveProject = useProjectsStore((s) => s.getActiveProject);
|
||||||
const [pendingPath, setPendingPath] = React.useState<string | null>(null);
|
const [pendingPath, setPendingPath] = React.useState<string | null>(null);
|
||||||
const [pathInputValue, setPathInputValue] = React.useState('');
|
const [pathInputValue, setPathInputValue] = React.useState('');
|
||||||
const [hasUserSelection, setHasUserSelection] = React.useState(false);
|
const [hasUserSelection, setHasUserSelection] = React.useState(false);
|
||||||
|
|||||||
@@ -72,8 +72,12 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
const archiveSessions = useSessionUIStore((s) => s.archiveSessions);
|
const archiveSessions = useSessionUIStore((s) => s.archiveSessions);
|
||||||
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
||||||
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
|
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
|
||||||
const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore();
|
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||||
const { projects, addProject, activeProjectId } = useProjectsStore();
|
const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
|
||||||
|
const isHomeReady = useDirectoryStore((s) => s.isHomeReady);
|
||||||
|
const projects = useProjectsStore((s) => s.projects);
|
||||||
|
const addProject = useProjectsStore((s) => s.addProject);
|
||||||
|
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
|
||||||
const { requestAccess, startAccessing } = useFileSystemAccess();
|
const { requestAccess, startAccessing } = useFileSystemAccess();
|
||||||
const { isMobile, isTablet, hasTouchInput } = useDeviceInfo();
|
const { isMobile, isTablet, hasTouchInput } = useDeviceInfo();
|
||||||
const useMobileOverlay = isMobile || isTablet || hasTouchInput;
|
const useMobileOverlay = isMobile || isTablet || hasTouchInput;
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { SidebarFooter } from './sidebar/SidebarFooter';
|
|||||||
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
|
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
|
||||||
import { SessionNodeItem } from './sidebar/SessionNodeItem';
|
import { SessionNodeItem } from './sidebar/SessionNodeItem';
|
||||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||||
import type { WorktreeMetadata } from '@/types/worktree';
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
import type { SortableDragHandleProps } from './sidebar/sortableItems';
|
import type { SortableDragHandleProps } from './sidebar/sortableItems';
|
||||||
@@ -290,7 +291,18 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata);
|
const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata);
|
||||||
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||||
const updateStore = useUpdateStore();
|
const updateStore = useUpdateStore(useShallow((s) => ({
|
||||||
|
checkForUpdates: s.checkForUpdates,
|
||||||
|
available: s.available,
|
||||||
|
runtimeType: s.runtimeType,
|
||||||
|
info: s.info,
|
||||||
|
downloading: s.downloading,
|
||||||
|
downloaded: s.downloaded,
|
||||||
|
progress: s.progress,
|
||||||
|
error: s.error,
|
||||||
|
downloadUpdate: s.downloadUpdate,
|
||||||
|
restartToUpdate: s.restartToUpdate,
|
||||||
|
})));
|
||||||
|
|
||||||
const sessions = React.useMemo(() => {
|
const sessions = React.useMemo(() => {
|
||||||
const liveById = new Map(liveSessions.map((session) => [session.id, session]));
|
const liveById = new Map(liveSessions.map((session) => [session.id, session]));
|
||||||
|
|||||||
@@ -165,26 +165,32 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
|||||||
[compareSessionNodes, group.sessions, searchData?.filteredNodes, shouldFilterGroupContents],
|
[compareSessionNodes, group.sessions, searchData?.filteredNodes, shouldFilterGroupContents],
|
||||||
);
|
);
|
||||||
const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null);
|
const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null);
|
||||||
const scopeFolders = folderScopeKey ? getFoldersForScope(folderScopeKey) : [];
|
const scopeFolders = React.useMemo(
|
||||||
|
() => folderScopeKey ? getFoldersForScope(folderScopeKey) : [],
|
||||||
|
[folderScopeKey, getFoldersForScope]
|
||||||
|
);
|
||||||
|
|
||||||
const nodeBySessionId = new Map<string, SessionNode>();
|
const nodeBySessionId = React.useMemo(() => {
|
||||||
const collectNodeLookup = (nodes: SessionNode[]) => {
|
const map = new Map<string, SessionNode>();
|
||||||
nodes.forEach((node) => {
|
const collectNodeLookup = (nodes: SessionNode[]) => {
|
||||||
nodeBySessionId.set(node.session.id, node);
|
nodes.forEach((node) => {
|
||||||
if (node.children.length > 0) {
|
map.set(node.session.id, node);
|
||||||
collectNodeLookup(node.children);
|
if (node.children.length > 0) {
|
||||||
}
|
collectNodeLookup(node.children);
|
||||||
});
|
}
|
||||||
};
|
});
|
||||||
collectNodeLookup(sourceGroupNodes);
|
};
|
||||||
|
collectNodeLookup(sourceGroupNodes);
|
||||||
|
return map;
|
||||||
|
}, [sourceGroupNodes]);
|
||||||
|
|
||||||
const allFoldersForGroupBase = scopeFolders.map((folder) => {
|
const allFoldersForGroupBase = React.useMemo(() => scopeFolders.map((folder) => {
|
||||||
const nodes = folder.sessionIds
|
const nodes = folder.sessionIds
|
||||||
.map((sid) => nodeBySessionId.get(sid))
|
.map((sid) => nodeBySessionId.get(sid))
|
||||||
.filter((n): n is SessionNode => Boolean(n))
|
.filter((n): n is SessionNode => Boolean(n))
|
||||||
.sort(compareSessionNodes);
|
.sort(compareSessionNodes);
|
||||||
return { folder, nodes };
|
return { folder, nodes };
|
||||||
});
|
}), [scopeFolders, nodeBySessionId, compareSessionNodes]);
|
||||||
|
|
||||||
const allFoldersForGroup = React.useMemo(() => {
|
const allFoldersForGroup = React.useMemo(() => {
|
||||||
const folderMapById = new Map(allFoldersForGroupBase.map((entry) => [entry.folder.id, entry]));
|
const folderMapById = new Map(allFoldersForGroupBase.map((entry) => [entry.folder.id, entry]));
|
||||||
@@ -238,9 +244,9 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
|||||||
return allFoldersForGroupBase.filter(({ folder }) => shouldKeepFolder(folder.id));
|
return allFoldersForGroupBase.filter(({ folder }) => shouldKeepFolder(folder.id));
|
||||||
}, [allFoldersForGroupBase, group.isArchivedBucket, hasSessionSearchQuery, normalizedSessionSearchQuery]);
|
}, [allFoldersForGroupBase, group.isArchivedBucket, hasSessionSearchQuery, normalizedSessionSearchQuery]);
|
||||||
|
|
||||||
const sessionIdsInFolders = new Set(allFoldersForGroup.flatMap((f) => f.folder.sessionIds));
|
const sessionIdsInFolders = React.useMemo(() => new Set(allFoldersForGroup.flatMap((f) => f.folder.sessionIds)), [allFoldersForGroup]);
|
||||||
const ungroupedSessions = sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id));
|
const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]);
|
||||||
const rootFolders = allFoldersForGroup.filter(({ folder }) => !folder.parentId);
|
const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]);
|
||||||
|
|
||||||
if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) {
|
if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
|
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
|
||||||
|
import type { ChildSessionExport } from '@/lib/exportSession';
|
||||||
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context';
|
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context';
|
||||||
import { useSync } from '@/sync/use-sync';
|
import { useSync } from '@/sync/use-sync';
|
||||||
import { useViewportStore } from '@/sync/viewport-store';
|
import { useViewportStore } from '@/sync/viewport-store';
|
||||||
@@ -174,8 +177,20 @@ const areEqual = (prev: Props, next: Props): boolean => {
|
|||||||
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false;
|
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false;
|
||||||
if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false;
|
if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false;
|
||||||
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false;
|
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false;
|
||||||
if ((prev.editingId === prevSessionId) !== (next.editingId === nextSessionId)) return false;
|
if (prev.editingId !== next.editingId) {
|
||||||
if (prev.editTitle !== next.editTitle && ((prev.editingId === prevSessionId) || (next.editingId === nextSessionId))) return false;
|
const prevEditingInTree = treeContainsSessionId(prev.node, prev.editingId);
|
||||||
|
const nextEditingInTree = treeContainsSessionId(next.node, next.editingId);
|
||||||
|
if (prevEditingInTree || nextEditingInTree) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prev.editTitle !== next.editTitle) {
|
||||||
|
const prevEditingInTree = treeContainsSessionId(prev.node, prev.editingId);
|
||||||
|
const nextEditingInTree = treeContainsSessionId(next.node, next.editingId);
|
||||||
|
if (prevEditingInTree || nextEditingInTree) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if ((prev.copiedSessionId === prevSessionId) !== (next.copiedSessionId === nextSessionId)) return false;
|
if ((prev.copiedSessionId === prevSessionId) !== (next.copiedSessionId === nextSessionId)) return false;
|
||||||
|
|
||||||
const prevMenuInTree = treeContainsMenuKey(prev.node, prev.openSidebarMenuKey, prev.renderContext ?? 'project', prev.archivedBucket ?? false);
|
const prevMenuInTree = treeContainsMenuKey(prev.node, prev.openSidebarMenuKey, prev.renderContext ?? 'project', prev.archivedBucket ?? false);
|
||||||
@@ -267,6 +282,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
const liveSession = useSession(session.id);
|
const liveSession = useSession(session.id);
|
||||||
const resolvedSession = liveSession ?? session;
|
const resolvedSession = liveSession ?? session;
|
||||||
|
|
||||||
|
const sessionDirectory =
|
||||||
|
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
||||||
|
?? normalizePath(groupDirectory ?? null);
|
||||||
|
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined);
|
||||||
|
const sync = useSync();
|
||||||
|
|
||||||
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
|
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
|
||||||
const isRowSelected = useSessionMultiSelectStore(
|
const isRowSelected = useSessionMultiSelectStore(
|
||||||
React.useCallback((state) => state.selectedIds.has(session.id), [session.id]),
|
React.useCallback((state) => state.selectedIds.has(session.id), [session.id]),
|
||||||
@@ -285,15 +306,14 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
walk(root);
|
walk(root);
|
||||||
return out;
|
return out;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [exportDialogOpen, setExportDialogOpen] = React.useState(false);
|
||||||
|
const [exportIncludeSubtasks, setExportIncludeSubtasks] = React.useState(true);
|
||||||
|
|
||||||
const menuInstanceKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${session.id}`;
|
const menuInstanceKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${session.id}`;
|
||||||
const sessionDirectory =
|
|
||||||
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
|
||||||
?? normalizePath(groupDirectory ?? null);
|
|
||||||
const isZombie = useViewportStore(
|
const isZombie = useViewportStore(
|
||||||
React.useCallback((state) => Boolean(state.sessionMemoryState.get(session.id)?.isZombie), [session.id]),
|
React.useCallback((state) => Boolean(state.sessionMemoryState.get(session.id)?.isZombie), [session.id]),
|
||||||
);
|
);
|
||||||
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined);
|
|
||||||
const sync = useSync();
|
|
||||||
const sessionStatus = useGlobalSessionStatus(session.id);
|
const sessionStatus = useGlobalSessionStatus(session.id);
|
||||||
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined);
|
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined);
|
||||||
const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null;
|
const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null;
|
||||||
@@ -312,7 +332,41 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp);
|
const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp);
|
||||||
const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);
|
const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);
|
||||||
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
|
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
|
||||||
const handleExportSession = React.useCallback(async () => {
|
|
||||||
|
const descendantCount = React.useMemo(() => collectNodeDescendantIds(node).length, [collectNodeDescendantIds, node]);
|
||||||
|
|
||||||
|
const collectChildExports = React.useCallback(async (children: SessionNode[]): Promise<{ children: ChildSessionExport[]; skipped: number }> => {
|
||||||
|
const results: ChildSessionExport[] = [];
|
||||||
|
let skipped = 0;
|
||||||
|
for (const child of children) {
|
||||||
|
try {
|
||||||
|
await sync.syncSession(child.session.id);
|
||||||
|
const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list;
|
||||||
|
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
|
||||||
|
const childAgent = (child.session as Session & { agent?: string }).agent;
|
||||||
|
const grandChildren = await collectChildExports(child.children);
|
||||||
|
skipped += grandChildren.skipped;
|
||||||
|
results.push({
|
||||||
|
title: childTitle,
|
||||||
|
agent: childAgent,
|
||||||
|
records: childRecords,
|
||||||
|
children: grandChildren.children,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
skipped += collectNodeDescendantIds(child).length + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { children: results, skipped };
|
||||||
|
}, [collectNodeDescendantIds, directoryStore, sync, t]);
|
||||||
|
|
||||||
|
const showSkippedSubtasksWarning = React.useCallback((count: number) => {
|
||||||
|
if (count <= 0) return;
|
||||||
|
toast.warning(count === 1
|
||||||
|
? t('sessions.sidebar.session.export.skippedSubtaskSingle', { count })
|
||||||
|
: t('sessions.sidebar.session.export.skippedSubtaskMany', { count }));
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const doExportSession = React.useCallback(async (includeSubtasks: boolean) => {
|
||||||
if (!sessionDirectory) {
|
if (!sessionDirectory) {
|
||||||
toast.error(t('sessions.sidebar.session.export.nothingToExport'));
|
toast.error(t('sessions.sidebar.session.export.nothingToExport'));
|
||||||
return;
|
return;
|
||||||
@@ -326,7 +380,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const markdown = formatSessionAsMarkdown(records, resolvedSession.title ?? null);
|
let childExports: ChildSessionExport[] | undefined;
|
||||||
|
let skippedSubtaskCount = 0;
|
||||||
|
if (includeSubtasks && node.children.length > 0) {
|
||||||
|
const collected = await collectChildExports(node.children);
|
||||||
|
childExports = collected.children;
|
||||||
|
skippedSubtaskCount = collected.skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = formatSessionAsMarkdown(records, resolvedSession.title ?? null, childExports);
|
||||||
const filename = buildExportFilename(resolvedSession.title ?? null);
|
const filename = buildExportFilename(resolvedSession.title ?? null);
|
||||||
const savedPath = await saveAsMarkdownDesktop(markdown, filename);
|
const savedPath = await saveAsMarkdownDesktop(markdown, filename);
|
||||||
|
|
||||||
@@ -343,12 +405,22 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
showSkippedSubtasksWarning(skippedSubtaskCount);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadAsMarkdown(markdown, filename);
|
downloadAsMarkdown(markdown, filename);
|
||||||
toast.success(t('sessions.sidebar.session.export.success'));
|
toast.success(t('sessions.sidebar.session.export.success'));
|
||||||
}, [directoryStore, resolvedSession.title, session.id, sessionDirectory, sync, t]);
|
showSkippedSubtasksWarning(skippedSubtaskCount);
|
||||||
|
}, [collectChildExports, directoryStore, node.children, resolvedSession.title, session.id, sessionDirectory, showSkippedSubtasksWarning, sync, t]);
|
||||||
|
const handleExportSession = React.useCallback(async () => {
|
||||||
|
if (node.children.length > 0) {
|
||||||
|
setExportIncludeSubtasks(true);
|
||||||
|
setExportDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await doExportSession(false);
|
||||||
|
}, [doExportSession, node.children.length]);
|
||||||
|
|
||||||
if (editingId === session.id) {
|
if (editingId === session.id) {
|
||||||
return (
|
return (
|
||||||
@@ -808,6 +880,47 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
{hasChildren && isExpanded
|
{hasChildren && isExpanded
|
||||||
? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket, undefined, renderContext))
|
? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket, undefined, renderContext))
|
||||||
: null}
|
: null}
|
||||||
|
<Dialog open={exportDialogOpen} onOpenChange={setExportDialogOpen}>
|
||||||
|
<DialogContent showCloseButton={false} className="max-w-sm gap-5">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('sessions.sidebar.session.export.dialog.title')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{descendantCount === 1
|
||||||
|
? t('sessions.sidebar.session.export.dialog.descriptionSingle', { count: descendantCount })
|
||||||
|
: t('sessions.sidebar.session.export.dialog.descriptionMany', { count: descendantCount })}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<label className="flex items-center gap-2 typography-ui-label cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportIncludeSubtasks}
|
||||||
|
onChange={(e) => setExportIncludeSubtasks(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-border accent-primary"
|
||||||
|
/>
|
||||||
|
{t('sessions.sidebar.session.export.dialog.includeSubtasks')}
|
||||||
|
</label>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExportDialogOpen(false)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{t('sessions.sidebar.dialogs.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setExportDialogOpen(false);
|
||||||
|
void doExportSession(exportIncludeSubtasks);
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{t('sessions.sidebar.session.export.dialog.confirm')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class InnerErrorBoundary extends React.Component<InnerErrorBoundaryProps, ErrorB
|
|||||||
{this.state.error && (
|
{this.state.error && (
|
||||||
<details className="text-xs font-mono bg-muted p-3 rounded">
|
<details className="text-xs font-mono bg-muted p-3 rounded">
|
||||||
<summary className="cursor-pointer hover:bg-interactive-hover/80">{strings.detailsSummary}</summary>
|
<summary className="cursor-pointer hover:bg-interactive-hover/80">{strings.detailsSummary}</summary>
|
||||||
<pre className="mt-2 overflow-x-auto">
|
<pre className="mt-2 max-h-48 overflow-auto">
|
||||||
{this.state.error.toString()}
|
{this.state.error.toString()}
|
||||||
{this.state.errorInfo?.componentStack ? `\n\n${strings.componentStackLabel}${this.state.errorInfo.componentStack}` : ''}
|
{this.state.errorInfo?.componentStack ? `\n\n${strings.componentStackLabel}${this.state.errorInfo.componentStack}` : ''}
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@@ -250,14 +250,14 @@ export const HelpDialog: React.FC = () => {
|
|||||||
{t(section.categoryKey)}
|
{t(section.categoryKey)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{section.items.map((shortcut, index) => {
|
{section.items.map((shortcut) => {
|
||||||
const displayKeys = shortcut.id
|
const displayKeys = shortcut.id
|
||||||
? renderShortcut(shortcut.id, Array.isArray(shortcut.keys) ? shortcut.keys[0] : shortcut.keys, shortcutOverrides)
|
? renderShortcut(shortcut.id, Array.isArray(shortcut.keys) ? shortcut.keys[0] : shortcut.keys, shortcutOverrides)
|
||||||
: (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / "));
|
: (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / "));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={shortcut.id || shortcut.descriptionKey}
|
||||||
className="flex items-center justify-between py-1 px-2"
|
className="flex items-center justify-between py-1 px-2"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
|
|||||||
const frameRef = React.useRef<number | null>(null);
|
const frameRef = React.useRef<number | null>(null);
|
||||||
const metricsFrameRef = React.useRef<number | null>(null);
|
const metricsFrameRef = React.useRef<number | null>(null);
|
||||||
const isDraggingRef = React.useRef(false);
|
const isDraggingRef = React.useRef(false);
|
||||||
|
const isHoveringRef = React.useRef(false);
|
||||||
const lastUserIntentAtRef = React.useRef(0);
|
const lastUserIntentAtRef = React.useRef(0);
|
||||||
const dragStartRef = React.useRef<{
|
const dragStartRef = React.useRef<{
|
||||||
pointerX: number;
|
pointerX: number;
|
||||||
@@ -123,6 +124,10 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
|
|||||||
if (hideTimeoutRef.current) {
|
if (hideTimeoutRef.current) {
|
||||||
clearTimeout(hideTimeoutRef.current);
|
clearTimeout(hideTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
// Don't schedule hide if hovering over the thumb
|
||||||
|
if (isHoveringRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
hideTimeoutRef.current = setTimeout(() => setVisible(false), hideDelayMs);
|
hideTimeoutRef.current = setTimeout(() => setVisible(false), hideDelayMs);
|
||||||
}, [hideDelayMs]);
|
}, [hideDelayMs]);
|
||||||
|
|
||||||
@@ -287,6 +292,21 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
|
|||||||
scheduleHide();
|
scheduleHide();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleThumbMouseEnter = React.useCallback(() => {
|
||||||
|
isHoveringRef.current = true;
|
||||||
|
// Cancel any pending hide when hovering
|
||||||
|
if (hideTimeoutRef.current) {
|
||||||
|
clearTimeout(hideTimeoutRef.current);
|
||||||
|
hideTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleThumbMouseLeave = React.useCallback(() => {
|
||||||
|
isHoveringRef.current = false;
|
||||||
|
// Schedule hide when leaving the thumb
|
||||||
|
scheduleHide();
|
||||||
|
}, [scheduleHide]);
|
||||||
|
|
||||||
const showVertical = vertical.length > 0;
|
const showVertical = vertical.length > 0;
|
||||||
const showHorizontal = horizontal.length > 0;
|
const showHorizontal = horizontal.length > 0;
|
||||||
if (!showVertical && !showHorizontal) return null;
|
if (!showVertical && !showHorizontal) return null;
|
||||||
@@ -311,6 +331,8 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
|
|||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerCancel={handlePointerUp}
|
onPointerCancel={handlePointerUp}
|
||||||
|
onMouseEnter={handleThumbMouseEnter}
|
||||||
|
onMouseLeave={handleThumbMouseLeave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showHorizontal && (
|
{showHorizontal && (
|
||||||
@@ -325,6 +347,8 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
|
|||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerCancel={handlePointerUp}
|
onPointerCancel={handlePointerUp}
|
||||||
|
onMouseEnter={handleThumbMouseEnter}
|
||||||
|
onMouseLeave={handleThumbMouseLeave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ function DialogContent({
|
|||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
data-state-slot="dialog"
|
data-state-slot="dialog"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background text-foreground fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border p-6 shadow-none overflow-hidden pwa-dialog-content",
|
"bg-background text-foreground fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-lg max-h-[calc(100dvh-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border p-6 shadow-none overflow-y-auto pwa-dialog-content",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1761,6 +1761,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
}
|
}
|
||||||
}, [loadDirectory, root, toggleExpandedPath]);
|
}, [loadDirectory, root, toggleExpandedPath]);
|
||||||
|
|
||||||
|
const fileRowPermissions = React.useMemo(
|
||||||
|
() => ({ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }),
|
||||||
|
[canRename, canCreateFile, canCreateFolder, canDelete, canReveal]
|
||||||
|
);
|
||||||
|
|
||||||
function renderTree(dirPath: string, depth: number): React.ReactNode {
|
function renderTree(dirPath: string, depth: number): React.ReactNode {
|
||||||
const nodes = childrenByDir[dirPath] ?? [];
|
const nodes = childrenByDir[dirPath] ?? [];
|
||||||
|
|
||||||
@@ -1788,7 +1793,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
status={!isDir ? getFileStatus(node.path) : undefined}
|
status={!isDir ? getFileStatus(node.path) : undefined}
|
||||||
badge={isDir ? getFolderBadge(node.path) : undefined}
|
badge={isDir ? getFolderBadge(node.path) : undefined}
|
||||||
permissions={{ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }}
|
permissions={fileRowPermissions}
|
||||||
downloadFile={files.downloadFile}
|
downloadFile={files.downloadFile}
|
||||||
contextMenuPath={contextMenuPath}
|
contextMenuPath={contextMenuPath}
|
||||||
setContextMenuPath={setContextMenuPath}
|
setContextMenuPath={setContextMenuPath}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
|||||||
import { useFireworksCelebration } from '@/contexts/FireworksContext';
|
import { useFireworksCelebration } from '@/contexts/FireworksContext';
|
||||||
import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types';
|
import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types';
|
||||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import {
|
import {
|
||||||
@@ -272,7 +273,14 @@ export const GitView: React.FC = () => {
|
|||||||
}, [currentSessionId, inferredWorktreeMetadata, newSessionDraft?.open, worktreeMap]);
|
}, [currentSessionId, inferredWorktreeMetadata, newSessionDraft?.open, worktreeMap]);
|
||||||
|
|
||||||
const { profiles, globalIdentity, defaultGitIdentityId, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId } =
|
const { profiles, globalIdentity, defaultGitIdentityId, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId } =
|
||||||
useGitIdentitiesStore();
|
useGitIdentitiesStore(useShallow((s) => ({
|
||||||
|
profiles: s.profiles,
|
||||||
|
globalIdentity: s.globalIdentity,
|
||||||
|
defaultGitIdentityId: s.defaultGitIdentityId,
|
||||||
|
loadProfiles: s.loadProfiles,
|
||||||
|
loadGlobalIdentity: s.loadGlobalIdentity,
|
||||||
|
loadDefaultGitIdentityId: s.loadDefaultGitIdentityId,
|
||||||
|
})));
|
||||||
|
|
||||||
const isGitRepo = useIsGitRepo(currentDirectory ?? null);
|
const isGitRepo = useIsGitRepo(currentDirectory ?? null);
|
||||||
const status = useGitStatus(currentDirectory ?? null);
|
const status = useGitStatus(currentDirectory ?? null);
|
||||||
|
|||||||
@@ -100,17 +100,16 @@ export const TerminalView: React.FC = () => {
|
|||||||
const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true;
|
const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true;
|
||||||
|
|
||||||
const effectiveDirectory = useEffectiveDirectory() ?? null;
|
const effectiveDirectory = useEffectiveDirectory() ?? null;
|
||||||
const terminalStore = useTerminalStore();
|
const terminalSessions = useTerminalStore((s) => s.sessions);
|
||||||
const terminalSessions = terminalStore.sessions;
|
const terminalHydrated = useTerminalStore((s) => s.hasHydrated);
|
||||||
const terminalHydrated = terminalStore.hasHydrated;
|
const ensureDirectory = useTerminalStore((s) => s.ensureDirectory);
|
||||||
const ensureDirectory = terminalStore.ensureDirectory;
|
const createTab = useTerminalStore((s) => s.createTab);
|
||||||
const createTab = terminalStore.createTab;
|
const setActiveTab = useTerminalStore((s) => s.setActiveTab);
|
||||||
const setActiveTab = terminalStore.setActiveTab;
|
const closeTab = useTerminalStore((s) => s.closeTab);
|
||||||
const closeTab = terminalStore.closeTab;
|
const setTabSessionId = useTerminalStore((s) => s.setTabSessionId);
|
||||||
const setTabSessionId = terminalStore.setTabSessionId;
|
const setTabLifecycle = useTerminalStore((s) => s.setTabLifecycle);
|
||||||
const setTabLifecycle = terminalStore.setTabLifecycle;
|
const setConnecting = useTerminalStore((s) => s.setConnecting);
|
||||||
const setConnecting = terminalStore.setConnecting;
|
const appendToBuffer = useTerminalStore((s) => s.appendToBuffer);
|
||||||
const appendToBuffer = terminalStore.appendToBuffer;
|
|
||||||
|
|
||||||
const directoryTerminalState = React.useMemo(() => {
|
const directoryTerminalState = React.useMemo(() => {
|
||||||
if (!effectiveDirectory) return undefined;
|
if (!effectiveDirectory) return undefined;
|
||||||
|
|||||||
@@ -456,11 +456,14 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
|||||||
<CollapsibleTrigger className="w-full flex items-center justify-between py-1 hover:bg-[var(--interactive-hover)] rounded-md px-1 -mx-1 transition-colors">
|
<CollapsibleTrigger className="w-full flex items-center justify-between py-1 hover:bg-[var(--interactive-hover)] rounded-md px-1 -mx-1 transition-colors">
|
||||||
<p className="typography-ui-label font-medium text-foreground">
|
<p className="typography-ui-label font-medium text-foreground">
|
||||||
{t('agentManager.empty.setupCommands.label')}
|
{t('agentManager.empty.setupCommands.label')}
|
||||||
{setupCommands.filter(cmd => cmd.trim()).length > 0 && (
|
{(() => {
|
||||||
<span className="font-normal text-muted-foreground/70">
|
const trimmedCommandCount = setupCommands.filter(cmd => cmd.trim()).length;
|
||||||
{' '}({t('agentManager.empty.setupCommands.configured', { count: setupCommands.filter(cmd => cmd.trim()).length })})
|
return trimmedCommandCount > 0 ? (
|
||||||
</span>
|
<span className="font-normal text-muted-foreground/70">
|
||||||
)}
|
{' '}({t('agentManager.empty.setupCommands.configured', { count: trimmedCommandCount })})
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</p>
|
</p>
|
||||||
<RiArrowDownSLine className={cn(
|
<RiArrowDownSLine className={cn(
|
||||||
'h-4 w-4 text-muted-foreground transition-transform duration-200',
|
'h-4 w-4 text-muted-foreground transition-transform duration-200',
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const AIHighlightsBox: React.FC<AIHighlightsBoxProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{highlights.map((highlight, index) => (
|
{highlights.map((highlight, index) => (
|
||||||
<li key={index} className="typography-meta text-foreground">
|
<li key={`${highlight}-${index}`} className="typography-meta text-foreground">
|
||||||
{highlight}
|
{highlight}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -204,9 +204,9 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-[var(--surface-elevated)] rounded-lg p-3 max-h-40 overflow-y-auto overflow-x-hidden">
|
<div className="bg-[var(--surface-elevated)] rounded-lg p-3 max-h-40 overflow-y-auto overflow-x-hidden">
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{displayFiles.map((file, index) => (
|
{displayFiles.map((file) => (
|
||||||
<li
|
<li
|
||||||
key={index}
|
key={file}
|
||||||
className="typography-micro text-foreground font-mono truncate block"
|
className="typography-micro text-foreground font-mono truncate block"
|
||||||
title={file}
|
title={file}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -46,14 +46,14 @@ function getChangeTypeColor(changeType: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HistoryCommitRow: React.FC<HistoryCommitRowProps> = ({
|
export const HistoryCommitRow = React.memo(({
|
||||||
entry,
|
entry,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
files,
|
files,
|
||||||
isLoadingFiles,
|
isLoadingFiles,
|
||||||
onCopyHash,
|
onCopyHash,
|
||||||
}) => {
|
}: HistoryCommitRowProps) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
@@ -159,4 +159,4 @@ export const HistoryCommitRow: React.FC<HistoryCommitRowProps> = ({
|
|||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|||||||
@@ -342,8 +342,12 @@ export const useKeyboardShortcuts = () => {
|
|||||||
const target = e.target as Element | null;
|
const target = e.target as Element | null;
|
||||||
const isInsideDialog = Boolean(target?.closest('[role="dialog"]'));
|
const isInsideDialog = Boolean(target?.closest('[role="dialog"]'));
|
||||||
const isSettingsMounted = Boolean(document.querySelector('[data-settings-view="true"]'));
|
const isSettingsMounted = Boolean(document.querySelector('[data-settings-view="true"]'));
|
||||||
|
const isInsideTerminal = Boolean(
|
||||||
|
target?.closest('.terminal-viewport-container') ||
|
||||||
|
target?.getAttribute('data-terminal-hidden-input') === 'true'
|
||||||
|
);
|
||||||
|
|
||||||
if (isInsideDialog || isSettingsMounted) {
|
if (isInsideDialog || isSettingsMounted || isInsideTerminal) {
|
||||||
resetAbortPriming();
|
resetAbortPriming();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export const useMenuActions = (
|
|||||||
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
|
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
|
||||||
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
|
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
|
||||||
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
|
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
|
||||||
const { addProject } = useProjectsStore();
|
const addProject = useProjectsStore((s) => s.addProject);
|
||||||
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
|
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
|
||||||
const { requestAccess, startAccessing } = useFileSystemAccess();
|
const { requestAccess, startAccessing } = useFileSystemAccess();
|
||||||
const { setThemeMode } = useThemeSystem();
|
const { setThemeMode } = useThemeSystem();
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects mobile keyboard open/close via the Visual Viewport API and manages:
|
||||||
|
* - `--oc-keyboard-inset` CSS variable on :root
|
||||||
|
* - `--oc-visual-viewport-height` / `--oc-visual-viewport-offset-top` CSS variables
|
||||||
|
* - `--oc-keyboard-home-indicator` CSS variable (iOS home bar)
|
||||||
|
* - `--oc-keyboard-avoid-offset` on the active input's keyboard-avoid target
|
||||||
|
* - `isKeyboardOpen` state in the UI store
|
||||||
|
*
|
||||||
|
* Platform-specific handling:
|
||||||
|
* - iOS: sticky inset with home indicator padding
|
||||||
|
* - Android: resize-based detection with `maxObservedLayoutHeight` tracking
|
||||||
|
*/
|
||||||
|
export function useMobileKeyboardManager(isMobile: boolean): void {
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
let stickyKeyboardInset = 0;
|
||||||
|
let ignoreOpenUntilZero = false;
|
||||||
|
let previousHeight = 0;
|
||||||
|
let maxObservedLayoutHeight = 0;
|
||||||
|
let previousOrientation = '';
|
||||||
|
let keyboardAvoidTarget: HTMLElement | null = null;
|
||||||
|
|
||||||
|
const setKeyboardOpen = useUIStore.getState().setKeyboardOpen;
|
||||||
|
const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent;
|
||||||
|
const isAndroid = /Android/i.test(userAgent);
|
||||||
|
const isIOS = /iPad|iPhone|iPod/.test(userAgent);
|
||||||
|
|
||||||
|
const clearKeyboardAvoidTarget = () => {
|
||||||
|
if (!keyboardAvoidTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
keyboardAvoidTarget.style.setProperty('--oc-keyboard-avoid-offset', '0px');
|
||||||
|
keyboardAvoidTarget.removeAttribute('data-keyboard-avoid-active');
|
||||||
|
keyboardAvoidTarget = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveKeyboardAvoidTarget = (active: HTMLElement | null) => {
|
||||||
|
if (!active) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const explicitTargetId = active.getAttribute('data-keyboard-avoid-target-id');
|
||||||
|
if (explicitTargetId) {
|
||||||
|
const explicitTarget = document.getElementById(explicitTargetId);
|
||||||
|
if (explicitTarget instanceof HTMLElement) {
|
||||||
|
return explicitTarget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const markedTarget = active.closest('[data-keyboard-avoid]') as HTMLElement | null;
|
||||||
|
if (markedTarget) {
|
||||||
|
if (markedTarget.getAttribute('data-keyboard-avoid') === 'none') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return markedTarget;
|
||||||
|
}
|
||||||
|
if (active.classList.contains('overlay-scrollbar-container')) {
|
||||||
|
const parent = active.parentElement;
|
||||||
|
if (parent instanceof HTMLElement) {
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return active;
|
||||||
|
};
|
||||||
|
|
||||||
|
const forceKeyboardClosed = () => {
|
||||||
|
stickyKeyboardInset = 0;
|
||||||
|
ignoreOpenUntilZero = true;
|
||||||
|
root.style.setProperty('--oc-keyboard-inset', '0px');
|
||||||
|
setKeyboardOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTextInputTarget = (element: HTMLElement | null) => {
|
||||||
|
if (!element) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const tagName = element.tagName;
|
||||||
|
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
|
||||||
|
return isInput || element.isContentEditable;
|
||||||
|
};
|
||||||
|
|
||||||
|
let rafId = 0;
|
||||||
|
|
||||||
|
const updateVisualViewport = () => {
|
||||||
|
const viewport = window.visualViewport;
|
||||||
|
|
||||||
|
const height = viewport ? Math.round(viewport.height) : window.innerHeight;
|
||||||
|
const offsetTop = viewport ? Math.max(0, Math.round(viewport.offsetTop)) : 0;
|
||||||
|
const orientation = window.innerWidth >= window.innerHeight ? 'landscape' : 'portrait';
|
||||||
|
|
||||||
|
root.style.setProperty('--oc-visual-viewport-offset-top', `${offsetTop}px`);
|
||||||
|
root.style.setProperty('--oc-visual-viewport-height', `${height}px`);
|
||||||
|
|
||||||
|
const active = document.activeElement as HTMLElement | null;
|
||||||
|
const isTextTarget = isTextInputTarget(active);
|
||||||
|
|
||||||
|
const layoutHeight = Math.round(root.clientHeight || window.innerHeight);
|
||||||
|
if (previousOrientation !== orientation) {
|
||||||
|
previousOrientation = orientation;
|
||||||
|
maxObservedLayoutHeight = layoutHeight;
|
||||||
|
} else if (layoutHeight > maxObservedLayoutHeight || maxObservedLayoutHeight === 0) {
|
||||||
|
maxObservedLayoutHeight = layoutHeight;
|
||||||
|
}
|
||||||
|
const viewportSum = height + offsetTop;
|
||||||
|
const rawInset = Math.max(0, layoutHeight - viewportSum);
|
||||||
|
const rawAndroidResizeInset = isAndroid
|
||||||
|
? Math.max(0, maxObservedLayoutHeight - layoutHeight)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const openThreshold = isTextTarget ? 120 : 180;
|
||||||
|
const measuredInset = rawInset >= openThreshold ? rawInset : 0;
|
||||||
|
const androidResizeInset = isTextTarget && rawAndroidResizeInset >= openThreshold
|
||||||
|
? rawAndroidResizeInset
|
||||||
|
: 0;
|
||||||
|
const effectiveMeasuredInset = Math.max(measuredInset, androidResizeInset);
|
||||||
|
|
||||||
|
if (ignoreOpenUntilZero) {
|
||||||
|
if (effectiveMeasuredInset === 0) {
|
||||||
|
ignoreOpenUntilZero = false;
|
||||||
|
}
|
||||||
|
stickyKeyboardInset = 0;
|
||||||
|
} else if (stickyKeyboardInset === 0) {
|
||||||
|
if (effectiveMeasuredInset > 0 && isTextTarget) {
|
||||||
|
stickyKeyboardInset = effectiveMeasuredInset;
|
||||||
|
setKeyboardOpen(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const closingByHeight = !isTextTarget && height > previousHeight + 6;
|
||||||
|
|
||||||
|
if (effectiveMeasuredInset === 0) {
|
||||||
|
stickyKeyboardInset = 0;
|
||||||
|
setKeyboardOpen(false);
|
||||||
|
} else if (closingByHeight) {
|
||||||
|
forceKeyboardClosed();
|
||||||
|
} else if (effectiveMeasuredInset > 0 && isTextTarget) {
|
||||||
|
stickyKeyboardInset = effectiveMeasuredInset;
|
||||||
|
setKeyboardOpen(true);
|
||||||
|
} else if (effectiveMeasuredInset > stickyKeyboardInset) {
|
||||||
|
stickyKeyboardInset = effectiveMeasuredInset;
|
||||||
|
setKeyboardOpen(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
root.style.setProperty('--oc-keyboard-inset', `${stickyKeyboardInset}px`);
|
||||||
|
previousHeight = height;
|
||||||
|
|
||||||
|
const keyboardHomeIndicator = isIOS && stickyKeyboardInset > 0 ? 34 : 0;
|
||||||
|
root.style.setProperty('--oc-keyboard-home-indicator', `${keyboardHomeIndicator}px`);
|
||||||
|
|
||||||
|
const avoidTarget = isTextTarget ? resolveKeyboardAvoidTarget(active) : null;
|
||||||
|
|
||||||
|
if (!isMobile || !avoidTarget || !active) {
|
||||||
|
clearKeyboardAvoidTarget();
|
||||||
|
} else {
|
||||||
|
if (avoidTarget !== keyboardAvoidTarget) {
|
||||||
|
clearKeyboardAvoidTarget();
|
||||||
|
keyboardAvoidTarget = avoidTarget;
|
||||||
|
}
|
||||||
|
const viewportBottom = offsetTop + height;
|
||||||
|
const rect = active.getBoundingClientRect();
|
||||||
|
const overlap = rect.bottom - viewportBottom;
|
||||||
|
const clearance = 8;
|
||||||
|
const keyboardInset = Math.max(stickyKeyboardInset, effectiveMeasuredInset);
|
||||||
|
const avoidOffset = overlap > clearance && keyboardInset > 0
|
||||||
|
? Math.min(overlap, keyboardInset)
|
||||||
|
: 0;
|
||||||
|
const target = keyboardAvoidTarget;
|
||||||
|
if (target) {
|
||||||
|
target.style.setProperty('--oc-keyboard-avoid-offset', `${avoidOffset}px`);
|
||||||
|
target.setAttribute('data-keyboard-avoid-active', 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile && isTextTarget) {
|
||||||
|
const scroller = document.scrollingElement;
|
||||||
|
if (scroller && scroller.scrollTop !== 0) {
|
||||||
|
scroller.scrollTop = 0;
|
||||||
|
}
|
||||||
|
if (window.scrollY !== 0) {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleVisualViewportUpdate = () => {
|
||||||
|
if (rafId) return;
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
rafId = 0;
|
||||||
|
updateVisualViewport();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateVisualViewport();
|
||||||
|
|
||||||
|
const viewport = window.visualViewport;
|
||||||
|
viewport?.addEventListener('resize', scheduleVisualViewportUpdate);
|
||||||
|
viewport?.addEventListener('scroll', scheduleVisualViewportUpdate);
|
||||||
|
window.addEventListener('resize', scheduleVisualViewportUpdate);
|
||||||
|
window.addEventListener('orientationchange', scheduleVisualViewportUpdate);
|
||||||
|
|
||||||
|
const handleFocusIn = (event: FocusEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (isTextInputTarget(target)) {
|
||||||
|
ignoreOpenUntilZero = false;
|
||||||
|
}
|
||||||
|
scheduleVisualViewportUpdate();
|
||||||
|
};
|
||||||
|
document.addEventListener('focusin', handleFocusIn, true);
|
||||||
|
|
||||||
|
const handleFocusOut = (event: FocusEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (!isTextInputTarget(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const related = event.relatedTarget as HTMLElement | null;
|
||||||
|
if (isTextInputTarget(related)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
if (isTextInputTarget(document.activeElement as HTMLElement | null)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentViewport = window.visualViewport;
|
||||||
|
const height = currentViewport ? Math.round(currentViewport.height) : window.innerHeight;
|
||||||
|
const offsetTop = currentViewport ? Math.max(0, Math.round(currentViewport.offsetTop)) : 0;
|
||||||
|
const layoutHeight = Math.round(root.clientHeight || window.innerHeight);
|
||||||
|
const viewportSum = height + offsetTop;
|
||||||
|
const rawInset = Math.max(0, layoutHeight - viewportSum);
|
||||||
|
|
||||||
|
if (rawInset > 0) {
|
||||||
|
updateVisualViewport();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
forceKeyboardClosed();
|
||||||
|
updateVisualViewport();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('focusout', handleFocusOut, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
viewport?.removeEventListener('resize', scheduleVisualViewportUpdate);
|
||||||
|
viewport?.removeEventListener('scroll', scheduleVisualViewportUpdate);
|
||||||
|
window.removeEventListener('resize', scheduleVisualViewportUpdate);
|
||||||
|
window.removeEventListener('orientationchange', scheduleVisualViewportUpdate);
|
||||||
|
document.removeEventListener('focusin', handleFocusIn, true);
|
||||||
|
document.removeEventListener('focusout', handleFocusOut, true);
|
||||||
|
clearKeyboardAvoidTarget();
|
||||||
|
};
|
||||||
|
}, [isMobile]);
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ describe('resolveDesktopBootView', () => {
|
|||||||
isDesktopShell: true,
|
isDesktopShell: true,
|
||||||
bootOutcome: { target: 'local', status: 'unreachable' },
|
bootOutcome: { target: 'local', status: 'unreachable' },
|
||||||
}),
|
}),
|
||||||
).toEqual({ screen: 'recovery', variant: 'local-unreachable' });
|
).toEqual({ screen: 'recovery', variant: 'local-unavailable' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns recovery view for remote missing', () => {
|
test('returns recovery view for remote missing', () => {
|
||||||
@@ -257,9 +257,9 @@ describe('getInjectedBootOutcome', () => {
|
|||||||
|
|
||||||
test('returns valid outcome for well-formed main-local', () => {
|
test('returns valid outcome for well-formed main-local', () => {
|
||||||
const w = mockWindow();
|
const w = mockWindow();
|
||||||
w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { kind: 'main-local' };
|
w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { target: 'local', status: 'ok' };
|
||||||
try {
|
try {
|
||||||
expect(getInjectedBootOutcome()).toEqual({ kind: 'main-local' });
|
expect(getInjectedBootOutcome()).toEqual({ target: 'local', status: 'ok' });
|
||||||
} finally {
|
} finally {
|
||||||
restoreWindow();
|
restoreWindow();
|
||||||
}
|
}
|
||||||
@@ -339,7 +339,7 @@ describe('getBootInjectionStatus', () => {
|
|||||||
|
|
||||||
test('returns "valid" when global is present and well-formed', () => {
|
test('returns "valid" when global is present and well-formed', () => {
|
||||||
const w = mockWindow();
|
const w = mockWindow();
|
||||||
w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { kind: 'main-local' };
|
w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { target: 'local', status: 'ok' };
|
||||||
try {
|
try {
|
||||||
expect(getBootInjectionStatus()).toBe('valid');
|
expect(getBootInjectionStatus()).toBe('valid');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import { getRevealLabelKey } from '@/lib/utils';
|
|||||||
|
|
||||||
type SessionMessageRecord = { info: Message; parts: Part[] };
|
type SessionMessageRecord = { info: Message; parts: Part[] };
|
||||||
|
|
||||||
|
export type ChildSessionExport = {
|
||||||
|
title: string;
|
||||||
|
agent?: string;
|
||||||
|
records: SessionMessageRecord[];
|
||||||
|
children: ChildSessionExport[];
|
||||||
|
};
|
||||||
|
|
||||||
function formatTimestamp(timestamp: number | undefined): string {
|
function formatTimestamp(timestamp: number | undefined): string {
|
||||||
if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) {
|
if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) {
|
||||||
return '';
|
return '';
|
||||||
@@ -65,9 +72,29 @@ function formatMessageAsMarkdown(record: SessionMessageRecord): string {
|
|||||||
return `${role}\n\n${text}`;
|
return `${role}\n\n${text}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatChildSessionAsMarkdown(child: ChildSessionExport, depth: number): string {
|
||||||
|
const heading = '#'.repeat(Math.min(depth + 1, 6));
|
||||||
|
const agentLabel = child.agent ? ` — ${child.agent}` : '';
|
||||||
|
const childHeader = `${heading} Sub-agent: ${child.title}${agentLabel}\n\n---\n\n`;
|
||||||
|
|
||||||
|
const childBody = child.records
|
||||||
|
.map(formatMessageAsMarkdown)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n---\n\n');
|
||||||
|
|
||||||
|
const parts = [childHeader + childBody];
|
||||||
|
|
||||||
|
for (const grandchild of child.children) {
|
||||||
|
parts.push(formatChildSessionAsMarkdown(grandchild, depth + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('\n\n---\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
export function formatSessionAsMarkdown(
|
export function formatSessionAsMarkdown(
|
||||||
messages: SessionMessageRecord[],
|
messages: SessionMessageRecord[],
|
||||||
sessionTitle?: string | null,
|
sessionTitle?: string | null,
|
||||||
|
childSessions?: ChildSessionExport[],
|
||||||
): string {
|
): string {
|
||||||
const title = sessionTitle?.trim() || 'Session';
|
const title = sessionTitle?.trim() || 'Session';
|
||||||
const date = new Date().toISOString().split('T')[0];
|
const date = new Date().toISOString().split('T')[0];
|
||||||
@@ -79,7 +106,16 @@ export function formatSessionAsMarkdown(
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n\n---\n\n');
|
.join('\n\n---\n\n');
|
||||||
|
|
||||||
return header + body;
|
let result = header + body;
|
||||||
|
|
||||||
|
if (childSessions && childSessions.length > 0) {
|
||||||
|
const childMarkdown = childSessions
|
||||||
|
.map((child) => formatChildSessionAsMarkdown(child, 1))
|
||||||
|
.join('\n\n---\n\n');
|
||||||
|
result += '\n\n---\n\n' + childMarkdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadAsMarkdown(content: string, filename: string): void {
|
export function downloadAsMarkdown(content: string, filename: string): void {
|
||||||
|
|||||||
@@ -236,6 +236,14 @@ export const dict = {
|
|||||||
'sessions.sidebar.session.export.nothingToExport': 'Nothing to export',
|
'sessions.sidebar.session.export.nothingToExport': 'Nothing to export',
|
||||||
'sessions.sidebar.session.export.success': 'Session exported',
|
'sessions.sidebar.session.export.success': 'Session exported',
|
||||||
'sessions.sidebar.session.export.failedRevealPath': 'Failed to reveal path',
|
'sessions.sidebar.session.export.failedRevealPath': 'Failed to reveal path',
|
||||||
|
'sessions.sidebar.session.export.untitledSubagent': 'Untitled Sub-agent',
|
||||||
|
'sessions.sidebar.session.export.skippedSubtaskSingle': 'Exported session, but skipped {count} sub-agent task that could not be loaded.',
|
||||||
|
'sessions.sidebar.session.export.skippedSubtaskMany': 'Exported session, but skipped {count} sub-agent tasks that could not be loaded.',
|
||||||
|
'sessions.sidebar.session.export.dialog.title': 'Export Markdown',
|
||||||
|
'sessions.sidebar.session.export.dialog.descriptionSingle': 'This session has {count} sub-agent task. Include it in the export?',
|
||||||
|
'sessions.sidebar.session.export.dialog.descriptionMany': 'This session has {count} sub-agent tasks. Include them in the export?',
|
||||||
|
'sessions.sidebar.session.export.dialog.includeSubtasks': 'Include sub-agent tasks',
|
||||||
|
'sessions.sidebar.session.export.dialog.confirm': 'Export',
|
||||||
'sessions.sidebar.session.status.active': 'Session active',
|
'sessions.sidebar.session.status.active': 'Session active',
|
||||||
'sessions.sidebar.session.status.unread': 'Unread updates',
|
'sessions.sidebar.session.status.unread': 'Unread updates',
|
||||||
'sessions.sidebar.session.status.pinned': 'Pinned session',
|
'sessions.sidebar.session.status.pinned': 'Pinned session',
|
||||||
@@ -1958,6 +1966,10 @@ export const dict = {
|
|||||||
'onboarding.desktopRecovery.common.useRemote': 'Use Remote',
|
'onboarding.desktopRecovery.common.useRemote': 'Use Remote',
|
||||||
'onboarding.desktopRecovery.actions.retrying': 'Retrying…',
|
'onboarding.desktopRecovery.actions.retrying': 'Retrying…',
|
||||||
'onboarding.desktopRecovery.actions.retryConnection': 'Retry Connection',
|
'onboarding.desktopRecovery.actions.retryConnection': 'Retry Connection',
|
||||||
|
'startup.initRecovery.title': 'Startup failed',
|
||||||
|
'startup.initRecovery.description': 'OpenChamber could not finish initialization. Check that the server is running, then retry.',
|
||||||
|
'startup.initRecovery.retry': 'Retry',
|
||||||
|
'startup.initRecovery.retrying': 'Retrying…',
|
||||||
'onboarding.desktopRecovery.placeholders.remoteServer': 'the remote server',
|
'onboarding.desktopRecovery.placeholders.remoteServer': 'the remote server',
|
||||||
'onboarding.desktopRecovery.placeholders.unknownServer': 'unknown',
|
'onboarding.desktopRecovery.placeholders.unknownServer': 'unknown',
|
||||||
'vscodeLayout.title.chat': 'Chat',
|
'vscodeLayout.title.chat': 'Chat',
|
||||||
|
|||||||
@@ -237,6 +237,14 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.sidebar.session.export.nothingToExport": "No hay nada para exportar",
|
"sessions.sidebar.session.export.nothingToExport": "No hay nada para exportar",
|
||||||
"sessions.sidebar.session.export.success": "Sesión exportada",
|
"sessions.sidebar.session.export.success": "Sesión exportada",
|
||||||
"sessions.sidebar.session.export.failedRevealPath": "No se pudo mostrar la ruta",
|
"sessions.sidebar.session.export.failedRevealPath": "No se pudo mostrar la ruta",
|
||||||
|
"sessions.sidebar.session.export.untitledSubagent": "Subagente sin título",
|
||||||
|
"sessions.sidebar.session.export.skippedSubtaskSingle": "La sesión se exportó, pero se omitió {count} tarea de subagente que no se pudo cargar.",
|
||||||
|
"sessions.sidebar.session.export.skippedSubtaskMany": "La sesión se exportó, pero se omitieron {count} tareas de subagente que no se pudieron cargar.",
|
||||||
|
"sessions.sidebar.session.export.dialog.title": "Exportar Markdown",
|
||||||
|
"sessions.sidebar.session.export.dialog.descriptionSingle": "Esta sesión tiene {count} tarea de subagente. ¿Incluirla en la exportación?",
|
||||||
|
"sessions.sidebar.session.export.dialog.descriptionMany": "Esta sesión tiene {count} tareas de subagente. ¿Incluirlas en la exportación?",
|
||||||
|
"sessions.sidebar.session.export.dialog.includeSubtasks": "Incluir tareas de subagente",
|
||||||
|
"sessions.sidebar.session.export.dialog.confirm": "Exportar",
|
||||||
"sessions.sidebar.session.status.active": "Sesión activa",
|
"sessions.sidebar.session.status.active": "Sesión activa",
|
||||||
"sessions.sidebar.session.status.unread": "Actualizaciones no leídas",
|
"sessions.sidebar.session.status.unread": "Actualizaciones no leídas",
|
||||||
"sessions.sidebar.session.status.pinned": "Sesión anclada",
|
"sessions.sidebar.session.status.pinned": "Sesión anclada",
|
||||||
@@ -1959,6 +1967,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"onboarding.desktopRecovery.common.useRemote": "Usar remoto",
|
"onboarding.desktopRecovery.common.useRemote": "Usar remoto",
|
||||||
"onboarding.desktopRecovery.actions.retrying": "Reintentando…",
|
"onboarding.desktopRecovery.actions.retrying": "Reintentando…",
|
||||||
"onboarding.desktopRecovery.actions.retryConnection": "Reintentar conexión",
|
"onboarding.desktopRecovery.actions.retryConnection": "Reintentar conexión",
|
||||||
|
"startup.initRecovery.title": "Error al iniciar",
|
||||||
|
"startup.initRecovery.description": "OpenChamber no pudo completar la inicialización. Comprueba que el servidor esté en ejecución y vuelve a intentarlo.",
|
||||||
|
"startup.initRecovery.retry": "Reintentar",
|
||||||
|
"startup.initRecovery.retrying": "Reintentando…",
|
||||||
"onboarding.desktopRecovery.placeholders.remoteServer": "el servidor remoto",
|
"onboarding.desktopRecovery.placeholders.remoteServer": "el servidor remoto",
|
||||||
"onboarding.desktopRecovery.placeholders.unknownServer": "desconocido",
|
"onboarding.desktopRecovery.placeholders.unknownServer": "desconocido",
|
||||||
"vscodeLayout.title.chat": "Chat",
|
"vscodeLayout.title.chat": "Chat",
|
||||||
|
|||||||
@@ -237,6 +237,14 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.sidebar.session.export.nothingToExport": "Não há nada para exportar",
|
"sessions.sidebar.session.export.nothingToExport": "Não há nada para exportar",
|
||||||
"sessions.sidebar.session.export.success": "Sessão exportada",
|
"sessions.sidebar.session.export.success": "Sessão exportada",
|
||||||
"sessions.sidebar.session.export.failedRevealPath": "Não foi possível mostrar o caminho",
|
"sessions.sidebar.session.export.failedRevealPath": "Não foi possível mostrar o caminho",
|
||||||
|
"sessions.sidebar.session.export.untitledSubagent": "Subagente sem título",
|
||||||
|
"sessions.sidebar.session.export.skippedSubtaskSingle": "A sessão foi exportada, mas {count} tarefa de subagente não pôde ser carregada e foi ignorada.",
|
||||||
|
"sessions.sidebar.session.export.skippedSubtaskMany": "A sessão foi exportada, mas {count} tarefas de subagente não puderam ser carregadas e foram ignoradas.",
|
||||||
|
"sessions.sidebar.session.export.dialog.title": "Exportar Markdown",
|
||||||
|
"sessions.sidebar.session.export.dialog.descriptionSingle": "Esta sessão tem {count} tarefa de subagente. Incluí-la na exportação?",
|
||||||
|
"sessions.sidebar.session.export.dialog.descriptionMany": "Esta sessão tem {count} tarefas de subagente. Incluí-las na exportação?",
|
||||||
|
"sessions.sidebar.session.export.dialog.includeSubtasks": "Incluir tarefas de subagente",
|
||||||
|
"sessions.sidebar.session.export.dialog.confirm": "Exportar",
|
||||||
"sessions.sidebar.session.status.active": "Sessão ativa",
|
"sessions.sidebar.session.status.active": "Sessão ativa",
|
||||||
"sessions.sidebar.session.status.unread": "Atualizações não lidas",
|
"sessions.sidebar.session.status.unread": "Atualizações não lidas",
|
||||||
"sessions.sidebar.session.status.pinned": "Sessão fixada",
|
"sessions.sidebar.session.status.pinned": "Sessão fixada",
|
||||||
@@ -1959,6 +1967,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"onboarding.desktopRecovery.common.useRemote": "Usar remoto",
|
"onboarding.desktopRecovery.common.useRemote": "Usar remoto",
|
||||||
"onboarding.desktopRecovery.actions.retrying": "Retentendo…",
|
"onboarding.desktopRecovery.actions.retrying": "Retentendo…",
|
||||||
"onboarding.desktopRecovery.actions.retryConnection": "Tentar novamente conexão",
|
"onboarding.desktopRecovery.actions.retryConnection": "Tentar novamente conexão",
|
||||||
|
"startup.initRecovery.title": "Falha ao iniciar",
|
||||||
|
"startup.initRecovery.description": "O OpenChamber não conseguiu concluir a inicialização. Verifique se o servidor está em execução e tente novamente.",
|
||||||
|
"startup.initRecovery.retry": "Tentar novamente",
|
||||||
|
"startup.initRecovery.retrying": "Tentando novamente…",
|
||||||
"onboarding.desktopRecovery.placeholders.remoteServer": "o servidor remoto",
|
"onboarding.desktopRecovery.placeholders.remoteServer": "o servidor remoto",
|
||||||
"onboarding.desktopRecovery.placeholders.unknownServer": "desconhecido",
|
"onboarding.desktopRecovery.placeholders.unknownServer": "desconhecido",
|
||||||
"vscodeLayout.title.chat": "Chat",
|
"vscodeLayout.title.chat": "Chat",
|
||||||
|
|||||||
@@ -237,6 +237,14 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.sidebar.session.export.nothingToExport": "Нічого для експорту",
|
"sessions.sidebar.session.export.nothingToExport": "Нічого для експорту",
|
||||||
"sessions.sidebar.session.export.success": "Сесія експортовано",
|
"sessions.sidebar.session.export.success": "Сесія експортовано",
|
||||||
"sessions.sidebar.session.export.failedRevealPath": "Не вдалося відкрити шлях",
|
"sessions.sidebar.session.export.failedRevealPath": "Не вдалося відкрити шлях",
|
||||||
|
"sessions.sidebar.session.export.untitledSubagent": "Під-агент без назви",
|
||||||
|
"sessions.sidebar.session.export.skippedSubtaskSingle": "Сесію експортовано, але пропущено {count} завдання під-агента, яке не вдалося завантажити.",
|
||||||
|
"sessions.sidebar.session.export.skippedSubtaskMany": "Сесію експортовано, але пропущено {count} завдань під-агента, які не вдалося завантажити.",
|
||||||
|
"sessions.sidebar.session.export.dialog.title": "Експорт Markdown",
|
||||||
|
"sessions.sidebar.session.export.dialog.descriptionSingle": "Ця сесія має {count} завдання під-агента. Додати його до експорту?",
|
||||||
|
"sessions.sidebar.session.export.dialog.descriptionMany": "Ця сесія має {count} завдань під-агентів. Додати їх до експорту?",
|
||||||
|
"sessions.sidebar.session.export.dialog.includeSubtasks": "Додати завдання під-агентів",
|
||||||
|
"sessions.sidebar.session.export.dialog.confirm": "Експортувати",
|
||||||
"sessions.sidebar.session.status.active": "Сесія активний",
|
"sessions.sidebar.session.status.active": "Сесія активний",
|
||||||
"sessions.sidebar.session.status.unread": "Непрочитані оновлення",
|
"sessions.sidebar.session.status.unread": "Непрочитані оновлення",
|
||||||
"sessions.sidebar.session.status.pinned": "Закріплений сесія",
|
"sessions.sidebar.session.status.pinned": "Закріплений сесія",
|
||||||
@@ -1959,6 +1967,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"onboarding.desktopRecovery.common.useRemote": "Використовувати Remote",
|
"onboarding.desktopRecovery.common.useRemote": "Використовувати Remote",
|
||||||
"onboarding.desktopRecovery.actions.retrying": "Повторна спроба…",
|
"onboarding.desktopRecovery.actions.retrying": "Повторна спроба…",
|
||||||
"onboarding.desktopRecovery.actions.retryConnection": "Повторити підключення",
|
"onboarding.desktopRecovery.actions.retryConnection": "Повторити підключення",
|
||||||
|
"startup.initRecovery.title": "Не вдалося запустити",
|
||||||
|
"startup.initRecovery.description": "OpenChamber не зміг завершити ініціалізацію. Перевірте, що сервер запущений, і повторіть спробу.",
|
||||||
|
"startup.initRecovery.retry": "Повторити спробу",
|
||||||
|
"startup.initRecovery.retrying": "Повторна спроба…",
|
||||||
"onboarding.desktopRecovery.placeholders.remoteServer": "віддалений сервер",
|
"onboarding.desktopRecovery.placeholders.remoteServer": "віддалений сервер",
|
||||||
"onboarding.desktopRecovery.placeholders.unknownServer": "невідомий",
|
"onboarding.desktopRecovery.placeholders.unknownServer": "невідомий",
|
||||||
"vscodeLayout.title.chat": "Чат",
|
"vscodeLayout.title.chat": "Чат",
|
||||||
|
|||||||
@@ -237,6 +237,14 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.sidebar.session.export.nothingToExport': '没有可导出的内容',
|
'sessions.sidebar.session.export.nothingToExport': '没有可导出的内容',
|
||||||
'sessions.sidebar.session.export.success': '会话已导出',
|
'sessions.sidebar.session.export.success': '会话已导出',
|
||||||
'sessions.sidebar.session.export.failedRevealPath': '显示路径失败',
|
'sessions.sidebar.session.export.failedRevealPath': '显示路径失败',
|
||||||
|
'sessions.sidebar.session.export.untitledSubagent': '未命名子代理',
|
||||||
|
'sessions.sidebar.session.export.skippedSubtaskSingle': '会话已导出,但跳过了 {count} 个无法加载的子代理任务。',
|
||||||
|
'sessions.sidebar.session.export.skippedSubtaskMany': '会话已导出,但跳过了 {count} 个无法加载的子代理任务。',
|
||||||
|
'sessions.sidebar.session.export.dialog.title': '导出 Markdown',
|
||||||
|
'sessions.sidebar.session.export.dialog.descriptionSingle': '此会话有 {count} 个子代理任务。是否包含在导出中?',
|
||||||
|
'sessions.sidebar.session.export.dialog.descriptionMany': '此会话有 {count} 个子代理任务。是否包含在导出中?',
|
||||||
|
'sessions.sidebar.session.export.dialog.includeSubtasks': '包含子代理任务',
|
||||||
|
'sessions.sidebar.session.export.dialog.confirm': '导出',
|
||||||
'sessions.sidebar.session.status.active': '会话活跃中',
|
'sessions.sidebar.session.status.active': '会话活跃中',
|
||||||
'sessions.sidebar.session.status.unread': '有未读更新',
|
'sessions.sidebar.session.status.unread': '有未读更新',
|
||||||
'sessions.sidebar.session.status.pinned': '已置顶会话',
|
'sessions.sidebar.session.status.pinned': '已置顶会话',
|
||||||
@@ -1959,6 +1967,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'onboarding.desktopRecovery.common.useRemote': '使用远程',
|
'onboarding.desktopRecovery.common.useRemote': '使用远程',
|
||||||
'onboarding.desktopRecovery.actions.retrying': '重试中…',
|
'onboarding.desktopRecovery.actions.retrying': '重试中…',
|
||||||
'onboarding.desktopRecovery.actions.retryConnection': '重试连接',
|
'onboarding.desktopRecovery.actions.retryConnection': '重试连接',
|
||||||
|
'startup.initRecovery.title': '启动失败',
|
||||||
|
'startup.initRecovery.description': 'OpenChamber 未能完成初始化。请检查服务器是否正在运行,然后重试。',
|
||||||
|
'startup.initRecovery.retry': '重试',
|
||||||
|
'startup.initRecovery.retrying': '重试中…',
|
||||||
'onboarding.desktopRecovery.placeholders.remoteServer': '远程服务器',
|
'onboarding.desktopRecovery.placeholders.remoteServer': '远程服务器',
|
||||||
'onboarding.desktopRecovery.placeholders.unknownServer': '未知服务器',
|
'onboarding.desktopRecovery.placeholders.unknownServer': '未知服务器',
|
||||||
'vscodeLayout.title.chat': '聊天',
|
'vscodeLayout.title.chat': '聊天',
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { Part } from "@opencode-ai/sdk/v2"
|
||||||
|
import { isSyntheticPart, isFullySyntheticMessage, filterSyntheticParts } from "./synthetic"
|
||||||
|
|
||||||
|
function createTextPart(id: string, text: string, synthetic?: boolean): Part {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "message-1",
|
||||||
|
type: "text",
|
||||||
|
text,
|
||||||
|
...(synthetic !== undefined ? { synthetic } : {}),
|
||||||
|
} as Part
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFilePart(id: string, url: string): Part {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "message-1",
|
||||||
|
type: "file",
|
||||||
|
mime: "text/plain",
|
||||||
|
url,
|
||||||
|
} as Part
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isSyntheticPart", () => {
|
||||||
|
test("returns false for undefined", () => {
|
||||||
|
expect(isSyntheticPart(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false for non-object", () => {
|
||||||
|
expect(isSyntheticPart(null as unknown as Part)).toBe(false)
|
||||||
|
expect(isSyntheticPart("string" as unknown as Part)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false for parts without synthetic property", () => {
|
||||||
|
const part = createTextPart("1", "hello")
|
||||||
|
expect(isSyntheticPart(part)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false for parts with synthetic: false", () => {
|
||||||
|
const part = createTextPart("1", "hello", false)
|
||||||
|
expect(isSyntheticPart(part)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns true for parts with synthetic: true", () => {
|
||||||
|
const part = createTextPart("1", "file content here", true)
|
||||||
|
expect(isSyntheticPart(part)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false for file parts", () => {
|
||||||
|
const part = createFilePart("1", "file:///path/to/file")
|
||||||
|
expect(isSyntheticPart(part)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isFullySyntheticMessage", () => {
|
||||||
|
test("returns false for undefined", () => {
|
||||||
|
expect(isFullySyntheticMessage(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false for empty array", () => {
|
||||||
|
expect(isFullySyntheticMessage([])).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false when all parts are non-synthetic", () => {
|
||||||
|
const parts = [
|
||||||
|
createTextPart("1", "hello"),
|
||||||
|
createFilePart("2", "file:///path"),
|
||||||
|
]
|
||||||
|
expect(isFullySyntheticMessage(parts)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false when some parts are synthetic", () => {
|
||||||
|
const parts = [
|
||||||
|
createTextPart("1", "user prompt"),
|
||||||
|
createTextPart("2", "file content", true),
|
||||||
|
]
|
||||||
|
expect(isFullySyntheticMessage(parts)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns true when all parts are synthetic", () => {
|
||||||
|
const parts = [
|
||||||
|
createTextPart("1", "file content 1", true),
|
||||||
|
createTextPart("2", "file content 2", true),
|
||||||
|
]
|
||||||
|
expect(isFullySyntheticMessage(parts)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("filterSyntheticParts", () => {
|
||||||
|
test("returns empty array for undefined", () => {
|
||||||
|
expect(filterSyntheticParts(undefined)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns empty array for empty array", () => {
|
||||||
|
expect(filterSyntheticParts([])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns all parts when no synthetic parts exist", () => {
|
||||||
|
const parts = [
|
||||||
|
createTextPart("1", "hello"),
|
||||||
|
createFilePart("2", "file:///path"),
|
||||||
|
]
|
||||||
|
expect(filterSyntheticParts(parts)).toEqual(parts)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("filters out synthetic parts when non-synthetic parts exist", () => {
|
||||||
|
const userPart = createTextPart("1", "user prompt")
|
||||||
|
const syntheticPart = createTextPart("2", "file content", true)
|
||||||
|
const parts = [userPart, syntheticPart]
|
||||||
|
expect(filterSyntheticParts(parts)).toEqual([userPart])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps synthetic parts when all parts are synthetic", () => {
|
||||||
|
const parts = [
|
||||||
|
createTextPart("1", "file content 1", true),
|
||||||
|
createTextPart("2", "file content 2", true),
|
||||||
|
]
|
||||||
|
expect(filterSyntheticParts(parts)).toEqual(parts)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { autoRespondsPermission, type PermissionAutoAcceptMap } from "./permissionAutoAccept"
|
||||||
|
|
||||||
|
function makeSession(id: string, parentID?: string): Session {
|
||||||
|
return { id, parentID } as Session
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("autoRespondsPermission", () => {
|
||||||
|
test("returns false when autoAccept is empty", () => {
|
||||||
|
expect(autoRespondsPermission({
|
||||||
|
autoAccept: {},
|
||||||
|
sessions: [makeSession("s1")],
|
||||||
|
sessionID: "s1",
|
||||||
|
})).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns true when session has autoAccept enabled", () => {
|
||||||
|
const autoAccept: PermissionAutoAcceptMap = { s1: true }
|
||||||
|
expect(autoRespondsPermission({
|
||||||
|
autoAccept,
|
||||||
|
sessions: [makeSession("s1")],
|
||||||
|
sessionID: "s1",
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false when session has autoAccept disabled", () => {
|
||||||
|
const autoAccept: PermissionAutoAcceptMap = { s1: false }
|
||||||
|
expect(autoRespondsPermission({
|
||||||
|
autoAccept,
|
||||||
|
sessions: [makeSession("s1")],
|
||||||
|
sessionID: "s1",
|
||||||
|
})).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns true when parent has autoAccept enabled", () => {
|
||||||
|
const autoAccept: PermissionAutoAcceptMap = { parent: true }
|
||||||
|
const sessions = [
|
||||||
|
makeSession("parent"),
|
||||||
|
makeSession("child", "parent"),
|
||||||
|
]
|
||||||
|
expect(autoRespondsPermission({
|
||||||
|
autoAccept,
|
||||||
|
sessions,
|
||||||
|
sessionID: "child",
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns true when grandparent has autoAccept enabled", () => {
|
||||||
|
const autoAccept: PermissionAutoAcceptMap = { grandparent: true }
|
||||||
|
const sessions = [
|
||||||
|
makeSession("grandparent"),
|
||||||
|
makeSession("parent", "grandparent"),
|
||||||
|
makeSession("child", "parent"),
|
||||||
|
]
|
||||||
|
expect(autoRespondsPermission({
|
||||||
|
autoAccept,
|
||||||
|
sessions,
|
||||||
|
sessionID: "child",
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false when only sibling has autoAccept enabled", () => {
|
||||||
|
const autoAccept: PermissionAutoAcceptMap = { sibling: true }
|
||||||
|
const sessions = [
|
||||||
|
makeSession("parent"),
|
||||||
|
makeSession("sibling", "parent"),
|
||||||
|
makeSession("child", "parent"),
|
||||||
|
]
|
||||||
|
expect(autoRespondsPermission({
|
||||||
|
autoAccept,
|
||||||
|
sessions,
|
||||||
|
sessionID: "child",
|
||||||
|
})).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("child autoAccept overrides parent", () => {
|
||||||
|
const autoAccept: PermissionAutoAcceptMap = { parent: true, child: false }
|
||||||
|
const sessions = [
|
||||||
|
makeSession("parent"),
|
||||||
|
makeSession("child", "parent"),
|
||||||
|
]
|
||||||
|
expect(autoRespondsPermission({
|
||||||
|
autoAccept,
|
||||||
|
sessions,
|
||||||
|
sessionID: "child",
|
||||||
|
})).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false for unknown session", () => {
|
||||||
|
const autoAccept: PermissionAutoAcceptMap = { s1: true }
|
||||||
|
expect(autoRespondsPermission({
|
||||||
|
autoAccept,
|
||||||
|
sessions: [],
|
||||||
|
sessionID: "unknown",
|
||||||
|
})).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { describe, expect, test, beforeEach, mock } from "bun:test"
|
||||||
|
import type { PermissionRequest } from "@/types/permission"
|
||||||
|
|
||||||
|
// Mock SDK client that records permission.reply / question.reply calls
|
||||||
|
const replyCalls: Array<{ method: string; params: Record<string, unknown> }> = []
|
||||||
|
|
||||||
|
const mockScopedClient = {
|
||||||
|
permission: {
|
||||||
|
reply: mock((params: Record<string, unknown>) => {
|
||||||
|
replyCalls.push({ method: "permission.reply", params })
|
||||||
|
return Promise.resolve({ data: true })
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
question: {
|
||||||
|
reply: mock((params: Record<string, unknown>) => {
|
||||||
|
replyCalls.push({ method: "question.reply", params })
|
||||||
|
return Promise.resolve({ data: true })
|
||||||
|
}),
|
||||||
|
reject: mock((params: Record<string, unknown>) => {
|
||||||
|
replyCalls.push({ method: "question.reject", params })
|
||||||
|
return Promise.resolve({ data: true })
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockSdk = {
|
||||||
|
permission: {
|
||||||
|
reply: mock((params: Record<string, unknown>) => {
|
||||||
|
replyCalls.push({ method: "permission.reply", params })
|
||||||
|
return Promise.resolve({ data: true })
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
question: {
|
||||||
|
reply: mock((params: Record<string, unknown>) => {
|
||||||
|
replyCalls.push({ method: "question.reply", params })
|
||||||
|
return Promise.resolve({ data: true })
|
||||||
|
}),
|
||||||
|
reject: mock((params: Record<string, unknown>) => {
|
||||||
|
replyCalls.push({ method: "question.reject", params })
|
||||||
|
return Promise.resolve({ data: true })
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock opencodeClient singleton
|
||||||
|
mock.module("@/lib/opencode/client", () => ({
|
||||||
|
opencodeClient: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
getScopedSdkClient: (_: string) => mockScopedClient,
|
||||||
|
getDirectory: () => "/test/project",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock useConfigStore
|
||||||
|
mock.module("@/stores/useConfigStore", () => ({
|
||||||
|
useConfigStore: {
|
||||||
|
getState: () => ({
|
||||||
|
isConnected: true,
|
||||||
|
hasEverConnected: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock useSessionUIStore
|
||||||
|
mock.module("./session-ui-store", () => ({
|
||||||
|
useSessionUIStore: {
|
||||||
|
getState: () => ({
|
||||||
|
getDirectoryForSession: (sessionId: string) => {
|
||||||
|
if (sessionId === "session-a") return "/test/project"
|
||||||
|
if (sessionId === "session-b") return "/other/project"
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock useInputStore (imported but not used in permission functions)
|
||||||
|
mock.module("./input-store", () => ({
|
||||||
|
useInputStore: {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock useGlobalSessionsStore (imported but not used in permission functions)
|
||||||
|
mock.module("@/stores/useGlobalSessionsStore", () => ({
|
||||||
|
useGlobalSessionsStore: {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock sync-refs (imported but not used in permission functions)
|
||||||
|
mock.module("./sync-refs", () => ({
|
||||||
|
registerSessionDirectory: () => {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { create, type StoreApi } from "zustand"
|
||||||
|
import { INITIAL_STATE } from "./types"
|
||||||
|
import type { DirectoryStore } from "./child-store"
|
||||||
|
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
function createStore(permissions: Record<string, PermissionRequest[]>): StoreApi<DirectoryStore> {
|
||||||
|
return create<DirectoryStore>()((set) => ({
|
||||||
|
...INITIAL_STATE,
|
||||||
|
permission: permissions,
|
||||||
|
patch: (partial) => set(partial),
|
||||||
|
replace: (next) => set(next),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChildStores(entries: Array<[string, StoreApi<DirectoryStore>]>) {
|
||||||
|
return {
|
||||||
|
children: new Map(entries),
|
||||||
|
ensureChild: (dir: string) => {
|
||||||
|
const store = new Map(entries).get(dir)
|
||||||
|
if (!store) throw new Error(`No store for ${dir}`)
|
||||||
|
return store
|
||||||
|
},
|
||||||
|
} as unknown as import("./child-store").ChildStoreManager
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("respondToPermission passes directory", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
replyCalls.length = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
test("passes directory from child store when permission is found", async () => {
|
||||||
|
const permission: PermissionRequest = {
|
||||||
|
id: "perm-1",
|
||||||
|
sessionID: "session-a",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: [],
|
||||||
|
metadata: {},
|
||||||
|
always: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = createStore({ "session-a": [permission] })
|
||||||
|
const childStores = createChildStores([["/test/project", store]])
|
||||||
|
|
||||||
|
const { setActionRefs, respondToPermission } = await import("./session-actions")
|
||||||
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
||||||
|
|
||||||
|
await respondToPermission("session-a", "perm-1", "once")
|
||||||
|
|
||||||
|
expect(replyCalls.length).toBe(1)
|
||||||
|
expect(replyCalls[0].params.requestID).toBe("perm-1")
|
||||||
|
expect(replyCalls[0].params.reply).toBe("once")
|
||||||
|
expect(replyCalls[0].params.directory).toBe("/test/project")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("passes directory from session mapping when permission not in store", async () => {
|
||||||
|
const childStores = createChildStores([])
|
||||||
|
|
||||||
|
const { setActionRefs, respondToPermission } = await import("./session-actions")
|
||||||
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
||||||
|
|
||||||
|
await respondToPermission("session-b", "perm-2", "always")
|
||||||
|
|
||||||
|
expect(replyCalls.length).toBe(1)
|
||||||
|
expect(replyCalls[0].params.requestID).toBe("perm-2")
|
||||||
|
expect(replyCalls[0].params.reply).toBe("always")
|
||||||
|
expect(replyCalls[0].params.directory).toBe("/other/project")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("passes directory from current directory as last resort", async () => {
|
||||||
|
const childStores = createChildStores([])
|
||||||
|
|
||||||
|
const { setActionRefs, respondToPermission } = await import("./session-actions")
|
||||||
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/fallback/dir")
|
||||||
|
|
||||||
|
await respondToPermission("unknown-session", "perm-3", "reject")
|
||||||
|
|
||||||
|
expect(replyCalls.length).toBe(1)
|
||||||
|
expect(replyCalls[0].params.requestID).toBe("perm-3")
|
||||||
|
expect(replyCalls[0].params.reply).toBe("reject")
|
||||||
|
expect(replyCalls[0].params.directory).toBe("/fallback/dir")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("dismissPermission passes directory", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
replyCalls.length = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
test("passes directory and reply=reject", async () => {
|
||||||
|
const permission: PermissionRequest = {
|
||||||
|
id: "perm-10",
|
||||||
|
sessionID: "session-a",
|
||||||
|
permission: "edit",
|
||||||
|
patterns: [],
|
||||||
|
metadata: {},
|
||||||
|
always: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = createStore({ "session-a": [permission] })
|
||||||
|
const childStores = createChildStores([["/test/project", store]])
|
||||||
|
|
||||||
|
const { setActionRefs, dismissPermission } = await import("./session-actions")
|
||||||
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
||||||
|
|
||||||
|
await dismissPermission("session-a", "perm-10")
|
||||||
|
|
||||||
|
expect(replyCalls.length).toBe(1)
|
||||||
|
expect(replyCalls[0].params.requestID).toBe("perm-10")
|
||||||
|
expect(replyCalls[0].params.reply).toBe("reject")
|
||||||
|
expect(replyCalls[0].params.directory).toBe("/test/project")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("respondToQuestion passes directory", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
replyCalls.length = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
test("passes directory to question.reply", async () => {
|
||||||
|
const childStores = createChildStores([])
|
||||||
|
|
||||||
|
const { setActionRefs, respondToQuestion } = await import("./session-actions")
|
||||||
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
||||||
|
|
||||||
|
await respondToQuestion("session-a", "q-1", [["answer1"]])
|
||||||
|
|
||||||
|
expect(replyCalls.length).toBe(1)
|
||||||
|
expect(replyCalls[0].params.requestID).toBe("q-1")
|
||||||
|
expect(replyCalls[0].params.directory).toBe("/test/project")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("rejectQuestion passes directory", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
replyCalls.length = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
test("passes directory to question.reject", async () => {
|
||||||
|
const childStores = createChildStores([])
|
||||||
|
|
||||||
|
const { setActionRefs, rejectQuestion } = await import("./session-actions")
|
||||||
|
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
|
||||||
|
|
||||||
|
await rejectQuestion("session-a", "q-2")
|
||||||
|
|
||||||
|
expect(replyCalls.length).toBe(1)
|
||||||
|
expect(replyCalls[0].params.requestID).toBe("q-2")
|
||||||
|
expect(replyCalls[0].params.directory).toBe("/test/project")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -12,6 +12,7 @@ import { opencodeClient } from "@/lib/opencode/client"
|
|||||||
import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
|
import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
|
||||||
import { useConfigStore } from "@/stores/useConfigStore"
|
import { useConfigStore } from "@/stores/useConfigStore"
|
||||||
import { registerSessionDirectory } from "./sync-refs"
|
import { registerSessionDirectory } from "./sync-refs"
|
||||||
|
import { isSyntheticPart } from "@/lib/messages/synthetic"
|
||||||
|
|
||||||
// Reference set by SyncProvider — allows actions to access SDK and stores
|
// Reference set by SyncProvider — allows actions to access SDK and stores
|
||||||
let _sdk: OpencodeClient | null = null
|
let _sdk: OpencodeClient | null = null
|
||||||
@@ -443,9 +444,13 @@ export async function respondToPermission(
|
|||||||
response: "once" | "always" | "reject",
|
response: "once" | "always" | "reject",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await waitForConnectionOrThrow()
|
await waitForConnectionOrThrow()
|
||||||
|
const directory = resolveDirectoryForBlockingRequest("permission", sessionId, requestId)
|
||||||
|
|| getSessionDirectory(sessionId)
|
||||||
|
|| dir()
|
||||||
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
|
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
|
||||||
requestID: requestId,
|
requestID: requestId,
|
||||||
reply: response,
|
reply: response,
|
||||||
|
...(directory ? { directory } : {}),
|
||||||
})
|
})
|
||||||
if (!result.data) {
|
if (!result.data) {
|
||||||
throw new Error("Permission reply failed")
|
throw new Error("Permission reply failed")
|
||||||
@@ -457,9 +462,13 @@ export async function dismissPermission(
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await waitForConnectionOrThrow()
|
await waitForConnectionOrThrow()
|
||||||
|
const directory = resolveDirectoryForBlockingRequest("permission", sessionId, requestId)
|
||||||
|
|| getSessionDirectory(sessionId)
|
||||||
|
|| dir()
|
||||||
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
|
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
|
||||||
requestID: requestId,
|
requestID: requestId,
|
||||||
reply: "reject",
|
reply: "reject",
|
||||||
|
...(directory ? { directory } : {}),
|
||||||
})
|
})
|
||||||
if (!result.data) {
|
if (!result.data) {
|
||||||
throw new Error("Permission dismissal failed")
|
throw new Error("Permission dismissal failed")
|
||||||
@@ -476,9 +485,13 @@ export async function respondToQuestion(
|
|||||||
answers: string[] | string[][],
|
answers: string[] | string[][],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await waitForConnectionOrThrow()
|
await waitForConnectionOrThrow()
|
||||||
|
const directory = resolveDirectoryForBlockingRequest("question", sessionId, requestId)
|
||||||
|
|| getSessionDirectory(sessionId)
|
||||||
|
|| dir()
|
||||||
const result = await getRequestReplyClient("question", sessionId, requestId).question.reply({
|
const result = await getRequestReplyClient("question", sessionId, requestId).question.reply({
|
||||||
requestID: requestId,
|
requestID: requestId,
|
||||||
answers: answers as Array<Array<string>>,
|
answers: answers as Array<Array<string>>,
|
||||||
|
...(directory ? { directory } : {}),
|
||||||
})
|
})
|
||||||
if (!result.data) {
|
if (!result.data) {
|
||||||
throw new Error("Question reply failed")
|
throw new Error("Question reply failed")
|
||||||
@@ -490,8 +503,12 @@ export async function rejectQuestion(
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await waitForConnectionOrThrow()
|
await waitForConnectionOrThrow()
|
||||||
|
const directory = resolveDirectoryForBlockingRequest("question", sessionId, requestId)
|
||||||
|
|| getSessionDirectory(sessionId)
|
||||||
|
|| dir()
|
||||||
const result = await getRequestReplyClient("question", sessionId, requestId).question.reject({
|
const result = await getRequestReplyClient("question", sessionId, requestId).question.reject({
|
||||||
requestID: requestId,
|
requestID: requestId,
|
||||||
|
...(directory ? { directory } : {}),
|
||||||
})
|
})
|
||||||
if (!result.data) {
|
if (!result.data) {
|
||||||
throw new Error("Question rejection failed")
|
throw new Error("Question rejection failed")
|
||||||
@@ -525,13 +542,14 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract message text for prompt restoration
|
// Extract message text for prompt restoration (only non-synthetic text parts —
|
||||||
|
// the server adds file content as synthetic text parts that should not be restored)
|
||||||
const messages = state.message[sessionId] ?? []
|
const messages = state.message[sessionId] ?? []
|
||||||
const targetMsg = messages.find((m) => m.id === messageId)
|
const targetMsg = messages.find((m) => m.id === messageId)
|
||||||
let messageText = ""
|
let messageText = ""
|
||||||
if (targetMsg && targetMsg.role === "user") {
|
if (targetMsg && targetMsg.role === "user") {
|
||||||
const parts = state.part[messageId] ?? []
|
const parts = state.part[messageId] ?? []
|
||||||
const textParts = parts.filter((p) => p.type === "text")
|
const textParts = parts.filter((p) => p.type === "text" && !isSyntheticPart(p))
|
||||||
messageText = textParts
|
messageText = textParts
|
||||||
.map((p: Record<string, unknown>) => (p as { text?: string }).text || (p as { content?: string }).content || "")
|
.map((p: Record<string, unknown>) => (p as { text?: string }).text || (p as { content?: string }).content || "")
|
||||||
.join("\n")
|
.join("\n")
|
||||||
@@ -646,10 +664,11 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro
|
|||||||
const store = dirStore()
|
const store = dirStore()
|
||||||
const state = store.getState()
|
const state = store.getState()
|
||||||
|
|
||||||
// Extract message text for input restoration
|
// Extract message text for input restoration (only non-synthetic text parts —
|
||||||
|
// the server adds file content as synthetic text parts that should not be restored)
|
||||||
const parts = state.part[messageId] ?? []
|
const parts = state.part[messageId] ?? []
|
||||||
let messageText = ""
|
let messageText = ""
|
||||||
const textParts = parts.filter((p) => p.type === "text")
|
const textParts = parts.filter((p) => p.type === "text" && !isSyntheticPart(p))
|
||||||
messageText = textParts
|
messageText = textParts
|
||||||
.map((p: Part) => ((p as Record<string, unknown>).text as string) || ((p as Record<string, unknown>).content as string) || "")
|
.map((p: Part) => ((p as Record<string, unknown>).text as string) || ((p as Record<string, unknown>).content as string) || "")
|
||||||
.join("\n")
|
.join("\n")
|
||||||
|
|||||||
@@ -358,6 +358,10 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const previousSessionId = get().currentSessionId
|
const previousSessionId = get().currentSessionId
|
||||||
|
|
||||||
|
// Set currentSessionId immediately so the skeleton renders without delay.
|
||||||
|
set({ currentSessionId: id })
|
||||||
|
|
||||||
const directoryState = useDirectoryStore.getState()
|
const directoryState = useDirectoryStore.getState()
|
||||||
|
|
||||||
const sessionDir = resolveSessionDirectory(
|
const sessionDir = resolveSessionDirectory(
|
||||||
@@ -376,20 +380,21 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
console.warn("Failed to set OpenCode directory for session switch:", e)
|
console.warn("Failed to set OpenCode directory for session switch:", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save viewport anchor for previous session
|
// Defer viewport anchor save for previous session — not needed for the
|
||||||
|
// skeleton to render and reads messages which can be expensive.
|
||||||
if (previousSessionId && previousSessionId !== id) {
|
if (previousSessionId && previousSessionId !== id) {
|
||||||
const memState = useViewportStore.getState().sessionMemoryState.get(previousSessionId)
|
const prevId = previousSessionId
|
||||||
if (!memState?.isStreaming) {
|
setTimeout(() => {
|
||||||
const prevMessages = getSyncMessages(previousSessionId)
|
const memState = useViewportStore.getState().sessionMemoryState.get(prevId)
|
||||||
if (prevMessages.length > 0) {
|
if (!memState?.isStreaming) {
|
||||||
useViewportStore.getState().updateViewportAnchor(previousSessionId, prevMessages.length - 1)
|
const prevMessages = getSyncMessages(prevId)
|
||||||
|
if (prevMessages.length > 0) {
|
||||||
|
useViewportStore.getState().updateViewportAnchor(prevId, prevMessages.length - 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ currentSessionId: id })
|
|
||||||
|
|
||||||
// Mark session viewed in notification store + update active session ref
|
|
||||||
// Mark session viewed in notification store + update active session ref
|
// Mark session viewed in notification store + update active session ref
|
||||||
if (id) {
|
if (id) {
|
||||||
markSessionViewed(id)
|
markSessionViewed(id)
|
||||||
|
|||||||
@@ -169,12 +169,18 @@ export function getSessionWorktreeRepairActions(
|
|||||||
export type MutationBlockingReason =
|
export type MutationBlockingReason =
|
||||||
| { reason: 'attention'; attentionReason: NonNullable<SessionWorktreeAttachment['attentionReason']> }
|
| { reason: 'attention'; attentionReason: NonNullable<SessionWorktreeAttachment['attentionReason']> }
|
||||||
| { reason: 'missing' }
|
| { reason: 'missing' }
|
||||||
| { reason: 'invalid' };
|
| { reason: 'invalid' }
|
||||||
|
| { reason: 'dirty'; dirtyFiles?: number };
|
||||||
|
|
||||||
export function getMutationBlockingReasons(
|
export function getMutationBlockingReasons(
|
||||||
attachment: SessionWorktreeAttachment | null | undefined
|
attachment: SessionWorktreeAttachment | null | undefined,
|
||||||
|
gitStatus?: { isClean?: boolean; files?: Array<{ path: string }> }
|
||||||
): MutationBlockingReason[] {
|
): MutationBlockingReason[] {
|
||||||
const reasons: MutationBlockingReason[] = [];
|
const reasons: MutationBlockingReason[] = [];
|
||||||
|
if (gitStatus && gitStatus.isClean === false) {
|
||||||
|
const dirtyFiles = gitStatus.files?.length;
|
||||||
|
reasons.push(dirtyFiles != null ? { reason: 'dirty', dirtyFiles } : { reason: 'dirty' });
|
||||||
|
}
|
||||||
if (!attachment) return reasons;
|
if (!attachment) return reasons;
|
||||||
if (attachment.worktreeStatus === 'missing') {
|
if (attachment.worktreeStatus === 'missing') {
|
||||||
reasons.push({ reason: 'missing' });
|
reasons.push({ reason: 'missing' });
|
||||||
|
|||||||
Vendored
+5
@@ -21,4 +21,9 @@ declare module "bun:test" {
|
|||||||
toContain(expected: unknown): void;
|
toContain(expected: unknown): void;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
export function beforeEach(fn: () => void | Promise<void>): void;
|
||||||
|
export function mock<T extends (...args: never[]) => unknown>(fn?: T): T;
|
||||||
|
export namespace mock {
|
||||||
|
function module(moduleName: string, factory: () => Record<string, unknown>): void;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<html lang="en" class="h-full">
|
<html lang="en" class="h-full">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<!-- interactive-widget=resizes-content: tells supporting browsers (Chrome 108+, Safari 16.4+) that the
|
||||||
|
page handles keyboard-driven viewport resizing itself. Unsupported browsers ignore the token. -->
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { pathLooksUserConfigured, mergePathValues } from './path-utils.js';
|
||||||
|
|
||||||
export const createOpenCodeEnvRuntime = (deps) => {
|
export const createOpenCodeEnvRuntime = (deps) => {
|
||||||
const {
|
const {
|
||||||
@@ -162,21 +163,6 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const pathLooksUserConfigured = (value) => {
|
|
||||||
if (typeof value !== 'string' || !value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const home = os.homedir();
|
|
||||||
return value.split(path.delimiter).some((segment) => (
|
|
||||||
segment.startsWith(home + path.sep)
|
|
||||||
|| segment === home
|
|
||||||
|| segment.startsWith('/opt/homebrew/')
|
|
||||||
|| segment.startsWith('/opt/pkg/')
|
|
||||||
|| segment.startsWith('/opt/pmk/')
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyLoginShellEnvSnapshot = () => {
|
const applyLoginShellEnvSnapshot = () => {
|
||||||
const snapshot = getLoginShellEnvSnapshot();
|
const snapshot = getLoginShellEnvSnapshot();
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
@@ -197,8 +183,9 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
|||||||
|
|
||||||
const currentPath = process.env.PATH || '';
|
const currentPath = process.env.PATH || '';
|
||||||
const shellPath = snapshot.PATH || '';
|
const shellPath = snapshot.PATH || '';
|
||||||
if (!pathLooksUserConfigured(currentPath) && shellPath) {
|
const home = os.homedir();
|
||||||
process.env.PATH = shellPath;
|
if (!pathLooksUserConfigured(currentPath, home, path.delimiter) && shellPath) {
|
||||||
|
process.env.PATH = mergePathValues(shellPath, currentPath, path.delimiter);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -116,4 +116,51 @@ describe('OpenCode lifecycle', () => {
|
|||||||
|
|
||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to buildAugmentedPath when buildManagedOpenCodePath is not provided', async () => {
|
||||||
|
delete process.env.OPENCODE_BINARY;
|
||||||
|
const child = createMockChild();
|
||||||
|
spawnMock.mockImplementationOnce(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
|
||||||
|
});
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = createRuntime({
|
||||||
|
buildManagedOpenCodePath: undefined,
|
||||||
|
buildAugmentedPath: vi.fn(() => '/home/user/.cargo/bin:/usr/local/bin'),
|
||||||
|
});
|
||||||
|
const server = await runtime.startOpenCode();
|
||||||
|
const [, , options] = spawnMock.mock.calls[0];
|
||||||
|
|
||||||
|
expect(options.env.PATH).toBe('/home/user/.cargo/bin:/usr/local/bin');
|
||||||
|
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to process.env.PATH when neither build function is provided', async () => {
|
||||||
|
delete process.env.OPENCODE_BINARY;
|
||||||
|
const originalPath = process.env.PATH;
|
||||||
|
process.env.PATH = '/usr/bin:/bin';
|
||||||
|
const child = createMockChild();
|
||||||
|
spawnMock.mockImplementationOnce(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
|
||||||
|
});
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = createRuntime({
|
||||||
|
buildManagedOpenCodePath: undefined,
|
||||||
|
buildAugmentedPath: undefined,
|
||||||
|
});
|
||||||
|
const server = await runtime.startOpenCode();
|
||||||
|
const [, , options] = spawnMock.mock.calls[0];
|
||||||
|
|
||||||
|
expect(options.env.PATH).toBe('/usr/bin:/bin');
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Shared PATH heuristics and merge utilities for server and Electron runtimes.
|
||||||
|
*
|
||||||
|
* The heuristic decides whether the current process.env.PATH looks like it was
|
||||||
|
* configured by the user (or their session manager) vs. a minimal system default.
|
||||||
|
* When the PATH looks user-configured we keep it; otherwise we prefer the login
|
||||||
|
* shell PATH which typically has the full toolchain.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TOOLCHAIN_SEGMENTS = [
|
||||||
|
'/opt/homebrew/',
|
||||||
|
'/opt/pkg/',
|
||||||
|
'/opt/pmk/',
|
||||||
|
'/snap/',
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOOLCHAIN_BASENAMES = new Set([
|
||||||
|
'.cargo',
|
||||||
|
'.bun',
|
||||||
|
'.nvm',
|
||||||
|
'.pyenv',
|
||||||
|
'.rbenv',
|
||||||
|
'.sdkman',
|
||||||
|
'.asdf',
|
||||||
|
'.volta',
|
||||||
|
'.fnm',
|
||||||
|
'.local',
|
||||||
|
'.opencode',
|
||||||
|
'node_modules',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when `value` (a PATH string) contains at least one segment that
|
||||||
|
* suggests the PATH was configured by the user or their session manager rather
|
||||||
|
* than being a bare system default.
|
||||||
|
*
|
||||||
|
* @param {string} value - The PATH string to inspect.
|
||||||
|
* @param {string} home - The user's home directory (os.homedir()).
|
||||||
|
* @param {string} delim - The PATH delimiter (':' on POSIX, ';' on Windows).
|
||||||
|
*/
|
||||||
|
export function pathLooksUserConfigured(value, home, delim) {
|
||||||
|
if (typeof value !== 'string' || !value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedHome = typeof home === 'string' ? home.replaceAll('\\', '/') : '';
|
||||||
|
const homeWithSep = normalizedHome ? normalizedHome + '/' : '';
|
||||||
|
|
||||||
|
return value.split(delim).some((segment) => {
|
||||||
|
if (!segment) return false;
|
||||||
|
const normalizedSegment = segment.replaceAll('\\', '/');
|
||||||
|
|
||||||
|
// Any path under the user's home directory.
|
||||||
|
if (normalizedHome && (normalizedSegment === normalizedHome || normalizedSegment.startsWith(homeWithSep))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Well-known package-manager / toolchain prefixes.
|
||||||
|
if (TOOLCHAIN_SEGMENTS.some((prefix) => normalizedSegment.startsWith(prefix))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Well-known dot-directories inside home (e.g. ~/.cargo/bin).
|
||||||
|
const parts = normalizedSegment.split('/').filter(Boolean);
|
||||||
|
if (parts.some((part) => TOOLCHAIN_BASENAMES.has(part))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges two PATH strings, deduplicating segments while preserving the order of
|
||||||
|
* `primary` and appending any segments from `fallback` that are not already
|
||||||
|
* present.
|
||||||
|
*
|
||||||
|
* @param {string} primary - The preferred PATH (e.g. user-configured or login shell).
|
||||||
|
* @param {string} fallback - The secondary PATH to fill gaps from.
|
||||||
|
* @param {string} delim - The PATH delimiter.
|
||||||
|
*/
|
||||||
|
export function mergePathValues(primary, fallback, delim) {
|
||||||
|
const seen = new Set();
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
const addSegments = (value) => {
|
||||||
|
if (typeof value !== 'string' || !value) return;
|
||||||
|
for (const segment of value.split(delim)) {
|
||||||
|
if (segment && !seen.has(segment)) {
|
||||||
|
seen.add(segment);
|
||||||
|
result.push(segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addSegments(primary);
|
||||||
|
addSegments(fallback);
|
||||||
|
|
||||||
|
return result.join(delim);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { pathLooksUserConfigured, mergePathValues } from './path-utils.js';
|
||||||
|
|
||||||
|
const home = os.homedir();
|
||||||
|
const delim = path.delimiter;
|
||||||
|
|
||||||
|
describe('pathLooksUserConfigured', () => {
|
||||||
|
it('returns false for empty or non-string values', () => {
|
||||||
|
expect(pathLooksUserConfigured('', home, delim)).toBe(false);
|
||||||
|
expect(pathLooksUserConfigured(null, home, delim)).toBe(false);
|
||||||
|
expect(pathLooksUserConfigured(undefined, home, delim)).toBe(false);
|
||||||
|
expect(pathLooksUserConfigured(42, home, delim)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for minimal system PATH', () => {
|
||||||
|
expect(pathLooksUserConfigured('/usr/local/bin:/usr/bin:/bin', home, delim)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects paths under home directory', () => {
|
||||||
|
expect(pathLooksUserConfigured(`${home}/.bun/bin:/usr/bin`, home, delim)).toBe(true);
|
||||||
|
expect(pathLooksUserConfigured(`${home}/.local/bin:/usr/bin`, home, delim)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects home directory itself', () => {
|
||||||
|
expect(pathLooksUserConfigured(`${home}:/usr/bin`, home, delim)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects well-known package manager prefixes', () => {
|
||||||
|
expect(pathLooksUserConfigured('/opt/homebrew/bin:/usr/bin', home, delim)).toBe(true);
|
||||||
|
expect(pathLooksUserConfigured('/opt/pkg/bin:/usr/bin', home, delim)).toBe(true);
|
||||||
|
expect(pathLooksUserConfigured('/snap/bin:/usr/bin', home, delim)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects well-known dot-directory basenames', () => {
|
||||||
|
expect(pathLooksUserConfigured('/some/path/.cargo/bin:/usr/bin', home, delim)).toBe(true);
|
||||||
|
expect(pathLooksUserConfigured('/some/path/.nvm/versions/node/v20/bin:/usr/bin', home, delim)).toBe(true);
|
||||||
|
expect(pathLooksUserConfigured('/some/path/.pyenv/shims:/usr/bin', home, delim)).toBe(true);
|
||||||
|
expect(pathLooksUserConfigured('/some/path/.opencode/bin:/usr/bin', home, delim)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects Windows home and toolchain paths', () => {
|
||||||
|
const windowsHome = 'C:\\Users\\agent';
|
||||||
|
expect(pathLooksUserConfigured('C:\\Users\\agent\\.bun\\bin;C:\\Windows\\System32', windowsHome, ';')).toBe(true);
|
||||||
|
expect(pathLooksUserConfigured('C:\\tools\\.cargo\\bin;C:\\Windows\\System32', windowsHome, ';')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mergePathValues', () => {
|
||||||
|
it('returns empty string for empty inputs', () => {
|
||||||
|
expect(mergePathValues('', '', delim)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns primary when fallback is empty', () => {
|
||||||
|
expect(mergePathValues('/a:/b', '', delim)).toBe('/a:/b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fallback when primary is empty', () => {
|
||||||
|
expect(mergePathValues('', '/a:/b', delim)).toBe('/a:/b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates segments, preserving primary order', () => {
|
||||||
|
expect(mergePathValues('/a:/b:/c', '/b:/d:/a', delim)).toBe('/a:/b:/c:/d');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends all fallback segments when no overlap', () => {
|
||||||
|
expect(mergePathValues('/a:/b', '/c:/d', delim)).toBe('/a:/b:/c:/d');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { registerOpenCodeProxy } from './proxy.js';
|
import { registerOpenCodeProxy } from './proxy.js';
|
||||||
|
import { pathLooksUserConfigured, mergePathValues } from './path-utils.js';
|
||||||
|
|
||||||
export const createServerUtilsRuntime = (dependencies) => {
|
export const createServerUtilsRuntime = (dependencies) => {
|
||||||
const {
|
const {
|
||||||
@@ -45,21 +46,6 @@ export const createServerUtilsRuntime = (dependencies) => {
|
|||||||
clearLastOpenCodeError();
|
clearLastOpenCodeError();
|
||||||
};
|
};
|
||||||
|
|
||||||
const pathLooksUserConfigured = (value) => {
|
|
||||||
if (typeof value !== 'string' || !value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const home = os.homedir();
|
|
||||||
return value.split(path.delimiter).some((segment) => (
|
|
||||||
segment.startsWith(home + path.sep)
|
|
||||||
|| segment === home
|
|
||||||
|| segment.startsWith('/opt/homebrew/')
|
|
||||||
|| segment.startsWith('/opt/pkg/')
|
|
||||||
|| segment.startsWith('/opt/pmk/')
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
const waitForOpenCodePort = async (timeoutMs = 15000) => {
|
const waitForOpenCodePort = async (timeoutMs = 15000) => {
|
||||||
if (getOpenCodePort() !== null) {
|
if (getOpenCodePort() !== null) {
|
||||||
return getOpenCodePort();
|
return getOpenCodePort();
|
||||||
@@ -77,40 +63,26 @@ export const createServerUtilsRuntime = (dependencies) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildAugmentedPath = () => {
|
const buildAugmentedPath = () => {
|
||||||
const home = os.homedir();
|
|
||||||
const currentPath = process.env.PATH || '';
|
const currentPath = process.env.PATH || '';
|
||||||
const loginShellPath = getLoginShellPath();
|
const loginShellPath = getLoginShellPath();
|
||||||
const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath);
|
const home = os.homedir();
|
||||||
|
const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath, home, path.delimiter);
|
||||||
const primaryPath = currentPathLooksUserConfigured ? currentPath : loginShellPath;
|
const primaryPath = currentPathLooksUserConfigured ? currentPath : loginShellPath;
|
||||||
const fallbackPath = currentPathLooksUserConfigured ? loginShellPath : currentPath;
|
const fallbackPath = currentPathLooksUserConfigured ? loginShellPath : currentPath;
|
||||||
const seen = new Set();
|
|
||||||
const augmented = [];
|
|
||||||
|
|
||||||
const addSegments = (value) => {
|
return mergePathValues(primaryPath, fallbackPath, path.delimiter);
|
||||||
if (typeof value !== 'string' || !value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const segment of value.split(path.delimiter)) {
|
|
||||||
if (segment && !seen.has(segment)) {
|
|
||||||
seen.add(segment);
|
|
||||||
augmented.push(segment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
addSegments(primaryPath);
|
|
||||||
addSegments(fallbackPath);
|
|
||||||
|
|
||||||
return augmented.join(path.delimiter);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildManagedOpenCodePath = () => {
|
const buildManagedOpenCodePath = () => {
|
||||||
const currentPath = process.env.PATH || '';
|
const currentPath = process.env.PATH || '';
|
||||||
if (pathLooksUserConfigured(currentPath)) {
|
const loginShellPath = getLoginShellPath();
|
||||||
|
const home = os.homedir();
|
||||||
|
|
||||||
|
if (pathLooksUserConfigured(currentPath, home, path.delimiter)) {
|
||||||
return currentPath;
|
return currentPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getLoginShellPath() || currentPath;
|
return mergePathValues(loginShellPath || '', currentPath, path.delimiter);
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseSseDataPayload = (block) => {
|
const parseSseDataPayload = (block) => {
|
||||||
|
|||||||
@@ -66,7 +66,15 @@ describe('server utils runtime', () => {
|
|||||||
|
|
||||||
const runtime = createRuntime(loginShellPath);
|
const runtime = createRuntime(loginShellPath);
|
||||||
|
|
||||||
expect(runtime.buildManagedOpenCodePath()).toBe(loginShellPath);
|
// Should prefer login shell PATH but merge in any process entries not already present.
|
||||||
|
expect(runtime.buildManagedOpenCodePath()).toBe([
|
||||||
|
path.join(home, '.opencode', 'bin'),
|
||||||
|
path.join(home, '.bun', 'bin'),
|
||||||
|
'/opt/homebrew/bin',
|
||||||
|
'/usr/bin',
|
||||||
|
'/usr/local/bin',
|
||||||
|
'/bin',
|
||||||
|
].join(path.delimiter));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves user-configured process PATH order before appending shell-only entries', () => {
|
it('preserves user-configured process PATH order before appending shell-only entries', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user