Merge remote-tracking branch 'origin/main' into feat/nested-git-repos
# Conflicts: # packages/ui/src/components/views/GitView.tsx # packages/ui/src/stores/DOCUMENTATION.md # packages/ui/src/stores/useGitStore.ts
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openchamber/ui",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/main.tsx",
|
||||
@@ -45,7 +45,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@legendapp/list": "3.3.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
"@opencode-ai/sdk": "1.18.25",
|
||||
"@pierre/diffs": "1.3.0-beta.6",
|
||||
"@replit/codemirror-vim": "^6.4.0",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
@@ -67,6 +67,7 @@
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"katex": "^0.17.0",
|
||||
"marked": "^17.0.3",
|
||||
"marked-linkify-it": "^4.0.2",
|
||||
"morphdom": "^2.7.7",
|
||||
"motion": "^12.23.24",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
+20
-20
@@ -7,6 +7,7 @@ import { Toaster } from '@/components/ui/sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel';
|
||||
import { setStreamPerfEnabled } from '@/stores/utils/streamDebug';
|
||||
import { setRequestsInFlightTrackingEnabled } from '@/stores/utils/requestsInFlight';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
// useEventStream removed — replaced by SyncProvider + SyncBridge
|
||||
import { useMenuActions } from '@/hooks/useMenuActions';
|
||||
@@ -19,8 +20,8 @@ import { useWebNotificationStream } from '@/hooks/useWebNotificationStream';
|
||||
import { useAgentMemorySync } from '@/hooks/useAgentMemorySync';
|
||||
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { useRootScrollLock } from '@/hooks/useRootScrollLock';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { hasModifier } from '@/lib/utils';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp, invokeDesktop } from '@/lib/desktop';
|
||||
import {
|
||||
getInjectedBootOutcome,
|
||||
@@ -48,6 +49,7 @@ import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
|
||||
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
@@ -246,6 +248,7 @@ function App({ apis }: AppProps) {
|
||||
const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
|
||||
const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
|
||||
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
||||
const refreshLinearAuthStatus = useLinearAuthStore((state) => state.refreshStatus);
|
||||
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
|
||||
// Embedded chats start inactive until the parent panel identifies the active
|
||||
// tab. Otherwise a newly loaded background tab can focus its composer first
|
||||
@@ -280,6 +283,13 @@ function App({ apis }: AppProps) {
|
||||
};
|
||||
}, [showMemoryDebug]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setRequestsInFlightTrackingEnabled(showMemoryDebug);
|
||||
return () => {
|
||||
setRequestsInFlightTrackingEnabled(false);
|
||||
};
|
||||
}, [showMemoryDebug]);
|
||||
|
||||
React.useEffect(() => {
|
||||
applyMobileKeyboardMode(mobileKeyboardMode);
|
||||
}, [mobileKeyboardMode]);
|
||||
@@ -337,7 +347,8 @@ function App({ apis }: AppProps) {
|
||||
}
|
||||
|
||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
||||
}, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]);
|
||||
void refreshLinearAuthStatus(apis.linear, { force: true });
|
||||
}, [apis.github, apis.linear, embeddedSessionChat, refreshGitHubAuthStatus, refreshLinearAuthStatus]);
|
||||
|
||||
useAppFontEffects();
|
||||
|
||||
@@ -710,6 +721,8 @@ function App({ apis }: AppProps) {
|
||||
|
||||
useWindowTitle();
|
||||
|
||||
useRootScrollLock();
|
||||
|
||||
useRouter();
|
||||
|
||||
const handleToggleMemoryDebug = React.useCallback(() => {
|
||||
@@ -723,25 +736,12 @@ function App({ apis }: AppProps) {
|
||||
|
||||
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
|
||||
|
||||
// Palette-only action: the memory debug panel has no keyboard shortcut.
|
||||
React.useEffect(() => {
|
||||
if (embeddedSessionChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isDebugShortcut = hasModifier(e)
|
||||
&& e.shiftKey
|
||||
&& !e.altKey
|
||||
&& (e.code === 'KeyD' || e.key.toLowerCase() === 'd');
|
||||
|
||||
if (isDebugShortcut) {
|
||||
e.preventDefault();
|
||||
setShowMemoryDebug(prev => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
if (embeddedSessionChat) return;
|
||||
const handleToggle = () => setShowMemoryDebug((previous) => !previous);
|
||||
window.addEventListener('openchamber:memory-debug-toggle', handleToggle);
|
||||
return () => window.removeEventListener('openchamber:memory-debug-toggle', handleToggle);
|
||||
}, [embeddedSessionChat]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout';
|
||||
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
|
||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { useRootScrollLock } from '@/hooks/useRootScrollLock';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
@@ -318,6 +319,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
|
||||
useMiniChatKeyboardShortcuts();
|
||||
usePushVisibilityBeacon({ enabled: true });
|
||||
useWindowTitle();
|
||||
useRootScrollLock();
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SettingsView } from '@/components/views/SettingsView';
|
||||
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
|
||||
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
||||
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
@@ -21,6 +22,7 @@ import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import type { ProjectRef } from '@/lib/projectContextApi';
|
||||
import { readTabletLayout, useOrientation, useTabletLayout } from '@/lib/device';
|
||||
import { useHardwareKeyboard } from '@/lib/hardwareKeyboard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -33,6 +35,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useMcpConfigStore, type McpDraft } from '@/stores/useMcpConfigStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
@@ -110,7 +113,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
const [workspaceTab, setWorkspaceTab] = React.useState<MobileWorkspaceTab>('changes');
|
||||
// A plan opened from the workspace drawer's Notes tab, shown as a fullscreen
|
||||
// layer on top of it (back returns to the notes).
|
||||
const [openPlan, setOpenPlan] = React.useState<{ id: string; title: string } | null>(null);
|
||||
const [openPlan, setOpenPlan] = React.useState<{ id: string; title: string; projectRef: ProjectRef } | null>(null);
|
||||
const [settingsInitialMobileStage, setSettingsInitialMobileStage] = React.useState<'nav' | 'page-content'>('nav');
|
||||
// When set, the Changes surface opens directly into the per-file diff for this path.
|
||||
const [pendingChangesDiff, setPendingChangesDiff] = React.useState<{ path: string; staged: boolean } | null>(null);
|
||||
@@ -541,7 +544,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<PlanView
|
||||
projectPlanId={openPlan.id}
|
||||
savedProjectPlan={{ projectRef: openPlan.projectRef, planId: openPlan.id }}
|
||||
onNavigatedToChat={() => {
|
||||
closeSurface();
|
||||
closeWorkspace();
|
||||
@@ -628,6 +631,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
const clearError = useSessionUIStore((state) => state.clearError);
|
||||
const setIsMobile = useUIStore((state) => state.setIsMobile);
|
||||
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
||||
const refreshLinearAuthStatus = useLinearAuthStore((state) => state.refreshStatus);
|
||||
const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const [connectionEpoch, setConnectionEpoch] = React.useState(0);
|
||||
@@ -676,6 +680,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
const refreshInPlace = () => {
|
||||
void initializeApp();
|
||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
||||
void refreshLinearAuthStatus(apis.linear, { force: true });
|
||||
if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' });
|
||||
if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' });
|
||||
};
|
||||
@@ -744,7 +749,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
lastNativeResumeSyncEventAtRef.current = now;
|
||||
window.dispatchEvent(new Event('openchamber:system-resume'));
|
||||
}
|
||||
}, [agentsCount, apis.github, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus]);
|
||||
}, [agentsCount, apis.github, apis.linear, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus, refreshLinearAuthStatus]);
|
||||
|
||||
useNativeMobileChrome();
|
||||
useNativeMobileLifecycle(handleNativeResume);
|
||||
@@ -772,6 +777,23 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
};
|
||||
}, [isNativeMobileApp, handleNativeResume]);
|
||||
|
||||
// A confirmed mid-session auth expiry (classified centrally from live 401
|
||||
// traffic) runs the same seq-guarded re-probe the resume path uses: it ends
|
||||
// in needs-login → the native welcome screen with the auth-expired notice.
|
||||
// The shared web banner never renders on native (the session gate is not
|
||||
// mounted here), so this is the only surface reacting to the signal.
|
||||
React.useEffect(() => {
|
||||
if (!isNativeMobileApp) return;
|
||||
return useAuthSessionStore.subscribe((store, previous) => {
|
||||
if (store.state === 'expired' && previous.state !== 'expired') {
|
||||
handleNativeResume();
|
||||
// The probe ladder owns the outcome from here; the shared store goes
|
||||
// back to 'ok' so a later expiry can signal again.
|
||||
useAuthSessionStore.getState().markAuthenticated();
|
||||
}
|
||||
});
|
||||
}, [isNativeMobileApp, handleNativeResume]);
|
||||
|
||||
React.useEffect(() => {
|
||||
registerRuntimeAPIs(apis);
|
||||
return () => registerRuntimeAPIs(null);
|
||||
@@ -1012,7 +1034,8 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
React.useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
||||
}, [apis.github, isConnected, refreshGitHubAuthStatus]);
|
||||
void refreshLinearAuthStatus(apis.linear, { force: true });
|
||||
}, [apis.github, apis.linear, isConnected, refreshGitHubAuthStatus, refreshLinearAuthStatus]);
|
||||
|
||||
// Discover all worktrees for every known project so the draft session's
|
||||
// worktree/branch dropdown can list every available branch — not only the
|
||||
|
||||
@@ -41,6 +41,8 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getProjectLabel, normalizePath } from './mobilePaths';
|
||||
import { CHAT_DRAFT_PROJECT_ID, isChatDirectoryPath } from '@/lib/chatDirectories';
|
||||
import { partitionSidebarSessions } from '@/components/session/sidebar/list/sessionCollection';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
@@ -1022,6 +1024,27 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
return merged.filter((session) => !session.time?.archived);
|
||||
}, [globalActiveSessions, liveSessions]);
|
||||
|
||||
// Managed Chats (sessions under ~/.config/openchamber/chats) are not owned
|
||||
// by any registered project; they get their own section above the project
|
||||
// tree, the same split the desktop sidebar makes. Temporary /btw forks are
|
||||
// dropped here as well.
|
||||
const { projectSessions, chatSessions } = React.useMemo(
|
||||
() => partitionSidebarSessions(sessions, false),
|
||||
[sessions],
|
||||
);
|
||||
const chatsBucket = React.useMemo<WorktreeBucket>(() => ({
|
||||
key: CHAT_DRAFT_PROJECT_ID,
|
||||
label: '',
|
||||
path: '',
|
||||
worktree: null,
|
||||
sessions: orderSessionsByLifecycleScopes(chatSessions, pinnedSessionIds, sessionOrderRanks),
|
||||
}), [chatSessions, pinnedSessionIds, sessionOrderRanks]);
|
||||
const chatsBucketKey = `${CHAT_DRAFT_PROJECT_ID}::${CHAT_DRAFT_PROJECT_ID}`;
|
||||
const chatRootCount = React.useMemo(
|
||||
() => chatSessions.filter((session) => !getParentId(session)).length,
|
||||
[chatSessions],
|
||||
);
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
|
||||
// On open, bring the current session (or at least its project) into view —
|
||||
@@ -1070,7 +1093,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
for (const worktree of node.project.worktrees) ensureBucket(node, worktree.path, worktree);
|
||||
}
|
||||
|
||||
for (const session of sessions) {
|
||||
for (const session of projectSessions) {
|
||||
const directory = getSessionDirectory(session);
|
||||
if (!directory) continue;
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
@@ -1093,7 +1116,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}, [activeProjectId, pinnedSessionIds, projectsMeta, sessionOrderRanks, sessions]);
|
||||
}, [activeProjectId, pinnedSessionIds, projectSessions, projectsMeta, sessionOrderRanks]);
|
||||
|
||||
const normalizedDirectory = normalizePath(currentDirectory);
|
||||
|
||||
@@ -1149,8 +1172,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
// Paginated, tree-aware list of a bucket's sessions: top-level sessions paginate,
|
||||
// and a parent with subsessions can be expanded to reveal its children (nested,
|
||||
// recursively). Pagination counts only top-level sessions.
|
||||
const renderBucketSessions = (node: ProjectNode, bucket: WorktreeBucket, indent: number) => {
|
||||
const bucketKey = `${node.project.id}::${bucket.key}`;
|
||||
const renderBucketSessions = (bucketKey: string, bucket: WorktreeBucket, indent: number) => {
|
||||
|
||||
// Group children by parent within this bucket, and treat sessions whose parent
|
||||
// is not in this bucket as top-level so nothing is hidden.
|
||||
@@ -1336,13 +1358,14 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
const buildSessionContextLabel = React.useCallback(
|
||||
(session: Session): string => {
|
||||
const directory = getSessionDirectory(session);
|
||||
if (isChatDirectoryPath(directory)) return t('mobile.sessions.section.chats');
|
||||
const project = findExactProjectMatch(projectsMeta, directory);
|
||||
if (!project) return getProjectLabel(directory) || directory;
|
||||
const matchedWorktree = findExactWorktreeMatch(project, normalizePath(directory));
|
||||
if (matchedWorktree?.branch) return `${project.label} · ${matchedWorktree.branch}`;
|
||||
return project.label;
|
||||
},
|
||||
[projectsMeta],
|
||||
[projectsMeta, t],
|
||||
);
|
||||
|
||||
const handleSelectProject = (project: ProjectMeta) => {
|
||||
@@ -1481,7 +1504,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{projectsMeta.length === 0 ? (
|
||||
{projectsMeta.length === 0 && chatSessions.length === 0 ? (
|
||||
<MobileSessionsEmpty
|
||||
title={t('mobile.sessions.empty.noProjectsTitle')}
|
||||
description={t('mobile.sessions.empty.noProjectsDescription')}
|
||||
@@ -1601,7 +1624,56 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{orderedNodes.map((node, nodeIndex) => {
|
||||
{(() => {
|
||||
const chatsExpanded = projectExpandedMap[CHAT_DRAFT_PROJECT_ID] ?? true;
|
||||
const chatsLabel = t('mobile.sessions.section.chats');
|
||||
return (
|
||||
<section>
|
||||
<div className="flex min-h-12 w-full items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-12 min-w-0 flex-1 items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
|
||||
onClick={() => {
|
||||
if (revealedRowId) {
|
||||
handleRowKeyRevealedChange(revealedRowId, false);
|
||||
return;
|
||||
}
|
||||
toggleProject(CHAT_DRAFT_PROJECT_ID, chatsExpanded);
|
||||
}}
|
||||
aria-expanded={chatsExpanded}
|
||||
aria-label={
|
||||
chatsExpanded
|
||||
? t('sessions.sidebar.group.collapseAria', { label: chatsLabel })
|
||||
: t('sessions.sidebar.group.expandAria', { label: chatsLabel })
|
||||
}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-[var(--surface-muted)] text-muted-foreground">
|
||||
<Icon name="chat-4" className="size-4" />
|
||||
</span>
|
||||
<span className="block min-w-0 flex-1 truncate typography-ui-label font-semibold text-foreground">
|
||||
{chatsLabel}
|
||||
</span>
|
||||
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">
|
||||
{chatRootCount}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{chatsExpanded ? (
|
||||
<div className="pb-2">
|
||||
{chatsBucket.sessions.length > 0 ? (
|
||||
renderBucketSessions(chatsBucketKey, chatsBucket, PROJECT_SESSION_INDENT)
|
||||
) : (
|
||||
<p className="px-3 pb-1 typography-micro text-muted-foreground" style={{ paddingLeft: PROJECT_SESSION_INDENT }}>
|
||||
{t('sessions.sidebar.activity.chatsEmpty')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
{orderedNodes.map((node) => {
|
||||
const projectExpanded = isProjectExpanded(node);
|
||||
const buckets = normalizedQuery
|
||||
? node.buckets.filter((bucket) =>
|
||||
@@ -1614,7 +1686,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
return (
|
||||
<section
|
||||
key={node.project.id}
|
||||
className={cn(nodeIndex > 0 && 'border-t border-border/70')}
|
||||
className="border-t border-border/70"
|
||||
>
|
||||
<MobileSwipeActionsRow
|
||||
actionsWidth={96}
|
||||
@@ -1712,7 +1784,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
return (
|
||||
<>
|
||||
{rootBucket && rootBucket.sessions.length > 0
|
||||
? renderBucketSessions(node, rootBucket, PROJECT_SESSION_INDENT)
|
||||
? renderBucketSessions(`${node.project.id}::${rootBucket.key}`, rootBucket, PROJECT_SESSION_INDENT)
|
||||
: null}
|
||||
{worktreeBuckets.map((bucket) => {
|
||||
const worktreeExpanded = isWorktreeExpanded(node, bucket);
|
||||
@@ -1787,7 +1859,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
</button>
|
||||
</MobileSwipeActionsRow>
|
||||
{worktreeExpanded
|
||||
? renderBucketSessions(node, bucket, PROJECT_SESSION_INDENT)
|
||||
? renderBucketSessions(`${node.project.id}::${bucket.key}`, bucket, PROJECT_SESSION_INDENT)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip';
|
||||
import { TerminalView } from '@/components/views/TerminalView';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ProjectRef } from '@/lib/projectContextApi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
|
||||
@@ -105,7 +106,7 @@ export const MobileWorkspaceDrawer: React.FC<{
|
||||
/** When set, the Changes tab opens directly into the per-file diff. */
|
||||
pendingChangesDiff: { path: string; staged: boolean } | null;
|
||||
/** Notes tab: opens a plan fullscreen (layered above the drawer). */
|
||||
onOpenPlan: (plan: { id: string; title: string }) => void;
|
||||
onOpenPlan: (plan: { id: string; title: string; projectRef: ProjectRef }) => void;
|
||||
/** MCP tab: jump to the MCP settings page pre-seeded with a new server draft. */
|
||||
onOpenMcpSettings: () => void;
|
||||
variant?: 'drawer' | 'panel';
|
||||
|
||||
@@ -14,6 +14,7 @@ import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
|
||||
import { useRouter } from '@/hooks/useRouter';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { useRootScrollLock } from '@/hooks/useRootScrollLock';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
@@ -57,6 +58,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
||||
useAppFontEffects();
|
||||
usePushVisibilityBeacon({ enabled: true });
|
||||
useWindowTitle();
|
||||
useRootScrollLock();
|
||||
useRouter();
|
||||
useGlobalSessionsPolling(panelType !== 'agentManager');
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { resetStreamingState } from '@/sync/streaming';
|
||||
import { useGlobalSessionStatusStore, replaceGlobalSessionStatusById } from '@/sync/global-session-status';
|
||||
import { replaceGlobalSessionStatusById } from '@/sync/global-session-status';
|
||||
import { resetSessionOrdering } from '@/sync/session-ordering';
|
||||
import { resetSessionActivityTiming } from '@/sync/session-activity-timing';
|
||||
import { syncDesktopSettings } from '@/lib/persistence';
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
||||
|
||||
/**
|
||||
* Non-blocking notice that the OpenChamber session expired mid-work. It never
|
||||
* takes the screen on its own: work stays visible and interactive, and only
|
||||
* the explicit "Log in" click hands control to the session gate's full login
|
||||
* flow (password, passkey, desktop shell — all already there).
|
||||
*/
|
||||
export const AuthExpiredBanner: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const authState = useAuthSessionStore((store) => store.state);
|
||||
const markReauthenticating = useAuthSessionStore((store) => store.markReauthenticating);
|
||||
|
||||
if (authState !== 'expired') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
// Below the header on purpose: the header row can be a window-drag region
|
||||
// on desktop, where nothing under the cursor is clickable.
|
||||
<div
|
||||
className="pointer-events-none fixed inset-x-0 z-[200] flex justify-center px-4"
|
||||
style={{ top: 'calc(var(--oc-header-height, 56px) + 8px)' }}
|
||||
>
|
||||
<div
|
||||
role="alert"
|
||||
className="oc-glass-popover oc-glass-floating pointer-events-auto flex items-center gap-3 rounded-lg px-3 py-2"
|
||||
>
|
||||
<Icon name="lock" className="size-4 flex-shrink-0" style={{ color: 'var(--status-error)' }} />
|
||||
<span className="typography-ui-label text-foreground">{t('sessionAuth.expired.banner')}</span>
|
||||
<Button size="xs" variant="outline" onClick={markReauthenticating} className="normal-case">
|
||||
{t('sessionAuth.expired.loginAction')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -303,6 +303,19 @@ mock.module('@/lib/passkeys', () => ({
|
||||
registerCurrentDevicePasskey: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
|
||||
const authSessionStore = {
|
||||
state: 'ok' as const,
|
||||
markAuthenticated: mock(() => undefined),
|
||||
};
|
||||
|
||||
mock.module('@/lib/runtime-auth-expiry', () => ({
|
||||
installAuthSessionFocusWatch: mock(() => undefined),
|
||||
useAuthSessionStore: Object.assign(
|
||||
(selector: (store: typeof authSessionStore) => unknown) => selector(authSessionStore),
|
||||
{ getState: () => authSessionStore },
|
||||
),
|
||||
}));
|
||||
|
||||
const { SessionAuthGate } = await import('./SessionAuthGate');
|
||||
|
||||
const flushEffects = async () => {
|
||||
|
||||
@@ -12,6 +12,8 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { installAuthSessionFocusWatch, useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
||||
import { AuthExpiredBanner } from './AuthExpiredBanner';
|
||||
import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts';
|
||||
@@ -351,6 +353,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
const [activePasskeyAction, setActivePasskeyAction] = React.useState<'auth' | 'register' | null>(null);
|
||||
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const hasResyncedRef = React.useRef(skipAuth);
|
||||
const hasBootstrapResyncedRef = React.useRef(skipAuth);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -557,6 +560,27 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
}
|
||||
}, [skipAuth, state]);
|
||||
|
||||
// Mid-session expiry: the banner asks for a re-login by flipping the shared
|
||||
// auth store to 'reauthenticating'; the gate answers with its own status
|
||||
// check, which lands in the full 'locked' flow on a genuine 401. A
|
||||
// successful login resolves the store back to 'ok'.
|
||||
const authSessionState = useAuthSessionStore((store) => store.state);
|
||||
React.useEffect(() => {
|
||||
if (!skipAuth) installAuthSessionFocusWatch();
|
||||
}, [skipAuth]);
|
||||
React.useEffect(() => {
|
||||
if (skipAuth) return;
|
||||
if (authSessionState === 'reauthenticating') {
|
||||
void checkStatusRef.current?.();
|
||||
}
|
||||
}, [authSessionState, skipAuth]);
|
||||
React.useEffect(() => {
|
||||
if (skipAuth) return;
|
||||
if (state === 'authenticated' && useAuthSessionStore.getState().state !== 'ok') {
|
||||
useAuthSessionStore.getState().markAuthenticated();
|
||||
}
|
||||
}, [skipAuth, state]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state === 'locked' && passwordInputRef.current) {
|
||||
passwordInputRef.current.focus();
|
||||
@@ -570,10 +594,18 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
}
|
||||
if (state === 'authenticated' && !hasResyncedRef.current) {
|
||||
hasResyncedRef.current = true;
|
||||
// First authentication of this page load is bootstrap: adopt the
|
||||
// persisted workspace pointers. A re-login after mid-session expiry is
|
||||
// not — this window already has its own workspace, and the shared
|
||||
// settings document may carry another window's pointers.
|
||||
const isBootstrapResync = !hasBootstrapResyncedRef.current;
|
||||
hasBootstrapResyncedRef.current = true;
|
||||
void (async () => {
|
||||
await initializeAppearancePreferences();
|
||||
await syncDesktopSettings();
|
||||
await applyPersistedDirectoryPreferences();
|
||||
await syncDesktopSettings({ adoptWorkspace: isBootstrapResync });
|
||||
if (isBootstrapResync) {
|
||||
await applyPersistedDirectoryPreferences();
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [skipAuth, state]);
|
||||
@@ -983,5 +1015,10 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
return (
|
||||
<>
|
||||
{skipAuth ? null : <AuthExpiredBanner />}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -339,6 +339,25 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tab
|
||||
}
|
||||
|
||||
if (action === 'browser.capture') {
|
||||
// A user may close the panel after browser.open. Chromium then removes
|
||||
// the zero-width webview's composited surface and capturePage() fails
|
||||
// with UnknownVizError. Reveal this existing browser tab again and let
|
||||
// the layout paint before asking Electron for the image.
|
||||
useUIStore.getState().openContextBrowser(directory, webview.getURL());
|
||||
const surfaceDeadline = Date.now() + 1_200;
|
||||
let previousWidth = 0;
|
||||
let stableSamples = 0;
|
||||
while (stableSamples < 2 && Date.now() < surfaceDeadline) {
|
||||
const width = webview.getBoundingClientRect().width;
|
||||
stableSamples = width >= 2 && Math.abs(width - previousWidth) < 0.5
|
||||
? stableSamples + 1
|
||||
: 0;
|
||||
previousWidth = width;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
// Wait for a settled page first: a screenshot of a half-painted layout is
|
||||
// worse than none, because it looks like a finished one.
|
||||
await waitForIdle();
|
||||
@@ -450,7 +469,7 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tab
|
||||
await waitForIdle();
|
||||
}
|
||||
return result;
|
||||
}, [annotationHost, loadUrl, waitForIdle]);
|
||||
}, [annotationHost, directory, loadUrl, waitForIdle]);
|
||||
|
||||
React.useEffect(
|
||||
() => registerBrowserController({ run: runControlAction }),
|
||||
|
||||
@@ -11,13 +11,27 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import ChatEmptyState from './ChatEmptyState';
|
||||
import { useGlobalSyncStore } from '@/sync/global-sync-store';
|
||||
import MessageList, { type MessageListHandle } from './MessageList';
|
||||
import { createTimelineRevealGate, TIMELINE_REVEAL_CAP_MS, TimelineRevealGateContext, type TimelineRevealGate } from './timelineRevealGate';
|
||||
|
||||
// How long the previous timeline stays on screen while a session that is not
|
||||
// in memory loads, before the skeleton takes over.
|
||||
const SESSION_SWITCH_HOLD_MS = 400;
|
||||
// End inset reserved for the status row that floats over the timeline's
|
||||
// bottom edge (its tallest resting height plus the mb-2 gap).
|
||||
const STATUS_OVERLAY_RESERVED_HEIGHT = 40;
|
||||
// A freshly opened timeline is shown once its content height has held still
|
||||
// for this many consecutive frames, or after the cap.
|
||||
const TIMELINE_SETTLE_STABLE_FRAMES = 2;
|
||||
const TIMELINE_SETTLE_CAP_MS = 300;
|
||||
import { PermissionCard } from './PermissionCard';
|
||||
import { QuestionCard } from './QuestionCard';
|
||||
import { hasActiveQuestionToolInCurrentTurn, recoverPendingQuestionWithRetry } from '@/sync/question-recovery';
|
||||
import { StatusRowContainer } from './StatusRowContainer';
|
||||
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
|
||||
import { SessionErrorNotice } from '@/components/chat/SessionErrorNotice';
|
||||
import ScrollToBottomButton from './components/ScrollToBottomButton';
|
||||
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
|
||||
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
||||
import { useScrollShadow } from '@/components/ui/useScrollShadow';
|
||||
import { useChatTimelineScroll, type TimelineListHandle } from '@/hooks/useChatTimelineScroll';
|
||||
import { useChatTimelineController } from './hooks/useChatTimelineController';
|
||||
@@ -55,6 +69,7 @@ import { WorkStatusPanel } from './work-status/WorkStatusPanel';
|
||||
import { useWorkStatusVisibility } from './work-status/useWorkStatusVisibility';
|
||||
import { getEmbeddedSessionChatOriginSessionId } from '@/components/layout/contextPanelEmbeddedChat';
|
||||
import { isFullySyntheticMessage } from '@/lib/messages/synthetic';
|
||||
import { hasContextParts } from '@/lib/messages/contextParts';
|
||||
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
||||
import { findShellCommandForMessage, isUserShellMarkerMessage } from './lib/shellBridge';
|
||||
import { resolveChatPromptReadOnly } from './chatPromptReadOnly';
|
||||
@@ -173,9 +188,9 @@ type ChatViewportProps = {
|
||||
} | null;
|
||||
scrollToBottom: () => void;
|
||||
endPinningReleased: boolean;
|
||||
// One-shot fade for content that replaced the hydration skeleton;
|
||||
// cached sessions render instantly without it.
|
||||
revealContent: boolean;
|
||||
/** The user waited for this session (held or fetched); reveal it with a fade. */
|
||||
revealWaited: boolean;
|
||||
revealGate: TimelineRevealGate;
|
||||
sessionQuestions: QuestionRequest[];
|
||||
sessionPermissions: PermissionRequest[];
|
||||
isProgrammaticFollowActive: boolean;
|
||||
@@ -212,7 +227,8 @@ const ChatViewport = React.memo(({
|
||||
retryOverlay,
|
||||
scrollToBottom,
|
||||
endPinningReleased,
|
||||
revealContent,
|
||||
revealWaited,
|
||||
revealGate,
|
||||
sessionQuestions,
|
||||
sessionPermissions,
|
||||
isProgrammaticFollowActive,
|
||||
@@ -261,7 +277,10 @@ const ChatViewport = React.memo(({
|
||||
// Other fully synthetic user messages (loop continuations,
|
||||
// plan-mode injections) are not prompts the user typed — keep
|
||||
// them out of the navigator entirely.
|
||||
if (isFullySyntheticMessage(message.parts)) {
|
||||
// Attached context (a quoted message, a terminal selection) is
|
||||
// synthetic transport-wise but is a turn the user sent, so a
|
||||
// context-only message stays navigable.
|
||||
if (isFullySyntheticMessage(message.parts) && !hasContextParts(message.parts)) {
|
||||
continue;
|
||||
}
|
||||
let displayParts = normalizedPromptPartsCache.current.get(message.parts);
|
||||
@@ -357,12 +376,84 @@ const ChatViewport = React.memo(({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SessionErrorNotice sessionId={currentSessionId} directory={directory} />
|
||||
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
|
||||
|
||||
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
|
||||
</>
|
||||
), [currentSessionId, directory, isMobile, sessionPermissions, sessionQuestions]);
|
||||
|
||||
// Opening a session paints the timeline as one finished picture: the root
|
||||
// stays invisible while any renderer holds a provisional first paint, then
|
||||
// everything appears together. A session the user waited for fades in
|
||||
// once as a whole; one that was ready at the click shows in the same
|
||||
// frame.
|
||||
const timelineRootRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const endPinningReleasedRef = React.useRef(endPinningReleased);
|
||||
endPinningReleasedRef.current = endPinningReleased;
|
||||
React.useLayoutEffect(() => {
|
||||
const root = timelineRootRef.current;
|
||||
if (!root) return;
|
||||
root.setAttribute('data-timeline-reveal', 'pending');
|
||||
let finished = false;
|
||||
let timer: number | null = null;
|
||||
let frame: number | null = null;
|
||||
// Revealed once the geometry has settled: after the last hold the
|
||||
// list still lays rows out from its own measurements over a few
|
||||
// frames, so the timeline stays hidden — pinned to the end on every
|
||||
// frame — until the content height has held still for two frames,
|
||||
// then shows already sitting on the end. The settle is bounded so a
|
||||
// list that keeps growing (images, late tool output) still appears.
|
||||
const reveal = (fade: boolean) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
const startedAt = performance.now();
|
||||
let lastHeight = -1;
|
||||
let stableFrames = 0;
|
||||
const settle = () => {
|
||||
frame = null;
|
||||
const node = scrollRef.current;
|
||||
let height = -1;
|
||||
if (node) {
|
||||
height = node.scrollHeight;
|
||||
if (!endPinningReleasedRef.current) {
|
||||
const end = height - node.clientHeight;
|
||||
if (end - node.scrollTop > 1) node.scrollTop = end;
|
||||
}
|
||||
}
|
||||
stableFrames = height === lastHeight ? stableFrames + 1 : 0;
|
||||
lastHeight = height;
|
||||
if (stableFrames < TIMELINE_SETTLE_STABLE_FRAMES && performance.now() - startedAt < TIMELINE_SETTLE_CAP_MS) {
|
||||
frame = window.requestAnimationFrame(settle);
|
||||
return;
|
||||
}
|
||||
if (fade) root.setAttribute('data-timeline-reveal', 'fading');
|
||||
else root.removeAttribute('data-timeline-reveal');
|
||||
};
|
||||
frame = window.requestAnimationFrame(settle);
|
||||
};
|
||||
// Holds are taken in layout effects, including those of rows the list
|
||||
// mounts in a nested synchronous pass; a microtask runs after all of
|
||||
// them and still before the browser paints this commit.
|
||||
queueMicrotask(() => {
|
||||
if (finished) return;
|
||||
revealGate.close();
|
||||
if (revealGate.holds === 0) {
|
||||
reveal(revealWaited);
|
||||
return;
|
||||
}
|
||||
revealGate.onEmpty = () => reveal(true);
|
||||
timer = window.setTimeout(() => reveal(true), TIMELINE_REVEAL_CAP_MS);
|
||||
});
|
||||
return () => {
|
||||
finished = true;
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||
revealGate.onEmpty = null;
|
||||
};
|
||||
}, [revealGate, revealWaited, scrollRef]);
|
||||
|
||||
const scrollContainerProps = React.useMemo(() => ({
|
||||
className: 'absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target',
|
||||
style: CHAT_SCROLL_STYLE,
|
||||
@@ -380,11 +471,12 @@ const ChatViewport = React.memo(({
|
||||
isDesktopExpandedInput
|
||||
? 'absolute inset-0 opacity-0 pointer-events-none'
|
||||
: 'flex-1',
|
||||
revealContent && !isDesktopExpandedInput && 'oc-chat-hydration-reveal',
|
||||
)}
|
||||
ref={timelineRootRef}
|
||||
aria-hidden={isDesktopExpandedInput}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<TimelineRevealGateContext.Provider value={revealGate}>
|
||||
<MessageList
|
||||
key={currentSessionKey}
|
||||
ref={messageListRef}
|
||||
@@ -412,6 +504,7 @@ const ChatViewport = React.memo(({
|
||||
listFooter={listFooter}
|
||||
scrollContainerProps={scrollContainerProps}
|
||||
/>
|
||||
</TimelineRevealGateContext.Provider>
|
||||
<OverlayScrollbar containerRef={scrollRef} disableHorizontal suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
|
||||
{showPromptNavigator && promptTurnIds.length >= 2 ? (
|
||||
<PromptNavigatorRail
|
||||
@@ -443,7 +536,8 @@ const ChatViewport = React.memo(({
|
||||
&& prev.retryOverlay === next.retryOverlay
|
||||
&& prev.scrollToBottom === next.scrollToBottom
|
||||
&& prev.endPinningReleased === next.endPinningReleased
|
||||
&& prev.revealContent === next.revealContent
|
||||
&& prev.revealWaited === next.revealWaited
|
||||
&& prev.revealGate === next.revealGate
|
||||
&& prev.sessionQuestions === next.sessionQuestions
|
||||
&& prev.sessionPermissions === next.sessionPermissions
|
||||
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
|
||||
@@ -583,10 +677,54 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
}) => {
|
||||
const messagesEnabled = messagesEnabledProp ?? active;
|
||||
const { t } = useI18n();
|
||||
// Session UI state
|
||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory);
|
||||
// Session UI state. The selection is published synchronously by the
|
||||
// sidebar click, but the chat swaps its content on a deferred copy: the
|
||||
// first commit paints the cheap reactions (active row, URL, tab) while the
|
||||
// timeline for the new session renders in an interruptible transition
|
||||
// behind it. Both fields travel as one value so the key, the message
|
||||
// subscription, and the loader target never mix an old directory with a
|
||||
// new session id.
|
||||
const liveSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||
const liveSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory);
|
||||
const materializedDraftSessionId = useSessionUIStore((s) => s.materializedDraftSessionId);
|
||||
const liveSelection = React.useMemo(
|
||||
() => ({ sessionId: liveSessionId, directory: liveSessionDirectory }),
|
||||
[liveSessionId, liveSessionDirectory],
|
||||
);
|
||||
// A session whose messages are not in memory yet keeps the previous
|
||||
// timeline on screen while they load, instead of flashing a skeleton
|
||||
// between two conversations. The hold ends when the session becomes
|
||||
// renderable or after SESSION_SWITCH_HOLD_MS, whichever comes first, and
|
||||
// never applies when nothing was shown before or when the session was just
|
||||
// created from a draft.
|
||||
const liveSessionRenderable = useSessionRenderable(liveSessionId ?? '', liveSessionDirectory ?? undefined);
|
||||
const shownSelectionRef = React.useRef(liveSelection);
|
||||
const [expiredHoldSessionId, setExpiredHoldSessionId] = React.useState<string | null>(null);
|
||||
const holdPreviousTimeline = Boolean(liveSessionId)
|
||||
&& !liveSessionRenderable
|
||||
&& liveSessionId !== materializedDraftSessionId
|
||||
&& shownSelectionRef.current.sessionId !== null
|
||||
&& shownSelectionRef.current.sessionId !== liveSessionId
|
||||
&& expiredHoldSessionId !== liveSessionId;
|
||||
React.useEffect(() => {
|
||||
if (!holdPreviousTimeline || !liveSessionId) return;
|
||||
const timer = window.setTimeout(() => setExpiredHoldSessionId(liveSessionId), SESSION_SWITCH_HOLD_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [holdPreviousTimeline, liveSessionId]);
|
||||
// A session the user waited for (not in memory at the click) fades in; one
|
||||
// that was ready appears in the same frame. Decided once per selection so
|
||||
// a later, warm visit to the same session is instant again.
|
||||
const lastLiveSessionIdRef = React.useRef<string | null | undefined>(undefined);
|
||||
const waitedSessionIdRef = React.useRef<string | null>(null);
|
||||
if (liveSessionId !== lastLiveSessionIdRef.current) {
|
||||
lastLiveSessionIdRef.current = liveSessionId;
|
||||
waitedSessionIdRef.current = liveSessionId && !liveSessionRenderable ? liveSessionId : null;
|
||||
}
|
||||
const targetSelection = holdPreviousTimeline ? shownSelectionRef.current : liveSelection;
|
||||
const { sessionId: currentSessionId, directory: currentSessionDirectory } = React.useDeferredValue(targetSelection);
|
||||
shownSelectionRef.current = { sessionId: currentSessionId, directory: currentSessionDirectory };
|
||||
const revealWaited = Boolean(currentSessionId) && currentSessionId === waitedSessionIdRef.current;
|
||||
|
||||
const clearMaterializedDraftSession = useSessionUIStore((s) => s.clearMaterializedDraftSession);
|
||||
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
|
||||
@@ -599,6 +737,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
const currentSessionKey = currentSessionId
|
||||
? JSON.stringify([getRuntimeKey(), effectiveSessionDirectory, currentSessionId])
|
||||
: null;
|
||||
// One gate per opened session; the scroll hook holds it until the
|
||||
// viewport is pinned to the end so the first visible frame is already
|
||||
// at the bottom.
|
||||
const revealGate = React.useMemo(() => createTimelineRevealGate(), [currentSessionKey]);
|
||||
const ensureSessionRenderable = React.useCallback(
|
||||
(sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory),
|
||||
[effectiveSessionDirectory, sync],
|
||||
@@ -645,6 +787,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
suspendPartUpdatesForMessageId: streamingMessageId,
|
||||
});
|
||||
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
|
||||
const authSessionExpired = useAuthSessionStore((store) => store.state !== 'ok');
|
||||
const wasAuthExpiredRef = React.useRef(false);
|
||||
const sessionMessageLoadState = useSessionMessageLoadState(
|
||||
currentSessionId ?? '',
|
||||
effectiveSessionDirectory,
|
||||
@@ -817,9 +961,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
return () => setWorkStatusPanelVisible(false);
|
||||
}, [setWorkStatusPanelVisible, showWorkStatusPanel]);
|
||||
const messageListRef = React.useRef<MessageListHandle | null>(null);
|
||||
// Session keys that showed the hydration skeleton this app run; their
|
||||
// content gets a one-shot reveal fade once it replaces the skeleton.
|
||||
const hydrationRevealKeyRef = React.useRef<string | null>(null);
|
||||
|
||||
const currentSession = useSession(currentSessionId, effectiveSessionDirectory);
|
||||
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
|
||||
@@ -900,13 +1041,17 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Selection policy reads the live selection, not the deferred one: right
|
||||
// after a click the deferred id still names the previous session (or
|
||||
// nothing) for one commit, and acting on that would open a draft over the
|
||||
// session the user just chose.
|
||||
React.useEffect(() => {
|
||||
if (autoOpenDraft && !currentSessionId && !draftOpen) {
|
||||
if (autoOpenDraft && !liveSessionId && !draftOpen) {
|
||||
// Programmatic fallback, not user navigation — must not clear the
|
||||
// persisted last-session pointer the cold-launch restore reads.
|
||||
openNewSessionDraft({ automatic: true });
|
||||
}
|
||||
}, [autoOpenDraft, currentSessionId, draftOpen, openNewSessionDraft]);
|
||||
}, [autoOpenDraft, liveSessionId, draftOpen, openNewSessionDraft]);
|
||||
|
||||
const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {});
|
||||
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
|
||||
@@ -917,7 +1062,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
// OVER the timeline's bottom edge; its measured height keeps the live
|
||||
// streaming line above it and reserves matching end inset in the list.
|
||||
const [statusOverlayHeight, setStatusOverlayHeight] = React.useState(0);
|
||||
const composerOverlayHeight = statusOverlayHeight;
|
||||
// The reserve is fixed so the timeline's end does not move when the row
|
||||
// appears a commit after the session opened: a viewport pinned to the end
|
||||
// would otherwise be left sitting the row's height above it. Measurement
|
||||
// only extends the reserve for a taller row.
|
||||
const composerOverlayHeight = Math.max(STATUS_OVERLAY_RESERVED_HEIGHT, statusOverlayHeight);
|
||||
const statusOverlayObserverRef = React.useRef<ResizeObserver | null>(null);
|
||||
const onStatusOverlayNode = React.useCallback((node: HTMLDivElement | null) => {
|
||||
statusOverlayObserverRef.current?.disconnect();
|
||||
@@ -974,6 +1123,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
sessionMessageCount,
|
||||
composerOverlayHeight,
|
||||
lastUserMessageId,
|
||||
sessionIsWorking,
|
||||
revealGate,
|
||||
onActiveTurnChange: handleActiveTurnChange,
|
||||
});
|
||||
|
||||
@@ -1156,20 +1307,28 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
const isSessionHydrating =
|
||||
Boolean(currentSessionId)
|
||||
&& !hasRenderableSessionSnapshot;
|
||||
React.useEffect(() => {
|
||||
if (isSessionHydrating || hydrationRevealKeyRef.current === null) return;
|
||||
// One-shot: forget the key after the reveal animation has played so a
|
||||
// later (now cached) visit to the same session opens instantly.
|
||||
const timer = setTimeout(() => {
|
||||
hydrationRevealKeyRef.current = null;
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isSessionHydrating, currentSessionKey]);
|
||||
const retrySessionLoad = React.useCallback(() => {
|
||||
if (!messagesEnabled || !currentSessionId) return;
|
||||
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
|
||||
}, [currentSessionId, effectiveSessionDirectory, messagesEnabled, sync]);
|
||||
|
||||
// A load that failed while the session was expired retries itself the
|
||||
// moment the re-login lands — the error screen should never outlive its
|
||||
// cause.
|
||||
React.useEffect(() => {
|
||||
if (authSessionExpired) {
|
||||
wasAuthExpiredRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (wasAuthExpiredRef.current) {
|
||||
wasAuthExpiredRef.current = false;
|
||||
if (sessionMessageLoadState.status === 'error') {
|
||||
retrySessionLoad();
|
||||
}
|
||||
}
|
||||
}, [authSessionExpired, retrySessionLoad, sessionMessageLoadState.status]);
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active || !currentSessionId) return;
|
||||
if (lastScrolledSessionKeyRef.current === currentSessionKey) return;
|
||||
@@ -1286,9 +1445,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
}
|
||||
|
||||
const showHydrationSkeleton = isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking;
|
||||
if (showHydrationSkeleton) {
|
||||
hydrationRevealKeyRef.current = currentSessionKey ?? currentSessionId ?? null;
|
||||
}
|
||||
if (showHydrationSkeleton) {
|
||||
if (sessionMessageLoadState.status === 'error') {
|
||||
return (
|
||||
@@ -1298,10 +1454,20 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
<Icon name="error-warning" className="size-4" />
|
||||
</div>
|
||||
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
|
||||
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
|
||||
{t('chat.container.sessionLoadError.retry')}
|
||||
</Button>
|
||||
<p className="typography-meta mt-1 text-muted-foreground">
|
||||
{authSessionExpired
|
||||
? t('chat.container.sessionLoadError.authDescription')
|
||||
: t('chat.container.sessionLoadError.description')}
|
||||
</p>
|
||||
{authSessionExpired ? (
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => useAuthSessionStore.getState().markReauthenticating()}>
|
||||
{t('sessionAuth.expired.loginAction')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
|
||||
{t('chat.container.sessionLoadError.retry')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1381,7 +1547,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
retryOverlay={retryOverlay}
|
||||
scrollToBottom={resumeToLatestInstant}
|
||||
endPinningReleased={userOwnsScroll}
|
||||
revealContent={hydrationRevealKeyRef.current !== null && hydrationRevealKeyRef.current === (currentSessionKey ?? currentSessionId ?? null)}
|
||||
revealWaited={revealWaited}
|
||||
revealGate={revealGate}
|
||||
sessionQuestions={sessionQuestions}
|
||||
sessionPermissions={sessionPermissions}
|
||||
isProgrammaticFollowActive={isFollowingProgrammatically}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@/sync/attachment-files';
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
import * as sessionActions from '@/sync/session-actions';
|
||||
import { buildLinkedIssue } from '@/lib/linkedIssues';
|
||||
import { buildLinkedIssue, buildLinkedLinearIssue } from '@/lib/linkedIssues';
|
||||
import { useUserMessageHistory } from "@/sync/sync-context";
|
||||
import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useSnippetsStore } from '@/stores/useSnippetsStore';
|
||||
@@ -35,7 +35,8 @@ import {
|
||||
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
|
||||
import { BtwPanel } from './btw/BtwPanel';
|
||||
import { useBtwPanelState } from './btw/useBtwPanelState';
|
||||
import { destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
|
||||
import { wasPromotedBtwSession } from '@/lib/sessionBtwMetadata';
|
||||
import { buildBtwSyntheticTexts, destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
|
||||
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
@@ -65,18 +66,21 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog';
|
||||
import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog';
|
||||
import { LinearIssuePickerDialog } from '@/components/session/LinearIssuePickerDialog';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { DraftPresetChips } from './DraftPresetChips';
|
||||
import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useGitStore, useIsGitRepo } from '@/stores/useGitStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useCommandsStore } from '@/stores/useCommandsStore';
|
||||
import { selectSkillsForDirectory, useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { selectCommandsForDirectory, useCommandsStore } from '@/stores/useCommandsStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import { togglePermissionAutoAccept } from './permissionAutoAccept';
|
||||
import { useKeybind } from '@/hooks/useKeybind';
|
||||
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
||||
import { extractGitChangedFiles } from './changedFiles';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
@@ -87,7 +91,18 @@ import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from
|
||||
import {
|
||||
assignImageAttachmentFilenames,
|
||||
buildAttachmentCitationText,
|
||||
nextPastedContextFilename,
|
||||
} from './attachmentCitations';
|
||||
import {
|
||||
createPastedContextFile,
|
||||
isLargePlainTextPaste,
|
||||
} from './composer/largeTextPaste';
|
||||
import {
|
||||
LARGE_TEXT_PASTE_TOAST_CLASSNAME,
|
||||
beginLargeTextPasteOffer,
|
||||
resolveLargeTextPasteOffer,
|
||||
} from './composer/largeTextPasteOffer';
|
||||
import type { LargeTextPasteBehavior } from '@/stores/useUIStore';
|
||||
import type { FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState';
|
||||
import {
|
||||
classifyMention,
|
||||
@@ -102,10 +117,12 @@ import {
|
||||
type ComposerEditorHandle,
|
||||
} from './composer/editor/ComposerEditor';
|
||||
import { createComposerEditorViewStore } from './composer/editor/viewStore';
|
||||
import { composerAutoCorrect } from './composer/editor/autocorrect';
|
||||
import {
|
||||
appendInlineText,
|
||||
appendWithLineBreaks,
|
||||
buildImagePasteInsertion,
|
||||
getMarkdownAutoPairEdit,
|
||||
shouldWrapSelectionAsLink,
|
||||
withInlineInsertionBoundaries,
|
||||
} from './composer/text';
|
||||
@@ -310,6 +327,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const messageRef = React.useRef(message);
|
||||
const currentChatDraftIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraftIdentityRef.current);
|
||||
const pendingPastedAttachmentFilenamesRef = React.useRef<Set<string>>(new Set());
|
||||
const largeTextPasteToastIdRef = React.useRef<string | number | null>(null);
|
||||
const largeTextPasteOfferIdRef = React.useRef(0);
|
||||
|
||||
// TODO: port sendMessage to session-actions (complex — creates sessions, handles attachments, etc.)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -336,6 +355,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
[btwDirectory, btwSessionId, currentSessionId],
|
||||
);
|
||||
const isBtwActive = Boolean(btwSessionRef) && !btwPanel.collapsed;
|
||||
// A session promoted out of `/btw` keeps the boundary instructions in its
|
||||
// transcript — there is no way to delete a message part — so it has to say
|
||||
// they no longer apply.
|
||||
const isPromotedBtwSession = wasPromotedBtwSession(btwPanel.parentSession);
|
||||
const activeRuntimeKey = getRuntimeKey();
|
||||
const chatDraftIdentity = React.useMemo(
|
||||
() => createChatDraftIdentity(
|
||||
@@ -400,10 +423,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const inputBarOffset = useUIStore((state) => state.inputBarOffset);
|
||||
const persistChatDraft = useUIStore((state) => state.persistChatDraft);
|
||||
const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled);
|
||||
const largeTextPasteBehavior = useUIStore((state) => state.largeTextPasteBehavior);
|
||||
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
|
||||
const setExpandedInput = useUIStore((state) => state.setExpandedInput);
|
||||
const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen);
|
||||
const { git: runtimeGit, vscode: vscodeApi } = useRuntimeAPIs();
|
||||
const { git: runtimeGit, vscode: vscodeApi, linear: runtimeLinear } = useRuntimeAPIs();
|
||||
const cycleAgentShortcutOverride = useUIStore((state) => state.shortcutOverrides.cycle_agent);
|
||||
const cycleAgentShortcut = React.useMemo(() => (
|
||||
getEffectiveShortcutCombo('cycle_agent', cycleAgentShortcutOverride ? { cycle_agent: cycleAgentShortcutOverride } : undefined)
|
||||
@@ -417,7 +441,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const ensureGitStatus = useGitStore((state) => state.ensureStatus);
|
||||
const fetchGitStatus = useGitStore((state) => state.fetchStatus);
|
||||
const clearGitDiffCache = useGitStore((state) => state.clearDiffCache);
|
||||
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
||||
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
|
||||
const [isNarrowComposer, setIsNarrowComposer] = React.useState(false);
|
||||
const [attachmentPreview, setAttachmentPreview] = React.useState<ToolPopupContent>({
|
||||
@@ -580,8 +603,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
|
||||
// Known slash-invocations (commands + skills + built-ins) used to highlight
|
||||
// matching /tokens in the composer, the same way confirmed @files are.
|
||||
const availableCommands = useCommandsStore((s) => s.commands);
|
||||
const availableSkills = useSkillsStore((s) => s.skills);
|
||||
const availableCommands = useCommandsStore((s) => selectCommandsForDirectory(s, currentDirectory));
|
||||
const availableSkills = useSkillsStore((s) => selectSkillsForDirectory(s, currentDirectory));
|
||||
const knownSlashNames = React.useMemo(() => {
|
||||
const names = new Set<string>([
|
||||
'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'btw', 'summary', 'workspace-review', 'plan-feature', 'craft-goal', 'schedule-task', 'catch-up', 'debug', 'weigh', 'explore',
|
||||
@@ -695,12 +718,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
attachments,
|
||||
};
|
||||
}, [resolveInlineFileMention]);
|
||||
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const prevWasAbortedRef = React.useRef(false);
|
||||
|
||||
// Issue linking state
|
||||
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
||||
const [prPickerOpen, setPrPickerOpen] = React.useState(false);
|
||||
const [linearPickerOpen, setLinearPickerOpen] = React.useState(false);
|
||||
const [linkedIssue, setLinkedIssue] = React.useState<{
|
||||
number: number;
|
||||
title: string;
|
||||
@@ -719,6 +742,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
contextText: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
} | null>(null);
|
||||
const [linkedLinearIssue, setLinkedLinearIssue] = React.useState<{
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
contextText: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
} | null>(null);
|
||||
|
||||
// Message queue
|
||||
const messageQueueTarget = currentSessionId
|
||||
@@ -951,6 +981,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
setPrPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const openLinearPicker = React.useCallback(() => {
|
||||
setLinearPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const getSubmitErrorMessage = (error: unknown, fallback: string) => {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
return message.toLowerCase().includes('runtime changed')
|
||||
@@ -964,6 +998,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const queuedMessageId = options?.queuedMessageId;
|
||||
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
|
||||
const capturedTarget = messageQueueTarget;
|
||||
// An expired session cannot deliver anything: keep the prompt in the
|
||||
// composer and point at the login banner instead of burning the send
|
||||
// on a guaranteed 401.
|
||||
if (useAuthSessionStore.getState().state !== 'ok') {
|
||||
toast.error(t('sessionAuth.expired.sendBlocked'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot the draft and current-session identity before the first
|
||||
// async gap so a later sidebar selection cannot reroute the send.
|
||||
const capturedDraftSnapshot = newSessionDraftOpen ? { ...newSessionDraft } : null;
|
||||
@@ -1002,6 +1044,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
|
||||
if (!providerIdToSend || !modelIdToSend) {
|
||||
console.warn('Cannot send message: provider or model not selected');
|
||||
toast.error(t('chat.chatInput.toast.noModelSelected'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1110,7 +1153,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
: [];
|
||||
|
||||
const availableSkillNames = new Set(
|
||||
useSkillsStore.getState().skills.map((skill) => skill.name),
|
||||
selectSkillsForDirectory(useSkillsStore.getState(), currentDirectory).map((skill) => skill.name),
|
||||
);
|
||||
|
||||
const outgoing = buildOutgoingMessage({
|
||||
@@ -1118,13 +1161,19 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
composerText: !queuedOnly && inputSnapshot.hasContent ? inputSnapshot.message : null,
|
||||
composerAttachments: attachedFiles,
|
||||
inlineComments: drafts,
|
||||
syntheticTexts: syntheticParts?.map((part) => part.text) ?? [],
|
||||
syntheticTexts: [
|
||||
...buildBtwSyntheticTexts({ isBtwActive, isPromotedBtwSession }),
|
||||
...(syntheticParts?.map((part) => part.text) ?? []),
|
||||
],
|
||||
linkedIssue: linkedIssue
|
||||
? { number: linkedIssue.number, title: linkedIssue.title, url: linkedIssue.url, contextText: linkedIssue.contextText }
|
||||
: null,
|
||||
linkedPr: linkedPr
|
||||
? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText }
|
||||
: null,
|
||||
linkedLinearIssue: linkedLinearIssue
|
||||
? { identifier: linkedLinearIssue.identifier, title: linkedLinearIssue.title, url: linkedLinearIssue.url, contextText: linkedLinearIssue.contextText }
|
||||
: null,
|
||||
}, {
|
||||
parseAgentMention: (text) => {
|
||||
const { sanitizedText, mention } = parseAgentMentions(text, agents);
|
||||
@@ -1362,6 +1411,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
if (linkedLinearIssue && linkTargetSessionId) {
|
||||
void sessionActions.setLinkedIssue(
|
||||
linkTargetSessionId,
|
||||
linkTargetDirectory,
|
||||
buildLinkedLinearIssue({
|
||||
identifier: linkedLinearIssue.identifier,
|
||||
title: linkedLinearIssue.title,
|
||||
url: linkedLinearIssue.url,
|
||||
author: linkedLinearIssue.author,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
// Clear linked issue after successful message send
|
||||
if (linkedIssue) {
|
||||
@@ -1370,6 +1433,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
if (linkedPr) {
|
||||
setLinkedPr(null);
|
||||
}
|
||||
if (linkedLinearIssue) {
|
||||
setLinkedLinearIssue(null);
|
||||
}
|
||||
}).catch((error: unknown) => {
|
||||
const rawMessage =
|
||||
error instanceof Error
|
||||
@@ -1382,10 +1448,25 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
console.error('Message send failed:', rawMessage || error);
|
||||
restoreConsumedDrafts();
|
||||
|
||||
const currentInput = composerRef.current?.getValue() ?? messageRef.current;
|
||||
if (newSessionDraftOpen && inputSnapshot.message && (!currentInput || currentInput === inputSnapshot.message)) {
|
||||
setMessage(inputSnapshot.message);
|
||||
writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
|
||||
// A failed send returns the typed prompt no matter WHY it failed —
|
||||
// auth, network, server, anything. Losing a long prompt to a toast
|
||||
// is the one outcome this handler must never produce.
|
||||
if (inputSnapshot.message) {
|
||||
if (currentChatDraftIdentityRef.current !== chatDraftIdentity) {
|
||||
// The user switched sessions mid-send: restore into that
|
||||
// session's persisted draft, not the visible composer.
|
||||
writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
|
||||
} else {
|
||||
const currentInput = composerRef.current?.getValue() ?? messageRef.current;
|
||||
if (!currentInput || currentInput === inputSnapshot.message) {
|
||||
setMessage(inputSnapshot.message);
|
||||
writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
|
||||
} else {
|
||||
// New typing already lives in the composer; the failed
|
||||
// prompt joins it instead of clobbering either text.
|
||||
useInputStore.getState().setPendingInputText(inputSnapshot.message, 'append');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isSoftNetworkError =
|
||||
@@ -1597,39 +1678,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const selEnd = ta?.getSelection().end ?? -1;
|
||||
|
||||
if (ta && selStart >= 0) {
|
||||
const applyEdit = (next: string, caretStart: number, caretEnd: number) => {
|
||||
const edit = getMarkdownAutoPairEdit(message, e.key, selStart, selEnd);
|
||||
if (edit) {
|
||||
e.preventDefault();
|
||||
setMessage(next);
|
||||
composerRef.current?.setSelection(caretStart, caretEnd);
|
||||
updateAutocompleteState(next, caretEnd);
|
||||
};
|
||||
|
||||
// Wrap the current selection: select text, press ` * _ ~ ( [ { " '
|
||||
const WRAP_PAIRS: Record<string, [string, string]> = {
|
||||
'`': ['`', '`'], '*': ['*', '*'], '_': ['_', '_'], '~': ['~', '~'],
|
||||
'(': ['(', ')'], '[': ['[', ']'], '{': ['{', '}'],
|
||||
'"': ['"', '"'], "'": ["'", "'"],
|
||||
};
|
||||
if (selEnd > selStart && WRAP_PAIRS[e.key]) {
|
||||
const [open, close] = WRAP_PAIRS[e.key];
|
||||
const selected = message.slice(selStart, selEnd);
|
||||
const next = `${message.slice(0, selStart)}${open}${selected}${close}${message.slice(selEnd)}`;
|
||||
applyEdit(next, selStart + open.length, selEnd + open.length);
|
||||
ta.replaceRange(
|
||||
edit.from,
|
||||
edit.to,
|
||||
edit.insert,
|
||||
edit.selectionStart,
|
||||
edit.selectionEnd,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Typing the third backtick at line start expands into a fenced
|
||||
// code block with the caret on the empty middle line (Slack-like).
|
||||
if (e.key === '`' && selStart === selEnd) {
|
||||
const before = message.slice(0, selStart);
|
||||
if (/(^|\n)``$/.test(before)) {
|
||||
const after = message.slice(selEnd);
|
||||
const next = `${before}\`\n\n\`\`\`${after}`;
|
||||
const caret = before.length + 2; // after the completed ``` and first newline
|
||||
applyEdit(next, caret, caret);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1696,29 +1756,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
containerRef: dropZoneRef,
|
||||
});
|
||||
|
||||
const startAbortIndicator = React.useCallback(() => {
|
||||
if (abortTimeoutRef.current) {
|
||||
clearTimeout(abortTimeoutRef.current);
|
||||
abortTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setShowAbortStatus(true);
|
||||
|
||||
abortTimeoutRef.current = setTimeout(() => {
|
||||
setShowAbortStatus(false);
|
||||
abortTimeoutRef.current = null;
|
||||
}, 1800);
|
||||
}, []);
|
||||
|
||||
const handleAbort = React.useCallback(() => {
|
||||
clearAbortPrompt();
|
||||
startAbortIndicator();
|
||||
|
||||
// btw mode: the stop button stops the fork's turn, not the main
|
||||
// session's.
|
||||
const abortTarget = isBtwActive && btwSessionId ? btwSessionId : currentSessionId;
|
||||
void abortCurrentOperation(abortTarget || undefined);
|
||||
}, [abortCurrentOperation, btwSessionId, clearAbortPrompt, currentSessionId, isBtwActive, startAbortIndicator]);
|
||||
}, [abortCurrentOperation, btwSessionId, clearAbortPrompt, currentSessionId, isBtwActive]);
|
||||
|
||||
const handleCycleAgent = React.useCallback((direction: 1 | -1 = 1) => {
|
||||
const nextAgentName = getCycledPrimaryAgentName(agents, currentAgentName, direction);
|
||||
@@ -1767,21 +1813,24 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
if (!editor) {
|
||||
// No mounted editor (collapsed mobile pill): append to the state
|
||||
// the editor will be seeded from.
|
||||
const nextValue = message + text;
|
||||
const nextValue = messageRef.current + text;
|
||||
setMessage(nextValue);
|
||||
updateAutocompleteState(nextValue, nextValue.length, inputSource, text);
|
||||
return;
|
||||
}
|
||||
|
||||
const { start, end } = editor.getSelection();
|
||||
const nextValue = `${message.substring(0, start)}${text}${message.substring(end)}`;
|
||||
// Read the live document — delayed toast actions must not use a
|
||||
// paste-time React `message` closure.
|
||||
const currentMessage = editor.getValue();
|
||||
const nextValue = `${currentMessage.substring(0, start)}${text}${currentMessage.substring(end)}`;
|
||||
const cursorPosition = start + text.length;
|
||||
|
||||
// One dispatch places both the text and the caret, so there is no
|
||||
// frame where the caret sits at a stale offset.
|
||||
editor.insertText(text);
|
||||
updateAutocompleteState(nextValue, cursorPosition, inputSource, text);
|
||||
}, [message, updateAutocompleteState]);
|
||||
}, [updateAutocompleteState]);
|
||||
|
||||
const clearDropTextSuppression = React.useCallback(() => {
|
||||
suppressNextFileDropTextInsertRef.current = false;
|
||||
@@ -1922,14 +1971,131 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
|
||||
const imageFiles = Array.from(fileMap.values());
|
||||
const pastedText = e.clipboardData.getData('text');
|
||||
const sessionReady = Boolean(currentSessionId || newSessionDraftOpen);
|
||||
|
||||
if (imageFiles.length === 0) {
|
||||
if (pastedText.includes('@')) {
|
||||
markFileMentionPasteSuppression();
|
||||
const behavior: LargeTextPasteBehavior = largeTextPasteBehavior;
|
||||
const shouldOfferLargePaste = sessionReady
|
||||
&& inputMode === 'normal'
|
||||
&& behavior !== 'inline'
|
||||
&& isLargePlainTextPaste(pastedText);
|
||||
|
||||
if (!shouldOfferLargePaste) {
|
||||
if (pastedText.includes('@')) {
|
||||
markFileMentionPasteSuppression();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Must run synchronously — ComposerEditor does not consume paste.
|
||||
e.preventDefault();
|
||||
|
||||
const pasteInline = () => {
|
||||
if (pastedText.includes('@')) {
|
||||
markFileMentionPasteSuppression();
|
||||
}
|
||||
insertTextAtSelection(
|
||||
pastedText,
|
||||
getFileMentionInputSourceForInsertedText(pastedText),
|
||||
);
|
||||
};
|
||||
|
||||
const attachAsFile = async () => {
|
||||
// Read live attachment + composer state at action time — the ask
|
||||
// toast can outlive the paste while the user types or attaches more.
|
||||
const liveAttachedFiles = useInputStore.getState().attachedFiles;
|
||||
const filename = nextPastedContextFilename([
|
||||
...liveAttachedFiles.map((file) => file.filename),
|
||||
...pendingPastedAttachmentFilenamesRef.current,
|
||||
]);
|
||||
const citationText = buildAttachmentCitationText([filename]);
|
||||
const editor = composerRef.current;
|
||||
const currentMessage = editor?.getValue() ?? messageRef.current;
|
||||
const selectionStart = editor?.getSelection().start ?? currentMessage.length;
|
||||
const selectionEnd = editor?.getSelection().end ?? currentMessage.length;
|
||||
const insertionText = withInlineInsertionBoundaries(
|
||||
citationText,
|
||||
currentMessage.slice(0, selectionStart),
|
||||
currentMessage.slice(selectionEnd),
|
||||
);
|
||||
|
||||
insertTextAtSelection(
|
||||
insertionText,
|
||||
getFileMentionInputSourceForInsertedText(insertionText),
|
||||
);
|
||||
|
||||
const file = createPastedContextFile(pastedText, filename);
|
||||
pendingPastedAttachmentFilenamesRef.current.add(filename);
|
||||
try {
|
||||
await addAttachedFile(file);
|
||||
} catch (error) {
|
||||
console.error('Clipboard text attach failed', error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('chat.chatInput.toast.clipboardTextAttachFailed'),
|
||||
);
|
||||
} finally {
|
||||
pendingPastedAttachmentFilenamesRef.current.delete(filename);
|
||||
}
|
||||
};
|
||||
|
||||
if (behavior === 'attach') {
|
||||
await attachAsFile();
|
||||
return;
|
||||
}
|
||||
|
||||
const offerId = beginLargeTextPasteOffer(largeTextPasteOfferIdRef.current);
|
||||
largeTextPasteOfferIdRef.current = offerId;
|
||||
|
||||
if (largeTextPasteToastIdRef.current !== null) {
|
||||
// Invalidate first so a synchronous onDismiss from dismiss()
|
||||
// cannot apply the superseded paste.
|
||||
toast.dismiss(largeTextPasteToastIdRef.current);
|
||||
largeTextPasteToastIdRef.current = null;
|
||||
}
|
||||
|
||||
const resolveLargePaste = (action: 'attach' | 'inline') => {
|
||||
const resolution = resolveLargeTextPasteOffer(
|
||||
largeTextPasteOfferIdRef.current,
|
||||
offerId,
|
||||
);
|
||||
largeTextPasteOfferIdRef.current = resolution.nextOfferId;
|
||||
if (!resolution.accepted) {
|
||||
return;
|
||||
}
|
||||
largeTextPasteToastIdRef.current = null;
|
||||
if (action === 'attach') {
|
||||
void attachAsFile();
|
||||
return;
|
||||
}
|
||||
pasteInline();
|
||||
};
|
||||
|
||||
largeTextPasteToastIdRef.current = toast.info(
|
||||
t('chat.chatInput.toast.largeTextPaste.title'),
|
||||
{
|
||||
duration: Infinity,
|
||||
className: LARGE_TEXT_PASTE_TOAST_CLASSNAME,
|
||||
action: {
|
||||
label: t('chat.chatInput.toast.largeTextPaste.attach'),
|
||||
onClick: () => resolveLargePaste('attach'),
|
||||
},
|
||||
cancel: {
|
||||
label: t('chat.chatInput.toast.largeTextPaste.inline'),
|
||||
onClick: () => resolveLargePaste('inline'),
|
||||
},
|
||||
onDismiss: () => {
|
||||
// Dismissing without a choice keeps the paste — insert inline
|
||||
// so clipboard content is not lost.
|
||||
resolveLargePaste('inline');
|
||||
},
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSessionId && !newSessionDraftOpen) {
|
||||
if (!sessionReady) {
|
||||
if (pastedText.includes('@')) {
|
||||
markFileMentionPasteSuppression();
|
||||
}
|
||||
@@ -1970,7 +2136,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
pendingPastedAttachmentFilenamesRef.current.delete(filename);
|
||||
}
|
||||
}
|
||||
}, [addAttachedFile, attachedFiles, currentSessionId, inputMode, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]);
|
||||
}, [addAttachedFile, attachedFiles, currentSessionId, inputMode, largeTextPasteBehavior, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]);
|
||||
|
||||
const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => {
|
||||
|
||||
@@ -2129,10 +2295,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
if (active && currentSessionId && composerRef.current && !isMobile) {
|
||||
composerRef.current.focus();
|
||||
}
|
||||
if (!active || !currentSessionId || isMobile) return;
|
||||
// Focusing forces layout. Right after a session switch the layout is
|
||||
// dirty from the whole timeline mounting, so the focus call would pay
|
||||
// for that layout inside the commit; a frame later it is nearly free.
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
composerRef.current?.focus();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [active, currentSessionId, isMobile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -2391,6 +2561,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
|
||||
const footerGapClass = 'gap-x-1.5 gap-y-0';
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const showLinearPicker = Boolean(runtimeLinear) && !isVSCode;
|
||||
// The work-status panel carries the agent's todos and the changed-file
|
||||
// count, but only on the desktop/web layout — VS Code and mobile have no
|
||||
// panel, so these keep their place above the composer there.
|
||||
@@ -2471,6 +2642,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
draftPickerOpen: mobileDraftPicker !== null,
|
||||
issuePickerOpen,
|
||||
prPickerOpen,
|
||||
linearPickerOpen,
|
||||
isDragging,
|
||||
},
|
||||
});
|
||||
@@ -2562,31 +2734,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
t,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const pendingAbortBanner = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId;
|
||||
if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) {
|
||||
startAbortIndicator();
|
||||
if (currentSessionId) {
|
||||
acknowledgeSessionAbort(currentSessionId);
|
||||
}
|
||||
}
|
||||
prevWasAbortedRef.current = pendingAbortBanner;
|
||||
}, [
|
||||
abortPromptSessionId,
|
||||
acknowledgeSessionAbort,
|
||||
currentSessionId,
|
||||
showAbortStatus,
|
||||
startAbortIndicator,
|
||||
]);
|
||||
useKeybind('toggle_permission_auto_accept', () => {
|
||||
if (!isPermissionAutoAcceptInteractive) return false;
|
||||
handlePermissionAutoAcceptToggle();
|
||||
});
|
||||
|
||||
// Acknowledging the abort record is what lets the working chip resume for
|
||||
// the next run; the old "Aborted" banner that used to accompany it is gone.
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (abortTimeoutRef.current) {
|
||||
clearTimeout(abortTimeoutRef.current);
|
||||
abortTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const pendingAbort = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId;
|
||||
if (!prevWasAbortedRef.current && pendingAbort && currentSessionId) {
|
||||
acknowledgeSessionAbort(currentSessionId);
|
||||
}
|
||||
prevWasAbortedRef.current = pendingAbort;
|
||||
}, [abortPromptSessionId, acknowledgeSessionAbort, currentSessionId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -2652,12 +2813,23 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
onRemove={() => setLinkedPr(null)}
|
||||
/>
|
||||
) : null}
|
||||
{linkedLinearIssue && !isVSCode ? (
|
||||
<LinkedReferenceRow
|
||||
numberLabel={linkedLinearIssue.identifier}
|
||||
title={linkedLinearIssue.title}
|
||||
url={linkedLinearIssue.url}
|
||||
author={linkedLinearIssue.author}
|
||||
openInBrowserLabel={t('chat.chatInput.linked.linearIssue.openInBrowserAria')}
|
||||
removeLabel={t('chat.chatInput.linked.linearIssue.removeAria')}
|
||||
onReopenPicker={() => setLinearPickerOpen(true)}
|
||||
onRemove={() => setLinkedLinearIssue(null)}
|
||||
/>
|
||||
) : null}
|
||||
<RevertedMessageDock
|
||||
sessionId={currentSessionId}
|
||||
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
||||
/>
|
||||
<MemoComposerStatusBar
|
||||
showAbortStatus={showAbortStatus}
|
||||
showTodos={composerStatusExtrasEnabled}
|
||||
leftAccessory={!composerStatusExtrasEnabled || newSessionDraftOpen || !hasPendingChanges
|
||||
? null
|
||||
@@ -2718,6 +2890,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
onPickLocalFiles={handlePickLocalFiles}
|
||||
onOpenIssuePicker={openIssuePicker}
|
||||
onOpenPrPicker={openPrPicker}
|
||||
showLinearPicker={showLinearPicker}
|
||||
onOpenLinearPicker={openLinearPicker}
|
||||
onOpenAttachSheet={openMobileAttachSheet}
|
||||
onStartDictation={toggleDictation}
|
||||
onAbort={handleAbort}
|
||||
@@ -2854,7 +3028,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
|
||||
: t('chat.chatInput.placeholder.selectSession')}
|
||||
editable={Boolean(currentSessionId || newSessionDraftOpen)}
|
||||
autoCorrect={isMobile}
|
||||
autoCorrect={composerAutoCorrect({ isMobile })}
|
||||
autoCapitalize={isMobile ? 'sentences' : 'none'}
|
||||
spellCheck={isMobile || inputSpellcheckEnabled}
|
||||
fillContainer={isComposerExpanded}
|
||||
@@ -2898,6 +3072,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
onPickLocalFiles={handlePickLocalFiles}
|
||||
onOpenIssuePicker={openIssuePicker}
|
||||
onOpenPrPicker={openPrPicker}
|
||||
showLinearPicker={showLinearPicker}
|
||||
onOpenLinearPicker={openLinearPicker}
|
||||
onOpenAttachSheet={openMobileAttachSheet}
|
||||
onToggleExpandedInput={handleToggleExpandedInput}
|
||||
onTogglePermissionAutoAccept={handlePermissionAutoAcceptToggle}
|
||||
@@ -2962,6 +3138,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
onSelect={(issue) => {
|
||||
setLinkedIssue(issue);
|
||||
setLinkedPr(null);
|
||||
setLinkedLinearIssue(null);
|
||||
}}
|
||||
/>
|
||||
<GitHubPrPickerDialog
|
||||
@@ -2970,6 +3147,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
onSelect={(pr) => {
|
||||
setLinkedPr(pr);
|
||||
setLinkedIssue(null);
|
||||
setLinkedLinearIssue(null);
|
||||
}}
|
||||
/>
|
||||
<LinearIssuePickerDialog
|
||||
open={linearPickerOpen}
|
||||
onOpenChange={setLinearPickerOpen}
|
||||
mode="select"
|
||||
onSelect={(issue) => {
|
||||
setLinkedLinearIssue(issue);
|
||||
setLinkedIssue(null);
|
||||
setLinkedPr(null);
|
||||
}}
|
||||
/>
|
||||
<ReviewFlowDialog
|
||||
@@ -3053,6 +3241,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
<Icon name="git-pull-request" className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
|
||||
{t('chat.chatInput.actions.linkGithubPr')}
|
||||
</button>
|
||||
{showLinearPicker ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-3 text-left typography-ui-label hover:bg-[var(--interactive-hover)]"
|
||||
onClick={() => {
|
||||
mobileShell.skipNextOverlayCloseRestore();
|
||||
setMobileAttachMenuOpen(false);
|
||||
requestAnimationFrame(openLinearPicker);
|
||||
}}
|
||||
>
|
||||
<Icon name="linear" className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
|
||||
{t('chat.chatInput.actions.linkLinearIssue')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</MobileOverlayPanel>
|
||||
) : null}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { deriveMessageRole } from './message/messageRole';
|
||||
import { filterVisibleParts, normalizeParts } from './message/partUtils';
|
||||
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
||||
import { isHiddenUserMessage } from './message/hiddenUserMessage';
|
||||
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
||||
import { flattenAssistantTextParts, flattenUserTextParts } from '@/lib/messages/messageText';
|
||||
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
|
||||
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
@@ -457,13 +457,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
}, [chatRenderMode, isMessageCompleted, isUser, visibleParts]);
|
||||
|
||||
|
||||
const assistantTextParts = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
return [];
|
||||
}
|
||||
return visibleParts.filter((part) => part.type === 'text');
|
||||
}, [isUser, visibleParts]);
|
||||
|
||||
const toolParts = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
return [];
|
||||
@@ -545,19 +538,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
|
||||
const shouldHideUserMessage = isUser && displayParts.length === 0;
|
||||
|
||||
// Message is considered to have an "open step" if info.finish is not yet present
|
||||
const hasOpenStep = typeof messageFinish !== 'string';
|
||||
|
||||
const shouldCoordinateRendering = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
return false;
|
||||
}
|
||||
if (assistantTextParts.length === 0 || toolParts.length === 0) {
|
||||
return hasOpenStep;
|
||||
}
|
||||
return true;
|
||||
}, [assistantTextParts.length, toolParts.length, hasOpenStep, isUser]);
|
||||
|
||||
const themeVariant = currentTheme?.metadata.variant;
|
||||
const isDarkTheme = React.useMemo(() => {
|
||||
if (themeVariant) {
|
||||
@@ -722,40 +702,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
|
||||
const messageTextContent = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
const shellOutputs = displayParts
|
||||
.filter((part): part is Part & { type: 'text'; shellAction?: { output?: unknown } } => part.type === 'text')
|
||||
.map((part) => {
|
||||
const output = part.shellAction?.output;
|
||||
return typeof output === 'string' ? output.trim() : '';
|
||||
})
|
||||
.filter((output) => output.length > 0);
|
||||
|
||||
if (shellOutputs.length > 0) {
|
||||
return shellOutputs.join('\n\n');
|
||||
}
|
||||
|
||||
const shellCommands = displayParts
|
||||
.filter((part): part is Part & { type: 'text'; shellAction?: { command?: unknown } } => part.type === 'text')
|
||||
.map((part) => {
|
||||
const command = part.shellAction?.command;
|
||||
return typeof command === 'string' ? command.trim() : '';
|
||||
})
|
||||
.filter((command) => command.length > 0);
|
||||
|
||||
if (shellCommands.length > 0) {
|
||||
return shellCommands.join('\n');
|
||||
}
|
||||
|
||||
const textParts = displayParts
|
||||
.filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text')
|
||||
.map((part) => {
|
||||
const text = part.text || part.content || '';
|
||||
return text.trim();
|
||||
})
|
||||
.filter((text) => text.length > 0);
|
||||
|
||||
const combined = textParts.join('\n');
|
||||
return combined.replace(/\n\s*\n+/g, '\n');
|
||||
return flattenUserTextParts(displayParts);
|
||||
}
|
||||
|
||||
if (assistantErrorText && assistantErrorText.trim().length > 0) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { cn, fuzzyMatch } from '@/lib/utils';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionMessages } from '@/sync/sync-context';
|
||||
import { useCommandsStore } from '@/stores/useCommandsStore';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { selectCommandsForDirectory, useCommandsStore } from '@/stores/useCommandsStore';
|
||||
import { selectSkillsForDirectory, useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -66,8 +66,6 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
}, ref) => {
|
||||
const { t } = useI18n();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const sessionMessages = useSessionMessages(currentSessionId ?? '');
|
||||
const hasMessagesInCurrentSession = sessionMessages.length > 0;
|
||||
const hasSession = Boolean(currentSessionId);
|
||||
const hasNewSessionDraft = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const canStartSessionCommand = hasSession || hasNewSessionDraft;
|
||||
@@ -76,10 +74,16 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
|
||||
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const commandsWithMetadata = useCommandsStore((s) => s.commands);
|
||||
const refreshCommands = useCommandsStore((s) => s.loadCommands);
|
||||
const skills = useSkillsStore((s) => s.skills);
|
||||
const refreshSkills = useSkillsStore((s) => s.loadSkills);
|
||||
// Commands and skills belong to the directory the composer sends to — the
|
||||
// session's own directory, or the Chats root for a chat draft — not to the
|
||||
// project the app was on last.
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const commandsWithMetadata = useCommandsStore((s) => selectCommandsForDirectory(s, effectiveDirectory));
|
||||
const loadCommandsForDirectory = useCommandsStore((s) => s.loadCommands);
|
||||
const skills = useSkillsStore((s) => selectSkillsForDirectory(s, effectiveDirectory));
|
||||
const loadSkillsForDirectory = useSkillsStore((s) => s.loadSkills);
|
||||
const refreshCommands = React.useCallback(() => loadCommandsForDirectory(effectiveDirectory), [effectiveDirectory, loadCommandsForDirectory]);
|
||||
const refreshSkills = React.useCallback(() => loadSkillsForDirectory(effectiveDirectory), [effectiveDirectory, loadSkillsForDirectory]);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const selectedIndexRef = React.useRef(0);
|
||||
const keyboardNavigationRef = React.useRef(false);
|
||||
@@ -140,7 +144,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
}));
|
||||
|
||||
const builtInCommands: CommandInfo[] = [
|
||||
...(hasSession && !hasMessagesInCurrentSession
|
||||
...(hasSession
|
||||
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
|
||||
: []
|
||||
),
|
||||
@@ -200,10 +204,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
];
|
||||
const allCommands = mergeCommandAutocompleteItems(builtInCommands, customCommands, skillCommands);
|
||||
|
||||
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||
const filtered = (searchQuery
|
||||
const filtered = searchQuery
|
||||
? allCommands.filter(cmd => commandMatchesSearch(cmd, searchQuery))
|
||||
: allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
||||
: allCommands;
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
const aStartsWith = a.name.toLowerCase().startsWith(searchQuery.toLowerCase());
|
||||
@@ -216,9 +219,8 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
setCommands(filtered);
|
||||
} catch {
|
||||
|
||||
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||
const builtInCommands: CommandInfo[] = [
|
||||
...(hasSession && !hasMessagesInCurrentSession
|
||||
...(hasSession
|
||||
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
|
||||
: []
|
||||
),
|
||||
@@ -277,12 +279,12 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
),
|
||||
];
|
||||
|
||||
const filtered = (searchQuery
|
||||
const filtered = searchQuery
|
||||
? builtInCommands.filter(cmd =>
|
||||
fuzzyMatch(cmd.name, searchQuery) ||
|
||||
(cmd.description && fuzzyMatch(cmd.description, searchQuery))
|
||||
)
|
||||
: builtInCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
||||
: builtInCommands;
|
||||
|
||||
setCommands(filtered);
|
||||
} finally {
|
||||
@@ -291,7 +293,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
};
|
||||
|
||||
loadCommands();
|
||||
}, [searchQuery, hasMessagesInCurrentSession, hasSession, canStartSessionCommand, canUseReviewHandoffFlow, commandsWithMetadata, skills, t]);
|
||||
}, [searchQuery, hasSession, canStartSessionCommand, canUseReviewHandoffFlow, commandsWithMetadata, skills, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
|
||||
@@ -116,13 +116,11 @@ const TodoItemRow: React.FC<{ todo: TodoItem }> = ({ todo }) => {
|
||||
const EMPTY_TODOS: TodoItem[] = [];
|
||||
|
||||
interface ComposerStatusBarProps {
|
||||
showAbortStatus?: boolean;
|
||||
showTodos?: boolean;
|
||||
leftAccessory?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ComposerStatusBar: React.FC<ComposerStatusBarProps> = ({
|
||||
showAbortStatus,
|
||||
showTodos = true,
|
||||
leftAccessory,
|
||||
}) => {
|
||||
@@ -186,7 +184,7 @@ export const ComposerStatusBar: React.FC<ComposerStatusBarProps> = ({
|
||||
|
||||
const hasTodoContent = showTodos && statusSummary.left > 0;
|
||||
const hasLeftAccessory = Boolean(leftAccessory);
|
||||
const hasContent = Boolean(showAbortStatus) || hasTodoContent || hasLeftAccessory;
|
||||
const hasContent = hasTodoContent || hasLeftAccessory;
|
||||
|
||||
const popoverRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useEffect(() => {
|
||||
@@ -252,16 +250,7 @@ export const ComposerStatusBar: React.FC<ComposerStatusBarProps> = ({
|
||||
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}>
|
||||
{/* Left: abort status | pending-changes accessory */}
|
||||
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
|
||||
{showAbortStatus ? (
|
||||
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
|
||||
<span className="flex items-center gap-1.5 typography-ui-label">
|
||||
<Icon name="close-circle" aria-hidden="true" />
|
||||
{t('chat.statusRow.aborted')}
|
||||
</span>
|
||||
</div>
|
||||
) : leftAccessory ? (
|
||||
leftAccessory
|
||||
) : null}
|
||||
{leftAccessory ?? null}
|
||||
</div>
|
||||
|
||||
{/* Right: todos dropdown */}
|
||||
|
||||
@@ -558,17 +558,29 @@ interface FilePart {
|
||||
|
||||
const GITHUB_ISSUE_LINK_MIME = 'application/vnd.github.issue-link';
|
||||
const GITHUB_PR_LINK_MIME = 'application/vnd.github.pull-request-link';
|
||||
const LINEAR_ISSUE_LINK_MIME = 'application/vnd.openchamber.linear-issue-link';
|
||||
|
||||
const getGitHubLinkKind = (file: FilePart): 'issue' | 'pr' | null => {
|
||||
type IssueLinkKind = 'github-issue' | 'github-pr' | 'linear-issue';
|
||||
|
||||
const getIssueLinkKind = (file: FilePart): IssueLinkKind | null => {
|
||||
if (file.mime === GITHUB_ISSUE_LINK_MIME) {
|
||||
return 'issue';
|
||||
return 'github-issue';
|
||||
}
|
||||
if (file.mime === GITHUB_PR_LINK_MIME) {
|
||||
return 'pr';
|
||||
return 'github-pr';
|
||||
}
|
||||
if (file.mime === LINEAR_ISSUE_LINK_MIME) {
|
||||
return 'linear-issue';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const issueLinkIcon = (kind: IssueLinkKind): 'github' | 'git-pull-request' | 'linear' => {
|
||||
if (kind === 'github-pr') return 'git-pull-request';
|
||||
if (kind === 'linear-issue') return 'linear';
|
||||
return 'github';
|
||||
};
|
||||
|
||||
interface MessageFilesDisplayProps {
|
||||
files: FilePart[];
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
@@ -591,7 +603,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
};
|
||||
|
||||
const resolveDisplayName = React.useCallback((file: FilePart): string => {
|
||||
const isGitHubLink = getGitHubLinkKind(file) !== null;
|
||||
const isGitHubLink = getIssueLinkKind(file) !== null;
|
||||
if (isGitHubLink && typeof file.filename === 'string' && file.filename.trim().length > 0) {
|
||||
return file.filename.trim();
|
||||
}
|
||||
@@ -665,11 +677,11 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
const fileName = resolveDisplayName(file);
|
||||
const ext = fileName.split('.').pop() || '';
|
||||
const sizeText = formatFileSize(file.size);
|
||||
const githubLinkKind = getGitHubLinkKind(file);
|
||||
const issueLinkKind = getIssueLinkKind(file);
|
||||
return (
|
||||
<Tooltip key={`file-${file.url || file.filename || index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
{githubLinkKind && file.url ? (
|
||||
{issueLinkKind && file.url ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -677,11 +689,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
}}
|
||||
className="inline-flex items-center bg-muted/30 border border-border/30 typography-meta gap-1 px-2 py-0.5 rounded-lg text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{githubLinkKind === 'pr' ? (
|
||||
<Icon name="git-pull-request" className="text-muted-foreground h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Icon name="github" className="text-muted-foreground h-3.5 w-3.5" />
|
||||
)}
|
||||
<Icon name={issueLinkIcon(issueLinkKind)} className="text-muted-foreground h-3.5 w-3.5" />
|
||||
<div className="overflow-hidden max-w-[220px]">
|
||||
<span className="truncate block" title={fileName}>{fileName}</span>
|
||||
</div>
|
||||
@@ -764,7 +772,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
const fileName = resolveDisplayName(file);
|
||||
const isImage = file.mime?.startsWith('image/');
|
||||
const sizeText = formatFileSize(file.size);
|
||||
const githubLinkKind = getGitHubLinkKind(file);
|
||||
const issueLinkKind = getIssueLinkKind(file);
|
||||
|
||||
if (isImage && file.url) {
|
||||
return (
|
||||
@@ -787,7 +795,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
);
|
||||
}
|
||||
|
||||
if (githubLinkKind && file.url) {
|
||||
if (issueLinkKind && file.url) {
|
||||
return (
|
||||
<Tooltip key={file.url || `${fileName}-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -802,11 +810,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
)}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{githubLinkKind === 'pr' ? (
|
||||
<Icon name="git-pull-request" className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
) : (
|
||||
<Icon name="github" className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
)}
|
||||
<Icon name={issueLinkIcon(issueLinkKind)} className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{fileName}</p>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { loadMarkdownRendererModule } from './markdownRendererLoader';
|
||||
import { getLoadedMarkdownRendererModule, loadMarkdownRendererModule } from './markdownRendererLoader';
|
||||
|
||||
// Thin lazy wrapper around the MarkdownRenderer implementation.
|
||||
// The full implementation (marked + Shiki highlighting + KaTeX + morphdom
|
||||
@@ -41,17 +41,29 @@ const MobileMarkdownFallback = (props: { content?: unknown; className?: unknown;
|
||||
);
|
||||
};
|
||||
|
||||
export const MarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownRendererLazy>> = (props) => (
|
||||
<React.Suspense fallback={<MobileMarkdownFallback {...props} />}>
|
||||
<MarkdownRendererLazy {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
export const MarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownRendererLazy>> = (props) => {
|
||||
const loaded = getLoadedMarkdownRendererModule();
|
||||
if (loaded) return <loaded.MarkdownRenderer {...props} />;
|
||||
return (
|
||||
<React.Suspense fallback={<MobileMarkdownFallback {...props} />}>
|
||||
<MarkdownRendererLazy {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export const SimpleMarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy>> = (props) => (
|
||||
<React.Suspense fallback={<MobileMarkdownFallback {...props} />}>
|
||||
<SimpleMarkdownRendererLazy {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
type SimpleMarkdownRendererProps = React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy> & {
|
||||
fallbackContent?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const SimpleMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({ fallbackContent, ...props }) => {
|
||||
const loaded = getLoadedMarkdownRendererModule();
|
||||
if (loaded) return <loaded.SimpleMarkdownRenderer {...props} />;
|
||||
return (
|
||||
<React.Suspense fallback={fallbackContent ?? <MobileMarkdownFallback {...props} />}>
|
||||
<SimpleMarkdownRendererLazy {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export const MarkdownImageGallery: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownImageGalleryLazy>> = (props) => (
|
||||
<React.Suspense fallback={null}>
|
||||
|
||||
@@ -193,6 +193,8 @@ const fakeReact = {
|
||||
return hookStates[index] as { current: T };
|
||||
},
|
||||
memo: <T>(component: T): T => component,
|
||||
createContext: <T>(defaultValue: T) => ({ Provider: 'provider', defaultValue }),
|
||||
useContext: <T>(context: { defaultValue: T }): T => context.defaultValue,
|
||||
};
|
||||
|
||||
const fakeJsx = (_type: string, props: FakeJsxProps | null, ...children: FakeElement[]): FakeElement => {
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import { fileReferenceExists } from './fileReferenceStat';
|
||||
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
||||
import { detachedMarkdownDomCache, type DetachedMarkdownDomKey } from './markdown/detachedMarkdownDomCache';
|
||||
import { TimelineRevealGateContext } from './timelineRevealGate';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
const useCurrentMermaidTheme = () => {
|
||||
@@ -692,6 +693,30 @@ const useMermaidInlineInteractions = ({
|
||||
const MERMAID_RENDER_CACHE = new Map<string, MermaidRender>();
|
||||
const MERMAID_RENDER_CACHE_MAX = 100;
|
||||
const MARKDOWN_DECORATION_ID_ATTR = 'data-md-decoration-id';
|
||||
|
||||
// True when the container already holds exactly these settled blocks with the
|
||||
// current decoration. The first paint of a remounted message is served from
|
||||
// the block cache; when that paint is already final, the async render would
|
||||
// only parse, highlight, sanitize, and morph the same HTML into place again.
|
||||
const domMatchesRenderedBlocks = (
|
||||
target: HTMLElement,
|
||||
blocks: ReadonlyArray<{ id: string }>,
|
||||
decorationId: string,
|
||||
): boolean => {
|
||||
const children = target.children;
|
||||
if (children.length !== blocks.length) return false;
|
||||
for (let index = 0; index < blocks.length; index += 1) {
|
||||
const child = children[index];
|
||||
if (
|
||||
!child
|
||||
|| child.getAttribute('data-md-id') !== blocks[index]?.id
|
||||
|| child.getAttribute(MARKDOWN_DECORATION_ID_ATTR) !== decorationId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const MARKDOWN_DECORATION_IDS = new WeakMap<DecorateContext, string>();
|
||||
let nextMarkdownDecorationId = 0;
|
||||
const MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS = 200_000;
|
||||
@@ -804,6 +829,16 @@ const useMorphdomMarkdown = ({
|
||||
|
||||
const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | null>(null);
|
||||
const renderRevisionRef = React.useRef(0);
|
||||
// A provisional first paint (blocks not in the settled cache) holds the
|
||||
// timeline reveal until the async render lands, so the session opens with
|
||||
// final code highlighting instead of a visible restyle.
|
||||
const revealGate = React.useContext(TimelineRevealGateContext);
|
||||
const releaseRevealHoldRef = React.useRef<(() => void) | null>(null);
|
||||
const releaseRevealHold = React.useCallback(() => {
|
||||
releaseRevealHoldRef.current?.();
|
||||
releaseRevealHoldRef.current = null;
|
||||
}, []);
|
||||
React.useEffect(() => releaseRevealHold, [releaseRevealHold]);
|
||||
// Only DOM that was actually restored or completed by the async pipeline is
|
||||
// eligible for capture. A fallback from an earlier content revision is not.
|
||||
const mountedDomRef = React.useRef<{
|
||||
@@ -909,6 +944,9 @@ const useMorphdomMarkdown = ({
|
||||
}
|
||||
if (hasMermaidBlock) refreshMermaidViewers();
|
||||
} else {
|
||||
if (!streaming && !releaseRevealHoldRef.current) {
|
||||
releaseRevealHoldRef.current = revealGate?.hold() ?? null;
|
||||
}
|
||||
const block = document.createElement('div');
|
||||
block.setAttribute('data-md-block', '');
|
||||
block.style.display = 'contents';
|
||||
@@ -939,6 +977,18 @@ const useMorphdomMarkdown = ({
|
||||
const renderRevision = renderRevisionRef.current;
|
||||
const decorationId = getMarkdownDecorationId(ctx);
|
||||
|
||||
if (!streaming) {
|
||||
const cachedBlocks = getCachedMarkdownBlocks(text, imageMode);
|
||||
if (cachedBlocks && domMatchesRenderedBlocks(target, cachedBlocks, decorationId)) {
|
||||
mountedDomRef.current = domCacheKey
|
||||
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
|
||||
: null;
|
||||
streamPerfCount('ui.markdown_renderer.settled_paint.reused');
|
||||
releaseRevealHold();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
|
||||
if (!active || renderRevisionRef.current !== renderRevision) return;
|
||||
const existing = Array.from(target.children) as HTMLElement[];
|
||||
@@ -1028,12 +1078,13 @@ const useMorphdomMarkdown = ({
|
||||
mountedDomRef.current = domCacheKey
|
||||
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
|
||||
: null;
|
||||
releaseRevealHold();
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, streaming, text]);
|
||||
}, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, releaseRevealHold, streaming, text]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
|
||||
@@ -1534,6 +1534,54 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return true;
|
||||
}, [allEntries.length]);
|
||||
|
||||
// A navigation scroll lands on estimates: an unmounted target teleports
|
||||
// to its estimated offset, and even a mounted one drifts when neighbours
|
||||
// finish measuring a frame later. This settle loop re-aligns the target to
|
||||
// the requested viewport position until the layout stops moving, and backs
|
||||
// off the moment the user touches the scroll.
|
||||
const settleNavigationTarget = React.useCallback((
|
||||
findElement: () => HTMLElement | null,
|
||||
desiredOffsetTop: number,
|
||||
) => {
|
||||
const container = resolveScrollContainer();
|
||||
if (!container || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
let frames = 0;
|
||||
let stable = 0;
|
||||
let cancelled = false;
|
||||
const cancelOnUserInput = () => {
|
||||
cancelled = true;
|
||||
container.removeEventListener('touchstart', cancelOnUserInput);
|
||||
container.removeEventListener('wheel', cancelOnUserInput);
|
||||
};
|
||||
container.addEventListener('touchstart', cancelOnUserInput, { passive: true });
|
||||
container.addEventListener('wheel', cancelOnUserInput, { passive: true });
|
||||
const step = () => {
|
||||
if (cancelled) return;
|
||||
const element = findElement();
|
||||
if (element) {
|
||||
const delta = element.getBoundingClientRect().top
|
||||
- container.getBoundingClientRect().top
|
||||
- desiredOffsetTop;
|
||||
if (Math.abs(delta) > 0.5) {
|
||||
container.scrollTop += delta;
|
||||
stable = 0;
|
||||
} else {
|
||||
stable += 1;
|
||||
}
|
||||
}
|
||||
frames += 1;
|
||||
if (stable >= ANCHOR_HOLD_STABLE_FRAMES || frames >= ANCHOR_HOLD_MAX_FRAMES) {
|
||||
container.removeEventListener('touchstart', cancelOnUserInput);
|
||||
container.removeEventListener('wheel', cancelOnUserInput);
|
||||
return;
|
||||
}
|
||||
window.requestAnimationFrame(step);
|
||||
};
|
||||
window.requestAnimationFrame(step);
|
||||
}, [resolveScrollContainer]);
|
||||
|
||||
const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => {
|
||||
const container = resolveScrollContainer();
|
||||
if (!container) {
|
||||
@@ -1569,14 +1617,19 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
if (!container) {
|
||||
return false;
|
||||
}
|
||||
const turnElement = container.querySelector<HTMLElement>(`[data-turn-id="${turnId}"]`);
|
||||
const findTurnElement = () => container.querySelector<HTMLElement>(`[data-turn-id="${turnId}"]`);
|
||||
const turnElement = findTurnElement();
|
||||
if (turnElement) {
|
||||
turnElement.scrollIntoView({ behavior, block: 'start' });
|
||||
if (behavior !== 'smooth') settleNavigationTarget(findTurnElement, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
return scrollHistoryIndexIntoView(index);
|
||||
if (!scrollHistoryIndexIntoView(index)) {
|
||||
return false;
|
||||
}
|
||||
if (behavior !== 'smooth') settleNavigationTarget(findTurnElement, 0);
|
||||
return true;
|
||||
},
|
||||
|
||||
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => {
|
||||
@@ -1586,8 +1639,12 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return false;
|
||||
}
|
||||
|
||||
return scrollMessageElementIntoView(messageId, behavior)
|
||||
const didScroll = scrollMessageElementIntoView(messageId, behavior)
|
||||
|| scrollHistoryIndexIntoView(index);
|
||||
if (didScroll && behavior !== 'smooth') {
|
||||
settleNavigationTarget(() => findMessageElement(messageId), 50);
|
||||
}
|
||||
return didScroll;
|
||||
},
|
||||
|
||||
holdViewportAnchor: (anchor) => {
|
||||
@@ -1730,7 +1787,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return () => {
|
||||
objectRef.current = null;
|
||||
};
|
||||
}, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, turnIndexMap, ref]);
|
||||
}, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, settleNavigationTarget, turnIndexMap, ref]);
|
||||
|
||||
const anchoredEndSpace = React.useMemo<TimelineAnchoredEndSpace | undefined>(() => {
|
||||
const resolved = resolveChatListAnchoredEndSpace(
|
||||
|
||||
@@ -324,7 +324,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
||||
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||
const currentVariant = useConfigStore((state) => state.currentVariant);
|
||||
const effectiveCurrentVariant = useConfigStore((state) => state.currentVariant);
|
||||
const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection);
|
||||
const currentVariant = currentVariantSelection.override ?? undefined;
|
||||
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||
const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant);
|
||||
const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent);
|
||||
@@ -332,6 +334,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
||||
const setModel = useConfigStore((state) => state.setModel);
|
||||
const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant);
|
||||
const setCurrentVariantOverride = useConfigStore((state) => state.setCurrentVariantOverride);
|
||||
const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants);
|
||||
const setAgent = useConfigStore((state) => state.setAgent);
|
||||
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
|
||||
@@ -630,7 +633,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
];
|
||||
|
||||
const prevAgentNameRef = React.useRef<string | undefined>(undefined);
|
||||
const explicitAgentSwitchRef = React.useRef<string | null>(null);
|
||||
const latestLoadedUserChoiceRestoreRef = React.useRef<string | null>(null);
|
||||
|
||||
const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined;
|
||||
@@ -693,6 +695,30 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return variants ? Object.keys(variants) : [];
|
||||
}, [providers]);
|
||||
|
||||
const resolveInheritedVariantForModel = React.useCallback((providerId: string, modelId: string, agentName?: string | null) => {
|
||||
const variantOptions = getModelVariantOptions(providerId, modelId);
|
||||
if (variantOptions.length === 0) return undefined;
|
||||
|
||||
let currentInherited: string | undefined;
|
||||
if (currentProviderId === providerId && currentModelId === modelId) {
|
||||
currentInherited = currentVariantSelection.inherited
|
||||
?? (currentVariantSelection.override === null || currentVariantSelection.override === undefined
|
||||
? effectiveCurrentVariant
|
||||
: undefined);
|
||||
}
|
||||
|
||||
const effectiveAgentName = agentName ?? uiAgentName ?? currentAgentName;
|
||||
const agent = effectiveAgentName ? agents.find((candidate) => candidate.name === effectiveAgentName) : undefined;
|
||||
const agentVariant = (
|
||||
agent?.model?.providerID === providerId
|
||||
&& agent.model.modelID === modelId
|
||||
) ? agent.variant : undefined;
|
||||
const candidates = currentSessionId
|
||||
? [agentVariant, settingsDefaultVariant, currentInherited]
|
||||
: [currentInherited, agentVariant, settingsDefaultVariant];
|
||||
return candidates.find((candidate) => candidate !== undefined && variantOptions.includes(candidate));
|
||||
}, [agents, currentAgentName, currentModelId, currentProviderId, currentSessionId, currentVariantSelection, effectiveCurrentVariant, getModelVariantOptions, settingsDefaultVariant, uiAgentName]);
|
||||
|
||||
const resolveModelVariantSelection = React.useCallback((providerId: string, modelId: string) => {
|
||||
const variantOptions = getModelVariantOptions(providerId, modelId);
|
||||
if (variantOptions.length === 0) {
|
||||
@@ -711,10 +737,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return currentVariant;
|
||||
}
|
||||
|
||||
if (!currentSessionId && settingsDefaultVariant && variantOptions.includes(settingsDefaultVariant)) {
|
||||
return settingsDefaultVariant;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [
|
||||
currentAgentName,
|
||||
@@ -724,7 +746,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
currentVariant,
|
||||
getAgentModelVariantForSession,
|
||||
getModelVariantOptions,
|
||||
settingsDefaultVariant,
|
||||
uiAgentName,
|
||||
]);
|
||||
|
||||
@@ -748,7 +769,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
}
|
||||
|
||||
manualVariantSelectionRef.current = true;
|
||||
setCurrentVariant(variant);
|
||||
setCurrentVariantOverride(
|
||||
variant ?? null,
|
||||
resolveInheritedVariantForModel(providerId, modelId, agentNameOverride),
|
||||
);
|
||||
addRecentEffort(providerId, modelId, variant);
|
||||
|
||||
const effectiveAgentName = agentNameOverride ?? resolveLiveAgentName();
|
||||
@@ -759,9 +783,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
addRecentEffort,
|
||||
currentSessionId,
|
||||
getModelVariantOptions,
|
||||
resolveInheritedVariantForModel,
|
||||
resolveLiveAgentName,
|
||||
saveAgentModelVariantForSession,
|
||||
setCurrentVariant,
|
||||
setCurrentVariantOverride,
|
||||
]);
|
||||
|
||||
const applyModelSelectionWithVariant = React.useCallback((providerId: string, modelId: string, variant: string | undefined, agentNameOverride?: string | null) => {
|
||||
@@ -1024,9 +1050,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
prevAgentNameRef.current = currentAgentName;
|
||||
|
||||
if (currentAgentName && currentSessionId) {
|
||||
const shouldPreferAgentModel = explicitAgentSwitchRef.current === currentAgentName;
|
||||
explicitAgentSwitchRef.current = null;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, 50);
|
||||
abortController.signal.addEventListener('abort', () => {
|
||||
@@ -1039,33 +1062,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedAgent = shouldPreferAgentModel
|
||||
? agents.find((agent) => agent.name === currentAgentName)
|
||||
: undefined;
|
||||
if (selectedAgent?.model?.providerID && selectedAgent.model.modelID) {
|
||||
const result = tryApplyModelSelection(
|
||||
selectedAgent.model.providerID,
|
||||
selectedAgent.model.modelID,
|
||||
currentAgentName,
|
||||
);
|
||||
if (result === 'applied' || result === 'provider-missing') {
|
||||
if (result === 'applied') {
|
||||
saveSessionModelSelection(
|
||||
currentSessionId,
|
||||
selectedAgent.model.providerID,
|
||||
selectedAgent.model.modelID,
|
||||
);
|
||||
saveAgentModelForSession(
|
||||
currentSessionId,
|
||||
currentAgentName,
|
||||
selectedAgent.model.providerID,
|
||||
selectedAgent.model.modelID,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const persistedChoice = getAgentModelForSession(currentSessionId, currentAgentName);
|
||||
|
||||
if (persistedChoice) {
|
||||
@@ -1091,12 +1087,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
abortController.abort();
|
||||
};
|
||||
}, [
|
||||
agents,
|
||||
currentAgentName,
|
||||
currentSessionId,
|
||||
getAgentModelForSession,
|
||||
saveAgentModelForSession,
|
||||
saveSessionModelSelection,
|
||||
tryApplyModelSelection,
|
||||
contextHydrated,
|
||||
]);
|
||||
@@ -1121,18 +1114,21 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
}
|
||||
|
||||
if (currentVariant && !availableVariants.includes(currentVariant)) {
|
||||
setCurrentVariant(undefined);
|
||||
setCurrentVariantOverride(
|
||||
null,
|
||||
resolveInheritedVariantForModel(currentProviderId, currentModelId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Draft state (no session yet): seed from settings default, but don't override
|
||||
// user selection while drafting.
|
||||
if (!currentSessionId) {
|
||||
if (!currentVariant && !manualVariantSelectionRef.current) {
|
||||
if (currentVariantSelection.override === undefined && !manualVariantSelectionRef.current) {
|
||||
const desired = settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant)
|
||||
? settingsDefaultVariant
|
||||
: undefined;
|
||||
setCurrentVariant(desired);
|
||||
setCurrentVariantOverride(desired ?? null, desired);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1144,13 +1140,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
currentModelId,
|
||||
);
|
||||
|
||||
const resolvedSaved = savedVariant && availableVariants.includes(savedVariant)
|
||||
? savedVariant
|
||||
: settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant)
|
||||
? settingsDefaultVariant
|
||||
: undefined;
|
||||
|
||||
setCurrentVariant(resolvedSaved);
|
||||
const inheritedVariant = resolveInheritedVariantForModel(currentProviderId, currentModelId);
|
||||
if (savedVariant && availableVariants.includes(savedVariant)) {
|
||||
setCurrentVariantOverride(savedVariant, inheritedVariant);
|
||||
} else if (currentVariantSelection.override === null) {
|
||||
setCurrentVariantOverride(null, inheritedVariant);
|
||||
} else {
|
||||
setCurrentVariant(inheritedVariant);
|
||||
}
|
||||
manualVariantSelectionRef.current = false;
|
||||
}, [
|
||||
availableVariants,
|
||||
@@ -1160,8 +1157,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
currentProviderId,
|
||||
currentModelId,
|
||||
currentVariant,
|
||||
currentVariantSelection.override,
|
||||
effectiveCurrentVariant,
|
||||
getAgentModelVariantForSession,
|
||||
resolveInheritedVariantForModel,
|
||||
setCurrentVariant,
|
||||
setCurrentVariantOverride,
|
||||
settingsDefaultVariant,
|
||||
]);
|
||||
|
||||
@@ -1177,7 +1178,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
|
||||
const handleAgentChange = React.useCallback((agentName: string, options?: { closeModelSelector?: boolean }) => {
|
||||
try {
|
||||
explicitAgentSwitchRef.current = agentName;
|
||||
setAgent(agentName);
|
||||
addRecentAgent(agentName);
|
||||
if (options?.closeModelSelector ?? true) {
|
||||
@@ -2248,7 +2248,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
: 'Default';
|
||||
|
||||
return (
|
||||
<span className={cn('typography-micro whitespace-nowrap', wasAdjusted ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
<span className={cn(
|
||||
'typography-micro whitespace-nowrap',
|
||||
isHighlighted
|
||||
? (wasAdjusted ? 'text-interactive-selection-foreground' : 'text-interactive-selection-foreground/70')
|
||||
: (wasAdjusted ? 'text-foreground' : 'text-muted-foreground'),
|
||||
)}>
|
||||
Thinking: {displayLabel}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,10 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { DiffPreview, WritePreview } from './DiffPreview';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getVisiblePermissionPatterns } from './permissionCardPatterns';
|
||||
import { formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
|
||||
// Newest pending card owns the keyboard; older cards wait their turn.
|
||||
const activePermissionCardIds: string[] = [];
|
||||
|
||||
const PERMISSION_BASH_CUSTOM_STYLE: React.CSSProperties = {
|
||||
margin: 0,
|
||||
@@ -126,6 +130,33 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleResponseRef = React.useRef(handleResponse);
|
||||
handleResponseRef.current = handleResponse;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasResponded) return;
|
||||
activePermissionCardIds.push(permission.id);
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (activePermissionCardIds.at(-1) !== permission.id) return;
|
||||
if (!event.altKey || event.metaKey || event.ctrlKey) return;
|
||||
const response = event.key === 'Enter'
|
||||
? (event.shiftKey ? 'always' as const : 'once' as const)
|
||||
: event.key === 'Backspace' && !event.shiftKey
|
||||
? 'reject' as const
|
||||
: null;
|
||||
if (!response) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void handleResponseRef.current(response);
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
const index = activePermissionCardIds.lastIndexOf(permission.id);
|
||||
if (index !== -1) activePermissionCardIds.splice(index, 1);
|
||||
};
|
||||
}, [hasResponded, permission.id]);
|
||||
|
||||
if (hasResponded) {
|
||||
return null;
|
||||
}
|
||||
@@ -380,6 +411,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
>
|
||||
<Icon name="check" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
|
||||
Allow Once
|
||||
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+enter')}</kbd>
|
||||
</button>
|
||||
|
||||
{permission.always.length > 0 ? (
|
||||
@@ -436,6 +468,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
>
|
||||
<Icon name="time" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
|
||||
Always Allow
|
||||
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+shift+enter')}</kbd>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -459,6 +492,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
>
|
||||
<Icon name="close" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
|
||||
Deny
|
||||
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+backspace')}</kbd>
|
||||
</button>
|
||||
|
||||
{isResponding && (
|
||||
|
||||
@@ -15,6 +15,7 @@ import * as sessionActions from '@/sync/session-actions';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { serializeQuestionAsJson, serializeQuestionAsMarkdown } from './questionSerializers';
|
||||
import { QUESTION_CUSTOM_TEXTAREA_MIN_HEIGHT, getQuestionCustomTextareaHeight } from './questionTextareaSizing';
|
||||
import { QuestionMarkdown } from './QuestionMarkdown';
|
||||
|
||||
interface QuestionCardProps {
|
||||
question: QuestionRequest;
|
||||
@@ -423,7 +424,11 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
|
||||
</div>
|
||||
) : activeQuestion ? (
|
||||
<>
|
||||
<div className="typography-meta font-medium text-foreground mb-1.5">{activeQuestion.question}</div>
|
||||
<QuestionMarkdown
|
||||
content={activeQuestion.question}
|
||||
size="meta"
|
||||
className="font-medium text-foreground mb-1.5"
|
||||
/>
|
||||
|
||||
{isMultiple ? (
|
||||
<div className="typography-micro text-muted-foreground mb-1.5">{t('chat.questionCard.selectMultiple')}</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { QuestionMarkdown } from './QuestionMarkdown';
|
||||
|
||||
// The markdown renderer is lazy, so a synchronous server render always emits the
|
||||
// Suspense fallback QuestionMarkdown supplies. That fallback is the surface that
|
||||
// has to keep the exact question text and the question typography classes.
|
||||
describe('QuestionMarkdown', () => {
|
||||
test('renders the question content verbatim', () => {
|
||||
const content = 'Choose **one** from `mode`: [details](https://example.com)';
|
||||
|
||||
const html = renderToStaticMarkup(<QuestionMarkdown content={content} size="meta" />);
|
||||
|
||||
expect(html).toBe(
|
||||
`<div class="question-markdown typography-meta whitespace-pre-wrap">${content}</div>`,
|
||||
);
|
||||
});
|
||||
|
||||
test('applies meta typography and caller classes', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QuestionMarkdown content="Meta" size="meta" className="font-medium text-foreground" />,
|
||||
);
|
||||
|
||||
expect(html).toContain('class="question-markdown typography-meta font-medium text-foreground whitespace-pre-wrap"');
|
||||
});
|
||||
|
||||
test('applies micro typography and caller classes', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QuestionMarkdown content="Micro" size="micro" className="text-muted-foreground" />,
|
||||
);
|
||||
|
||||
expect(html).toContain('class="question-markdown typography-micro text-muted-foreground whitespace-pre-wrap"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SimpleMarkdownRenderer } from './MarkdownRenderer';
|
||||
|
||||
interface QuestionMarkdownProps {
|
||||
content: string;
|
||||
size: 'meta' | 'micro';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function QuestionMarkdown({ content, size, className }: QuestionMarkdownProps) {
|
||||
const classes = cn('question-markdown', size === 'meta' ? 'typography-meta' : 'typography-micro', className);
|
||||
|
||||
return (
|
||||
<SimpleMarkdownRenderer
|
||||
content={content}
|
||||
variant="tool"
|
||||
className={classes}
|
||||
fallbackContent={<div className={cn(classes, 'whitespace-pre-wrap')}>{content}</div>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useLatestSessionError } from '@/sync/notification-store';
|
||||
import { useDirectoryStore, useSessionStatus } from '@/sync/sync-context';
|
||||
|
||||
interface SessionErrorNoticeProps {
|
||||
sessionId: string;
|
||||
directory?: string;
|
||||
}
|
||||
|
||||
// How long a user message may sit unanswered on an idle session before the
|
||||
// notice calls it a reply that never began.
|
||||
const UNANSWERED_AFTER_MS = 5_000;
|
||||
|
||||
type LastMessageState = {
|
||||
role: string;
|
||||
timestamp: number;
|
||||
hasError: boolean;
|
||||
} | null;
|
||||
|
||||
// The last message of a session, with whether it already carries an error of
|
||||
// its own: an assistant message that OpenCode marked failed renders its error
|
||||
// inline, so the session-level notice must not repeat it.
|
||||
const useLastMessageState = (sessionId: string, directory?: string): LastMessageState => {
|
||||
const store = useDirectoryStore(directory);
|
||||
const cacheRef = React.useRef<LastMessageState>(null);
|
||||
const getSnapshot = React.useCallback((): LastMessageState => {
|
||||
if (!sessionId) return null;
|
||||
const messages = store.getState().message[sessionId];
|
||||
const last = messages && messages.length > 0 ? messages[messages.length - 1] : null;
|
||||
// SAFETY: store messages are SDK `Message` records; `error` is the optional
|
||||
// assistant-message error the SDK types carry, read here only for presence.
|
||||
const info = last as { role?: string; time?: { completed?: number; created?: number }; error?: unknown } | null;
|
||||
if (!info) {
|
||||
cacheRef.current = null;
|
||||
return null;
|
||||
}
|
||||
const next: LastMessageState = {
|
||||
role: typeof info.role === 'string' ? info.role : '',
|
||||
timestamp: info.time?.completed ?? info.time?.created ?? 0,
|
||||
hasError: Boolean(info.error),
|
||||
};
|
||||
const cached = cacheRef.current;
|
||||
if (cached && cached.role === next.role && cached.timestamp === next.timestamp && cached.hasError === next.hasError) {
|
||||
return cached;
|
||||
}
|
||||
cacheRef.current = next;
|
||||
return next;
|
||||
}, [sessionId, store]);
|
||||
const subscribe = React.useCallback((notify: () => void) => {
|
||||
if (!sessionId) return () => undefined;
|
||||
return store.subscribe(notify);
|
||||
}, [sessionId, store]);
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows what OpenCode reported when it stopped a turn without producing a
|
||||
* reply. Rendered under the last message, only while that turn is the latest
|
||||
* one: sending again moves the last message past the error and hides it.
|
||||
*/
|
||||
export const SessionErrorNotice: React.FC<SessionErrorNoticeProps> = ({ sessionId, directory }) => {
|
||||
const { t } = useI18n();
|
||||
const latestError = useLatestSessionError(sessionId);
|
||||
const status = useSessionStatus(sessionId, directory);
|
||||
const lastMessage = useLastMessageState(sessionId, directory);
|
||||
|
||||
const isIdle = !status || status.type === 'idle';
|
||||
const reportedError = latestError && isIdle
|
||||
&& (!lastMessage || latestError.time >= lastMessage.timestamp)
|
||||
&& !(lastMessage?.role === 'assistant' && lastMessage.hasError)
|
||||
? latestError
|
||||
: null;
|
||||
// A user message that the session is idle on, with nothing after it for a
|
||||
// while, is a reply that never began: the send was accepted but OpenCode
|
||||
// produced neither a message nor an error for it.
|
||||
const unansweredSince = !reportedError && isIdle && lastMessage?.role === 'user' ? lastMessage.timestamp : null;
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
React.useEffect(() => {
|
||||
if (unansweredSince === null) return undefined;
|
||||
const remaining = UNANSWERED_AFTER_MS - (Date.now() - unansweredSince);
|
||||
if (remaining <= 0) return undefined;
|
||||
const timer = window.setTimeout(() => setNow(Date.now()), remaining + 50);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [unansweredSince]);
|
||||
const unanswered = unansweredSince !== null && Math.max(now, Date.now()) - unansweredSince >= UNANSWERED_AFTER_MS;
|
||||
|
||||
if (!reportedError && !unanswered) return null;
|
||||
|
||||
const detail = reportedError
|
||||
? (reportedError.error?.message ?? t('chat.sessionError.noDetails'))
|
||||
: t('chat.sessionError.noDetails');
|
||||
const name = reportedError?.error?.name;
|
||||
|
||||
return (
|
||||
<div className="chat-message-column">
|
||||
<div
|
||||
role="status"
|
||||
className="mt-3 max-w-full break-words rounded-2xl border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3 text-base leading-relaxed"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-error)]" />
|
||||
<div className="min-w-0 flex-1 break-words">
|
||||
<div className="font-medium text-foreground">{reportedError ? t('chat.sessionError.title') : t('chat.sessionError.noReply')}</div>
|
||||
<div className="mt-1 text-foreground/80">{name ? `${name}: ${detail}` : detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useSessionAssistState } from '@/hooks/useSessionAssist';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { TimelineRevealGateContext } from '@/components/chat/timelineRevealGate';
|
||||
|
||||
interface SessionRecapNoteProps {
|
||||
sessionId: string;
|
||||
@@ -12,8 +13,17 @@ interface SessionRecapNoteProps {
|
||||
// the last message (above the reserved bottom gap). Appears only after the
|
||||
// 1-minute quiet window, so the layout shift happens off-screen in practice.
|
||||
export const SessionRecapNote: React.FC<SessionRecapNoteProps> = React.memo(({ sessionId, directory, isMobile }) => {
|
||||
const { visibleRecap } = useSessionAssistState(sessionId, directory);
|
||||
const { visibleRecap, sessionKnown } = useSessionAssistState(sessionId, directory);
|
||||
const { t } = useI18n();
|
||||
// The recap is part of the opened session's finished picture: until the
|
||||
// session record is in memory it cannot be decided, and appearing a commit
|
||||
// later would grow the footer under a viewport already pinned to the end.
|
||||
const revealGate = React.useContext(TimelineRevealGateContext);
|
||||
React.useLayoutEffect(() => {
|
||||
if (sessionKnown) return undefined;
|
||||
const release = revealGate?.hold();
|
||||
return release ?? undefined;
|
||||
}, [revealGate, sessionKnown]);
|
||||
|
||||
if (!visibleRecap) {
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { cn, fuzzyMatch } from '@/lib/utils';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { selectSkillsForDirectory, useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||
@@ -38,13 +39,16 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
||||
const keyboardNavigationRef = React.useRef(false);
|
||||
const [filteredSkills, setFilteredSkills] = React.useState<SkillInfo[]>([]);
|
||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||
const skills = useSkillsStore((s) => s.skills);
|
||||
// Skills of the directory the composer sends to (session directory, or the
|
||||
// Chats root for a chat draft), not of the project the app was on last.
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const skills = useSkillsStore((s) => selectSkillsForDirectory(s, effectiveDirectory));
|
||||
const loadSkills = useSkillsStore((s) => s.loadSkills);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Always trigger loadSkills when autocomplete opens to ensure project context is fresh
|
||||
void loadSkills();
|
||||
}, [loadSkills]);
|
||||
// Always trigger loadSkills when autocomplete opens to ensure the directory's skills are fresh
|
||||
void loadSkills(effectiveDirectory);
|
||||
}, [effectiveDirectory, loadSkills]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const normalizedQuery = searchQuery.trim();
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import React from "react";
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
// The floating assistant-status chip that hovers above the composer while the
|
||||
// agent works ("Claude is working…", abort notice). ONLY that. The composer's
|
||||
// agent works ("Claude is working…"). ONLY that. The composer's
|
||||
// own bar — pending changes, todos dropdown — is ComposerStatusBar: they used
|
||||
// to share this component, and every restyle of this chip (glass, placement)
|
||||
// silently dragged the composer bar and its dropdown along with it.
|
||||
@@ -17,10 +15,8 @@ interface StatusRowProps {
|
||||
statusText?: string | null;
|
||||
isGenericStatus?: boolean;
|
||||
isWaitingForPermission?: boolean;
|
||||
wasAborted?: boolean;
|
||||
abortActive?: boolean;
|
||||
retryInfo?: { attempt?: number; next?: number } | null;
|
||||
showAbortStatus?: boolean;
|
||||
agentName?: string;
|
||||
modelName?: string | null;
|
||||
providerId?: string | null;
|
||||
@@ -31,19 +27,16 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
statusText = null,
|
||||
isGenericStatus,
|
||||
isWaitingForPermission,
|
||||
wasAborted,
|
||||
abortActive,
|
||||
retryInfo,
|
||||
showAbortStatus,
|
||||
agentName,
|
||||
modelName,
|
||||
providerId,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
|
||||
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
|
||||
const hasContent = isWorking || Boolean(wasAborted) || Boolean(showAbortStatus);
|
||||
const shouldRenderPlaceholder = !abortActive;
|
||||
const hasContent = isWorking;
|
||||
|
||||
if (!hasContent) {
|
||||
return null;
|
||||
@@ -63,14 +56,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
a shrink-to-fit wrapper around it always collapsed to zero. */}
|
||||
<div className="oc-glass-popover inline-flex w-max max-w-full items-center gap-2 h-8 whitespace-nowrap rounded-full [corner-shape:round] px-3">
|
||||
<div className="flex items-center min-w-0 gap-2 overflow-x-hidden">
|
||||
{showAbortStatus ? (
|
||||
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
|
||||
<span className="flex items-center gap-1.5 typography-ui-label">
|
||||
<Icon name="close-circle" aria-hidden="true"/>
|
||||
{t('chat.statusRow.aborted')}
|
||||
</span>
|
||||
</div>
|
||||
) : shouldRenderPlaceholder ? (
|
||||
{shouldRenderPlaceholder ? (
|
||||
<WorkingPlaceholder
|
||||
key={currentSessionId ?? "no-session"}
|
||||
isWorking={isWorking}
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
|
||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
|
||||
import { StatusRow } from './StatusRow';
|
||||
|
||||
@@ -12,15 +11,6 @@ import { StatusRow } from './StatusRow';
|
||||
* labels while still limiting subscriptions to the active assistant message.
|
||||
*/
|
||||
export const StatusRowContainer: React.FC = React.memo(() => {
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const abortRecord = useSessionUIStore(
|
||||
React.useCallback((state) => {
|
||||
if (!currentSessionId) {
|
||||
return null;
|
||||
}
|
||||
return state.sessionAbortFlags?.get(currentSessionId) ?? null;
|
||||
}, [currentSessionId]),
|
||||
);
|
||||
const { activeModel, working } = useAssistantStatus();
|
||||
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
@@ -35,16 +25,13 @@ export const StatusRowContainer: React.FC = React.memo(() => {
|
||||
return getProviderModelDisplayName(provider, activeModel.modelId) || null;
|
||||
}, [activeModel, providers]);
|
||||
|
||||
const wasAborted = Boolean(abortRecord && !abortRecord.acknowledged);
|
||||
|
||||
return (
|
||||
<StatusRow
|
||||
isWorking={working.isWorking}
|
||||
statusText={working.statusText}
|
||||
isGenericStatus={working.isGenericStatus}
|
||||
isWaitingForPermission={working.isWaitingForPermission}
|
||||
wasAborted={wasAborted || working.wasAborted}
|
||||
abortActive={wasAborted || working.abortActive}
|
||||
abortActive={working.abortActive}
|
||||
retryInfo={working.retryInfo}
|
||||
agentName={currentAgentName}
|
||||
modelName={modelDisplayName}
|
||||
|
||||
@@ -301,7 +301,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
filteredMessages.map(({ message }, index) => {
|
||||
const preview = getMessagePreview(message.parts);
|
||||
const preview = getMessagePreview(message.parts, undefined, t);
|
||||
const timestamp = message.info.time.created;
|
||||
const dateGroup = formatDateGroup(timestamp);
|
||||
const previous = filteredMessages[index - 1];
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildAttachmentCitationText,
|
||||
findAttachmentCitationRanges,
|
||||
isGenericImageFilename,
|
||||
nextPastedContextFilename,
|
||||
} from '../attachmentCitations';
|
||||
|
||||
describe('attachment citations', () => {
|
||||
@@ -53,4 +54,10 @@ describe('attachment citations', () => {
|
||||
['desktop.jpg'],
|
||||
)).toEqual([{ start: 8, end: 21 }]);
|
||||
});
|
||||
|
||||
test('assigns sequential pasted-context filenames', () => {
|
||||
expect(nextPastedContextFilename([])).toBe('pasted-context-1.txt');
|
||||
expect(nextPastedContextFilename(['pasted-context-1.txt', 'notes.md'])).toBe('pasted-context-2.txt');
|
||||
expect(nextPastedContextFilename(['PASTED-CONTEXT-2.TXT'])).toBe('pasted-context-1.txt');
|
||||
});
|
||||
});
|
||||
|
||||
+34
-5
@@ -138,14 +138,27 @@ const buildMaterializedSubagentSession = () => {
|
||||
return { messages, part };
|
||||
};
|
||||
|
||||
const syncContext = (globalThis as unknown as {
|
||||
// SAFETY: sync-context.tsx publishes exactly these two keys on globalThis
|
||||
// (SYNC_CONTEXT_GLOBAL_KEY / SYNC_RUNTIME_CONTEXT_GLOBAL_KEY) so every module
|
||||
// instance shares one context identity; the cast only adds those two optional
|
||||
// keys to the global object type, and the guards below re-check presence.
|
||||
const syncGlobals = globalThis as {
|
||||
__openchamber_sync_context__?: React.Context<unknown>;
|
||||
}).__openchamber_sync_context__;
|
||||
__openchamber_sync_runtime_context__?: React.Context<unknown>;
|
||||
};
|
||||
|
||||
const syncContext = syncGlobals.__openchamber_sync_context__;
|
||||
|
||||
if (!syncContext) {
|
||||
throw new Error('sync context was not published on globalThis by @/sync/sync-context');
|
||||
}
|
||||
|
||||
const syncRuntimeContext = syncGlobals.__openchamber_sync_runtime_context__;
|
||||
|
||||
if (!syncRuntimeContext) {
|
||||
throw new Error('sync runtime context was not published on globalThis by @/sync/sync-context');
|
||||
}
|
||||
|
||||
describe('issue #2903 busy embedded subagent status-line-only', () => {
|
||||
test('cold disabled reads hide a fully materialized 14-message subagent; enabled reads return all 14', async () => {
|
||||
const dom = installMinimalDom();
|
||||
@@ -173,7 +186,16 @@ describe('issue #2903 busy embedded subagent status-line-only', () => {
|
||||
});
|
||||
|
||||
const system = { childStores, messageLoader: {}, sdk: {}, runtimeKey: 'test', directory: DIRECTORY };
|
||||
const Provider = syncContext.Provider as React.Provider<unknown>;
|
||||
// Mirrors SyncProvider's own nesting: system context outer, runtime inner.
|
||||
// Directory-scoped hooks read the runtime context, so the harness must
|
||||
// provide it with a currentDirectory source for the store lookups.
|
||||
const runtime = {
|
||||
childStores,
|
||||
messageLoader: {},
|
||||
sdk: {},
|
||||
runtimeKey: 'test',
|
||||
currentDirectory: { get: () => DIRECTORY, subscribe: () => () => undefined },
|
||||
};
|
||||
let inactiveCount = -1;
|
||||
let activeCount = -1;
|
||||
let enabled = false;
|
||||
@@ -188,15 +210,22 @@ describe('issue #2903 busy embedded subagent status-line-only', () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderHarness = () =>
|
||||
React.createElement(
|
||||
syncContext.Provider,
|
||||
{ value: system },
|
||||
React.createElement(syncRuntimeContext.Provider, { value: runtime }, React.createElement(Harness)),
|
||||
);
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Provider, { value: system }, React.createElement(Harness)));
|
||||
root.render(renderHarness());
|
||||
});
|
||||
expect(inactiveCount).toBe(0);
|
||||
|
||||
enabled = true;
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Provider, { value: system }, React.createElement(Harness)));
|
||||
root.render(renderHarness());
|
||||
});
|
||||
expect(activeCount).toBe(14);
|
||||
} finally {
|
||||
|
||||
@@ -144,6 +144,20 @@ export const assignImageAttachmentFilenames = (
|
||||
});
|
||||
};
|
||||
|
||||
/** Next unused `pasted-context-N.txt` name for a large text paste attachment. */
|
||||
export const nextPastedContextFilename = (existingFilenames: string[]): string => {
|
||||
const used = new Set(existingFilenames.map(normalizeFilenameKey));
|
||||
|
||||
for (let index = 1; index < Number.MAX_SAFE_INTEGER; index += 1) {
|
||||
const candidate = `pasted-context-${index}.txt`;
|
||||
if (!used.has(normalizeFilenameKey(candidate))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return `pasted-context-${Date.now()}.txt`;
|
||||
};
|
||||
|
||||
export const buildAttachmentCitationText = (filenames: string[]): string => (
|
||||
filenames.map((filename) => `[${filename}]`).join(' ')
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { getBtwBoundaryMessageID, getBtwSessionID } from '@/lib/sessionBtwMetada
|
||||
import { useBtwStore } from '@/stores/useBtwStore';
|
||||
|
||||
export type BtwPanelState = {
|
||||
/** The session the composer is in — the one `/btw` would fork. */
|
||||
parentSession: Session | null;
|
||||
/** The active fork for this parent, or null when no panel should exist. */
|
||||
btwSessionId: string | null;
|
||||
btwSession: Session | null;
|
||||
@@ -40,6 +42,7 @@ export function useBtwPanelState(
|
||||
const destroying = Boolean(uiState?.destroying);
|
||||
const btwSessionId = btwSession && !destroying ? linkedBtwSessionId : null;
|
||||
return {
|
||||
parentSession: parentSession ?? null,
|
||||
btwSessionId,
|
||||
btwSession: btwSessionId ? btwSession : null,
|
||||
// SAFETY: the SDK Session type omits the server's `directory` field; this
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useI18n, type I18nKey, type I18nParams } from '@/lib/i18n';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getMessagePreview } from '../lib/messagePreview';
|
||||
@@ -58,12 +58,13 @@ const PANEL_HIDE_DELAY_MS = 160;
|
||||
const buildPromptEntries = (
|
||||
turnIds: string[],
|
||||
previewsByTurnId: Map<string, Part[]>,
|
||||
t: (key: I18nKey, params?: I18nParams) => string,
|
||||
): PromptEntry[] => {
|
||||
return turnIds.map((turnId) => {
|
||||
const parts = previewsByTurnId.get(turnId) ?? [];
|
||||
return {
|
||||
turnId,
|
||||
preview: getMessagePreview(parts, PREVIEW_MAX_CHARS),
|
||||
preview: getMessagePreview(parts, PREVIEW_MAX_CHARS, t),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -128,8 +129,8 @@ export function PromptNavigatorRail({
|
||||
}, []);
|
||||
|
||||
const prompts = React.useMemo(
|
||||
() => buildPromptEntries(turnIds, previewsByTurnId),
|
||||
[previewsByTurnId, turnIds],
|
||||
() => buildPromptEntries(turnIds, previewsByTurnId, t),
|
||||
[previewsByTurnId, t, turnIds],
|
||||
);
|
||||
|
||||
const visibleCount = Math.min(prompts.length, MAX_VISIBLE_TICKS);
|
||||
|
||||
@@ -9,6 +9,20 @@ interface TurnItemProps {
|
||||
renderMessage: (message: ChatMessageEntry) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sticky user header paints the chat background so assistant content scrolling
|
||||
* underneath disappears behind it. The soft edge lives in the header's own background
|
||||
* instead of an overlay below it: the bottom 0.75rem of the header box fades the
|
||||
* background out, and that strip sits over the empty space the user bubble already
|
||||
* reserves below itself. At rest the strip reveals the identical page background
|
||||
* (`--background` is generated from the same `surface.background` token), so it is
|
||||
* invisible and can never wash over the assistant content that follows.
|
||||
*/
|
||||
const STICKY_HEADER_BACKGROUND: React.CSSProperties = {
|
||||
backgroundImage:
|
||||
'linear-gradient(to bottom, var(--surface-background) calc(100% - 0.75rem), transparent)',
|
||||
};
|
||||
|
||||
const TurnItem: React.FC<TurnItemProps> = ({ turn, stickyUserHeader = true, renderMessage }) => {
|
||||
return (
|
||||
<section
|
||||
@@ -18,14 +32,13 @@ const TurnItem: React.FC<TurnItemProps> = ({ turn, stickyUserHeader = true, rend
|
||||
data-scroll-spy-id={turn.turnId}
|
||||
>
|
||||
{stickyUserHeader ? (
|
||||
<div className="sticky top-0 z-20 relative bg-[var(--surface-background)] [overflow-anchor:none]">
|
||||
<div
|
||||
className="sticky top-0 z-20 [overflow-anchor:none]"
|
||||
style={STICKY_HEADER_BACKGROUND}
|
||||
>
|
||||
<div className="relative z-10">
|
||||
{renderMessage(turn.userMessage)}
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-full z-0 h-4 bg-gradient-to-b from-[var(--surface-background)] to-transparent sm:h-8"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
renderMessage(turn.userMessage)
|
||||
|
||||
@@ -28,6 +28,18 @@ existing mobile fixed-position rules unchanged.
|
||||
| `attachments/` | Files: paths, drop payloads |
|
||||
| `ui/` | Presentation |
|
||||
| `text.ts` | How inserted text meets the text already there |
|
||||
| `largeTextPaste.ts` | Detect large plain-text pastes and build virtual `.txt` files |
|
||||
| `largeTextPasteOffer.ts` | Ask-toast offer id begin/resolve (supersede + double-apply guards) |
|
||||
|
||||
`ChatInput.handlePaste` owns paste orchestration: URL-over-selection markdown
|
||||
links, clipboard images (attach + citation), and large plain-text pastes.
|
||||
Large pastes (about 2,000 characters or 25 lines) follow the composer setting
|
||||
`largeTextPasteBehavior` (`ask` / `attach` / `inline`). Attaching creates an
|
||||
in-memory `text/plain` file named `pasted-context-N.txt`, inserts a bracket
|
||||
citation, and sends it through the same attachment pipeline as a manually
|
||||
picked `.txt` file. Ask-toast actions read live composer/attachment state so
|
||||
typing or other attaches between paste and choice stay consistent. Short text,
|
||||
images, and URL wraps keep their existing paths.
|
||||
|
||||
## The prompt language
|
||||
|
||||
@@ -60,6 +72,15 @@ copy.
|
||||
exactly what gets sent, so nothing downstream serializes a rich document model
|
||||
back into a prompt.
|
||||
|
||||
The document is not, however, the string it was given: CodeMirror normalizes
|
||||
line endings, so a `\r\n` pair becomes one break and the document ends up
|
||||
shorter than the inserted string. **Never derive a caret position from the
|
||||
length of text you are inserting** — a caret past the end makes `dispatch`
|
||||
throw, the transaction never applies, and the un-normalized text stays in React
|
||||
state to crash again on the next restore. Every edit that moves the caret goes
|
||||
through `replaceWithCaret` (`editor/documentEdits.ts`), which measures the
|
||||
change instead of the string.
|
||||
|
||||
The composer previously painted a transparent `<textarea>` over a mirror
|
||||
`<div>`. That restricted highlighting to styles which do not change glyph
|
||||
advance width — colour, background, underline — because anything else made the
|
||||
@@ -112,6 +133,14 @@ token: themes define `--interactive-selection` with its own alpha, so mixing it
|
||||
with transparent again is nearly invisible. The iOS system overlay owns its
|
||||
visible selection fill.
|
||||
|
||||
The content element keeps the existing correction policy: on in the mobile UI,
|
||||
off elsewhere. CodeMirror also reads the attribute and reverts Apple and
|
||||
Android's insert-period-on-double-space only when its value is exactly `off`.
|
||||
`editor/autocorrect.ts` uses the HTML standard's
|
||||
[ASCII case-insensitive `autocorrect` keywords](https://html.spec.whatwg.org/multipage/interaction.html#attr-autocorrect)
|
||||
to keep desktop word correction off while avoiding that CodeMirror-only
|
||||
revert. Its platform checks deliberately match CodeMirror's own browser flags.
|
||||
|
||||
`composerLanguage.ts` retokenizes the whole document on every change. The
|
||||
composer holds a prompt, not a source file: it is short enough that a full pass
|
||||
is cheaper and far simpler than incremental mapping, and it keeps the editor
|
||||
@@ -141,6 +170,9 @@ and the send path reading the same grammar.
|
||||
- `state/useDraftTarget.ts` — the draft can target a directory that does not
|
||||
exist yet (a worktree being created). It must survive not appearing in the
|
||||
branch list, or the selector snaps back to the project root mid-creation.
|
||||
- `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker
|
||||
state and registers its application shortcuts locally. The selectors only
|
||||
consume their shared prefix while the draft target UI is mounted.
|
||||
|
||||
## Mobile
|
||||
|
||||
@@ -159,8 +191,8 @@ hardware.
|
||||
|
||||
The package has no DOM test environment, so coverage stops at the state and
|
||||
logic layers: the language, the submit assembly, path and drop handling, text
|
||||
splicing, message history, and the CodeMirror language extension at the
|
||||
`EditorState` level.
|
||||
splicing, large-paste detection, paste-offer invalidation, message history, and
|
||||
the CodeMirror language extension at the `EditorState` level.
|
||||
|
||||
Rendering, focus, keyboard behavior, IME and WKWebView are **not covered by
|
||||
tests** and are verified by hand. Do not report a change to them as validated
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
LARGE_TEXT_PASTE_CHAR_THRESHOLD,
|
||||
LARGE_TEXT_PASTE_LINE_THRESHOLD,
|
||||
createPastedContextFile,
|
||||
isLargePlainTextPaste,
|
||||
} from '../largeTextPaste';
|
||||
|
||||
describe('large text paste helpers', () => {
|
||||
test('treats short text as not large', () => {
|
||||
expect(isLargePlainTextPaste('hello world')).toBe(false);
|
||||
expect(isLargePlainTextPaste('line1\nline2\nline3')).toBe(false);
|
||||
});
|
||||
|
||||
test('treats empty and whitespace-only pastes as not large', () => {
|
||||
expect(isLargePlainTextPaste('')).toBe(false);
|
||||
expect(isLargePlainTextPaste(' \n\t ')).toBe(false);
|
||||
});
|
||||
|
||||
test('detects pastes at the character threshold', () => {
|
||||
const text = 'a'.repeat(LARGE_TEXT_PASTE_CHAR_THRESHOLD);
|
||||
expect(isLargePlainTextPaste(text)).toBe(true);
|
||||
expect(isLargePlainTextPaste(text.slice(0, -1))).toBe(false);
|
||||
});
|
||||
|
||||
test('detects pastes at the line threshold', () => {
|
||||
const lines = Array.from({ length: LARGE_TEXT_PASTE_LINE_THRESHOLD }, (_, index) => `line ${index}`);
|
||||
expect(isLargePlainTextPaste(lines.join('\n'))).toBe(true);
|
||||
expect(isLargePlainTextPaste(lines.slice(0, -1).join('\n'))).toBe(false);
|
||||
});
|
||||
|
||||
test('honors custom thresholds', () => {
|
||||
expect(isLargePlainTextPaste('abcdef', { charThreshold: 5 })).toBe(true);
|
||||
expect(isLargePlainTextPaste('a\nb\nc', { lineThreshold: 3 })).toBe(true);
|
||||
expect(isLargePlainTextPaste('a\nb', { lineThreshold: 3, charThreshold: 100 })).toBe(false);
|
||||
});
|
||||
|
||||
test('creates a text/plain file with the given name', async () => {
|
||||
const file = createPastedContextFile('architecture notes', 'pasted-context-1.txt');
|
||||
expect(file.name).toBe('pasted-context-1.txt');
|
||||
expect(file.type.startsWith('text/plain')).toBe(true);
|
||||
expect(await file.text()).toBe('architecture notes');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
LARGE_TEXT_PASTE_TOAST_CLASSNAME,
|
||||
beginLargeTextPasteOffer,
|
||||
resolveLargeTextPasteOffer,
|
||||
} from '../largeTextPasteOffer';
|
||||
|
||||
describe('large text paste offer state', () => {
|
||||
test('begin allocates the next offer id', () => {
|
||||
expect(beginLargeTextPasteOffer(0)).toBe(1);
|
||||
expect(beginLargeTextPasteOffer(3)).toBe(4);
|
||||
});
|
||||
|
||||
test('resolve accepts a matching active offer and invalidates it', () => {
|
||||
expect(resolveLargeTextPasteOffer(2, 2)).toEqual({
|
||||
accepted: true,
|
||||
nextOfferId: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolve rejects a superseded offer without advancing', () => {
|
||||
expect(resolveLargeTextPasteOffer(5, 4)).toEqual({
|
||||
accepted: false,
|
||||
nextOfferId: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test('second resolve after accept is rejected (double-apply guard)', () => {
|
||||
const first = resolveLargeTextPasteOffer(1, 1);
|
||||
expect(first.accepted).toBe(true);
|
||||
expect(resolveLargeTextPasteOffer(first.nextOfferId, 1)).toEqual({
|
||||
accepted: false,
|
||||
nextOfferId: first.nextOfferId,
|
||||
});
|
||||
});
|
||||
|
||||
test('begin then resolve of the old id is rejected', () => {
|
||||
const previous = 2;
|
||||
const next = beginLargeTextPasteOffer(previous);
|
||||
expect(resolveLargeTextPasteOffer(next, previous)).toEqual({
|
||||
accepted: false,
|
||||
nextOfferId: next,
|
||||
});
|
||||
expect(resolveLargeTextPasteOffer(next, next).accepted).toBe(true);
|
||||
});
|
||||
|
||||
test('toast class widens only from the sm breakpoint', () => {
|
||||
const classes = LARGE_TEXT_PASTE_TOAST_CLASSNAME.split(/\s+/);
|
||||
expect(classes).toContain('sm:!min-w-[22rem]');
|
||||
expect(classes).toContain('sm:!w-auto');
|
||||
expect(classes).toContain('[&_[data-icon]]:!hidden');
|
||||
expect(classes.includes('!min-w-[22rem]')).toBe(false);
|
||||
expect(classes.includes('!w-auto')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
appendInlineText,
|
||||
appendWithLineBreaks,
|
||||
buildImagePasteInsertion,
|
||||
getMarkdownAutoPairEdit,
|
||||
shouldWrapSelectionAsLink,
|
||||
withInlineInsertionBoundaries,
|
||||
} from '../text';
|
||||
@@ -119,3 +120,39 @@ describe('shouldWrapSelectionAsLink', () => {
|
||||
expect(shouldWrapSelectionAsLink('https://x.dev', '[docs](https://y.dev)')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMarkdownAutoPairEdit', () => {
|
||||
test('completes a fenced block with the caret on the middle line', () => {
|
||||
expect(getMarkdownAutoPairEdit('``', '`', 2, 2)).toEqual({
|
||||
from: 2,
|
||||
to: 2,
|
||||
insert: '`\n\n```',
|
||||
selectionStart: 4,
|
||||
selectionEnd: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test('completes a fence at the start of any line', () => {
|
||||
expect(getMarkdownAutoPairEdit('intro\n``tail', '`', 8, 8)).toEqual({
|
||||
from: 8,
|
||||
to: 8,
|
||||
insert: '`\n\n```',
|
||||
selectionStart: 10,
|
||||
selectionEnd: 10,
|
||||
});
|
||||
});
|
||||
|
||||
test('does not complete two backticks in the middle of a line', () => {
|
||||
expect(getMarkdownAutoPairEdit('text ``', '`', 7, 7)).toBeNull();
|
||||
});
|
||||
|
||||
test('wraps selected text and keeps the text selected', () => {
|
||||
expect(getMarkdownAutoPairEdit('hello', '*', 1, 4)).toEqual({
|
||||
from: 1,
|
||||
to: 4,
|
||||
insert: '*ell*',
|
||||
selectionStart: 2,
|
||||
selectionEnd: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,9 @@ import {
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ComposerLanguageContext } from '../language/tokenize';
|
||||
import type { ComposerAutoCorrect } from './autocorrect';
|
||||
import { composerLanguage, setLanguageContext } from './composerLanguage';
|
||||
import { replaceWithCaret } from './documentEdits';
|
||||
import type { ComposerEditorViewStore } from './viewStore';
|
||||
import { composerEditorTheme, composerSelectionExtension } from './theme';
|
||||
import { handleComposerHostMouseDown } from './hostMouseDown';
|
||||
@@ -63,8 +65,8 @@ export interface ComposerEditorHandle {
|
||||
selectAll(): void;
|
||||
/** Replace the current selection, leaving the caret after the insertion. */
|
||||
insertText(text: string): void;
|
||||
/** Replace an explicit range; the caret lands at `caret` or after the text. */
|
||||
replaceRange(from: number, to: number, text: string, caret?: number): void;
|
||||
/** Replace a range; selection defaults to a caret after the inserted text. */
|
||||
replaceRange(from: number, to: number, text: string, selectionStart?: number, selectionEnd?: number): void;
|
||||
/** Viewport coordinates of the caret, for positioning popups. */
|
||||
caretCoords(position?: number): { top: number; bottom: number; left: number } | null;
|
||||
/** The scrollable element, for measuring and scroll compensation. */
|
||||
@@ -89,8 +91,11 @@ export interface ComposerEditorProps {
|
||||
placeholder?: string;
|
||||
editable?: boolean;
|
||||
spellCheck?: boolean;
|
||||
/** Mobile keyboards; ignored on desktop. */
|
||||
autoCorrect?: boolean;
|
||||
/**
|
||||
* The content element's autocorrect keyword. See `autocorrect.ts` for the
|
||||
* case-sensitive CodeMirror workaround.
|
||||
*/
|
||||
autoCorrect?: ComposerAutoCorrect;
|
||||
autoCapitalize?: 'none' | 'sentences';
|
||||
/** Fill the available height instead of growing with the content. */
|
||||
fillContainer?: boolean;
|
||||
@@ -157,7 +162,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
placeholder,
|
||||
editable = true,
|
||||
spellCheck = false,
|
||||
autoCorrect = false,
|
||||
autoCorrect = 'off',
|
||||
autoCapitalize = 'none',
|
||||
fillContainer = false,
|
||||
maxLines = 8,
|
||||
@@ -287,7 +292,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
}),
|
||||
EditorView.contentAttributes.of({
|
||||
spellcheck: String(handlersRef.current.spellCheck ?? false),
|
||||
autocorrect: handlersRef.current.autoCorrect ? 'on' : 'off',
|
||||
autocorrect: handlersRef.current.autoCorrect ?? 'off',
|
||||
autocapitalize: handlersRef.current.autoCapitalize ?? 'none',
|
||||
...(handlersRef.current['aria-label']
|
||||
? { 'aria-label': handlersRef.current['aria-label'] }
|
||||
@@ -347,17 +352,14 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
// A stale value echo can differ from CodeMirror's newer document,
|
||||
// and replacing it would interrupt the IME session and move the caret.
|
||||
if (view.compositionStarted) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: current.length, insert: value },
|
||||
// An external rewrite (draft restore, history navigation,
|
||||
// "add to chat", dictation insert) lands the caret at the END,
|
||||
// matching what a plain textarea did when its value was
|
||||
// replaced. Every rewrite that reaches here appends or
|
||||
// replaces wholesale; keeping the old caret instead left it
|
||||
// stranded before the inserted text, and the next insertion
|
||||
// or keystroke landed inside the previous one.
|
||||
selection: { anchor: value.length },
|
||||
});
|
||||
// An external rewrite (draft restore, history navigation,
|
||||
// "add to chat", dictation insert) lands the caret at the END,
|
||||
// matching what a plain textarea did when its value was replaced.
|
||||
// Every rewrite that reaches here appends or replaces wholesale;
|
||||
// keeping the old caret instead left it stranded before the
|
||||
// inserted text, and the next insertion or keystroke landed inside
|
||||
// the previous one.
|
||||
view.dispatch(replaceWithCaret(view.state, 0, current.length, value));
|
||||
// A large insert can push the caret below the fold, and a
|
||||
// transaction-time `scrollIntoView` cannot reach it: wrapped-line
|
||||
// heights are still estimates during the update, and the
|
||||
@@ -454,7 +456,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
if (!view) return;
|
||||
const content = view.contentDOM;
|
||||
content.setAttribute('spellcheck', String(spellCheck));
|
||||
content.setAttribute('autocorrect', autoCorrect ? 'on' : 'off');
|
||||
content.setAttribute('autocorrect', autoCorrect);
|
||||
content.setAttribute('autocapitalize', autoCapitalize);
|
||||
}, [autoCapitalize, autoCorrect, spellCheck]);
|
||||
|
||||
@@ -511,17 +513,18 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
if (!view || !text) return;
|
||||
const { from, to } = view.state.selection.main;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: text },
|
||||
selection: { anchor: from + text.length },
|
||||
...replaceWithCaret(view.state, from, to, text),
|
||||
userEvent: 'input.type',
|
||||
});
|
||||
},
|
||||
replaceRange(from, to, text, caret) {
|
||||
replaceRange(from, to, text, selectionStart, selectionEnd = selectionStart) {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const caret = selectionStart === undefined
|
||||
? undefined
|
||||
: { anchor: selectionStart, head: selectionEnd ?? selectionStart };
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: text },
|
||||
selection: { anchor: caret ?? from + text.length },
|
||||
...replaceWithCaret(view.state, from, to, text, caret),
|
||||
userEvent: 'input.type',
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { composerAutoCorrect, type ComposerAutoCorrect } from '../autocorrect';
|
||||
|
||||
const platform = (overrides: Partial<Navigator>): Navigator => ({
|
||||
maxTouchPoints: 0,
|
||||
platform: '',
|
||||
userAgent: '',
|
||||
vendor: '',
|
||||
...overrides,
|
||||
} as Navigator);
|
||||
|
||||
const codeMirrorKeepsDoubleSpacePeriod = (
|
||||
autoCorrect: ComposerAutoCorrect,
|
||||
): boolean => autoCorrect !== 'off';
|
||||
|
||||
const affectedPlatforms: Array<[string, Navigator]> = [
|
||||
['macOS', platform({ platform: 'MacIntel' })],
|
||||
['iPhone', platform({
|
||||
platform: 'iPhone',
|
||||
userAgent: 'Mozilla/5.0 Mobile/15E148 Safari/604.1',
|
||||
vendor: 'Apple Computer, Inc.',
|
||||
})],
|
||||
['iPadOS touch detection', platform({
|
||||
maxTouchPoints: 5,
|
||||
userAgent: 'Mozilla/5.0 Version/17.4 Safari/605.1.15',
|
||||
vendor: 'Apple Computer, Inc.',
|
||||
})],
|
||||
['Android', platform({
|
||||
platform: 'Linux armv8l',
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8)',
|
||||
})],
|
||||
];
|
||||
|
||||
const unaffectedPlatforms: Array<[string, Navigator]> = [
|
||||
['Windows', platform({ platform: 'Win32' })],
|
||||
['Linux', platform({ platform: 'Linux x86_64' })],
|
||||
];
|
||||
|
||||
describe('composerAutoCorrect', () => {
|
||||
test('matches the pinned CodeMirror period-revert guard', () => {
|
||||
const source = readFileSync(
|
||||
fileURLToPath(import.meta.resolve('@codemirror/view')),
|
||||
'utf8',
|
||||
);
|
||||
const semantics = source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\s+/g, '');
|
||||
|
||||
expect(/getAttribute\(["']autocorrect["']\)==["']off["']/.test(semantics)).toBe(true);
|
||||
expect(semantics).toContain(
|
||||
'constios=safari&&(/Mobile\\/\\w+/.test(nav.userAgent)||nav.maxTouchPoints>2)',
|
||||
);
|
||||
expect(semantics).toContain('mac:ios||/Mac/.test(nav.platform)');
|
||||
expect(semantics).toContain('android:/Android\\b/.test(nav.userAgent)');
|
||||
});
|
||||
|
||||
for (const [name, navigator] of affectedPlatforms) {
|
||||
test(`preserves the ${name} platform period without enabling autocorrect`, () => {
|
||||
const autoCorrect = composerAutoCorrect({ isMobile: false, navigator });
|
||||
|
||||
expect(autoCorrect.toLowerCase()).toBe('off');
|
||||
// @codemirror/view 6.39.13 reverts the native period only for exact "off".
|
||||
expect(codeMirrorKeepsDoubleSpacePeriod(autoCorrect)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, navigator] of unaffectedPlatforms) {
|
||||
test(`leaves desktop correction off on ${name}`, () => {
|
||||
expect(composerAutoCorrect({ isMobile: false, navigator })).toBe('off');
|
||||
});
|
||||
}
|
||||
|
||||
test('uses CodeMirror platform detection rather than a macOS user agent', () => {
|
||||
expect(composerAutoCorrect({
|
||||
isMobile: false,
|
||||
navigator: platform({
|
||||
platform: 'Linux x86_64',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
|
||||
}),
|
||||
})).toBe('off');
|
||||
});
|
||||
|
||||
test('preserves the existing mobile autocorrect policy', () => {
|
||||
expect(composerAutoCorrect({
|
||||
isMobile: true,
|
||||
navigator: platform({ platform: 'Win32' }),
|
||||
})).toBe('on');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
|
||||
import { replaceWithCaret } from '../documentEdits';
|
||||
|
||||
const apply = (doc: string, from: number, to: number, insert: string, caret?: { anchor: number; head: number }) => {
|
||||
const state = EditorState.create({ doc });
|
||||
const next = state.update(replaceWithCaret(state, from, to, insert, caret)).state;
|
||||
return { text: next.doc.toString(), selection: next.selection.main };
|
||||
};
|
||||
|
||||
describe('replaceWithCaret', () => {
|
||||
test('puts the caret at the end of a wholesale replacement', () => {
|
||||
const { text, selection } = apply('old', 0, 3, 'a new draft');
|
||||
|
||||
expect(text).toBe('a new draft');
|
||||
expect(selection.anchor).toBe(11);
|
||||
expect(selection.head).toBe(11);
|
||||
});
|
||||
|
||||
// Issue #3013: CodeMirror collapses `\r\n` into one line break, so a caret
|
||||
// taken from the JS string length falls outside the document and dispatch
|
||||
// throws `RangeError: Selection points outside of document`.
|
||||
test('keeps the caret inside the document when CRLF is normalized away', () => {
|
||||
const { text, selection } = apply('a', 0, 1, 'x\r\ny');
|
||||
|
||||
expect(text).toBe('x\ny');
|
||||
expect(selection.anchor).toBe(3);
|
||||
});
|
||||
|
||||
test('survives a draft made only of CRLF breaks', () => {
|
||||
const { text, selection } = apply('a', 0, 1, '\r\n\r\n\r\n');
|
||||
|
||||
expect(text).toBe('\n\n\n');
|
||||
expect(selection.anchor).toBe(3);
|
||||
});
|
||||
|
||||
test('places the caret after text inserted at the selection', () => {
|
||||
const { text, selection } = apply('hello world', 5, 5, ',\r\n there');
|
||||
|
||||
expect(text).toBe('hello,\n there world');
|
||||
expect(selection.anchor).toBe(13);
|
||||
});
|
||||
|
||||
test('honours an explicit caret', () => {
|
||||
const { selection } = apply('hello', 0, 5, 'goodbye', { anchor: 2, head: 4 });
|
||||
|
||||
expect(selection.anchor).toBe(2);
|
||||
expect(selection.head).toBe(4);
|
||||
});
|
||||
|
||||
test('clamps an explicit caret that the normalized document cannot hold', () => {
|
||||
const { text, selection } = apply('a', 0, 1, 'x\r\ny', { anchor: 4, head: 4 });
|
||||
|
||||
expect(text).toBe('x\ny');
|
||||
expect(selection.anchor).toBe(3);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -19,7 +19,7 @@ describe('composer value writeback composition guard (issue #2527)', () => {
|
||||
const effect = writebackEffect();
|
||||
const equalityCheck = effect.indexOf('if (current === value) return;');
|
||||
const compositionGuard = effect.indexOf('if (view.compositionStarted) return;');
|
||||
const dispatch = effect.indexOf('view.dispatch({');
|
||||
const dispatch = effect.indexOf('view.dispatch(');
|
||||
|
||||
expect(equalityCheck).toBeGreaterThan(-1);
|
||||
expect(compositionGuard).toBeGreaterThan(equalityCheck);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export type ComposerAutoCorrect = 'on' | 'off' | 'Off';
|
||||
|
||||
type PlatformNavigator = Pick<Navigator,
|
||||
'maxTouchPoints' | 'platform' | 'userAgent' | 'vendor'
|
||||
>;
|
||||
|
||||
/** Keep desktop autocorrect off without triggering CodeMirror's period revert. */
|
||||
export function composerAutoCorrect(options: {
|
||||
isMobile: boolean;
|
||||
navigator?: PlatformNavigator;
|
||||
}): ComposerAutoCorrect {
|
||||
if (options.isMobile) return 'on';
|
||||
|
||||
const nav = options.navigator
|
||||
?? (typeof navigator === 'undefined'
|
||||
? { maxTouchPoints: 0, platform: '', userAgent: '', vendor: '' }
|
||||
: navigator);
|
||||
// These must match CodeMirror's flags because its revert checks exact "off".
|
||||
const ios = /Apple Computer/.test(nav.vendor)
|
||||
&& (/Mobile\/\w+/.test(nav.userAgent) || nav.maxTouchPoints > 2);
|
||||
return ios || /Mac/.test(nav.platform) || /Android\b/.test(nav.userAgent)
|
||||
? 'Off'
|
||||
: 'off';
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { EditorState, TransactionSpec } from '@codemirror/state';
|
||||
|
||||
/**
|
||||
* Replace a document range and leave the caret inside the resulting document.
|
||||
*
|
||||
* CodeMirror normalizes line endings on the way in: a `\r\n` pair becomes one
|
||||
* line break, so the inserted string is longer than the text it produces. A
|
||||
* caret derived from the JavaScript string therefore lands past the end of the
|
||||
* document and `dispatch` throws `RangeError: Selection points outside of
|
||||
* document`. The transaction never applies, so the un-normalized text stays in
|
||||
* React state, gets persisted as a draft, and crashes the chat again on every
|
||||
* restore (issue #3013).
|
||||
*
|
||||
* Deriving the caret from the change set instead keeps it correct for whatever
|
||||
* CodeMirror actually inserted, without this module having to know the
|
||||
* normalization rules.
|
||||
*/
|
||||
export const replaceWithCaret = (
|
||||
state: EditorState,
|
||||
from: number,
|
||||
to: number,
|
||||
insert: string,
|
||||
caret?: { anchor: number; head: number },
|
||||
): TransactionSpec => {
|
||||
const changes = state.changes({ from, to, insert });
|
||||
const clamp = (position: number): number => Math.min(Math.max(position, 0), changes.newLength);
|
||||
// What CodeMirror inserted, measured on the document rather than on the
|
||||
// string: the new length minus everything the change left untouched.
|
||||
const insertedLength = changes.newLength - (state.doc.length - (to - from));
|
||||
const anchor = caret ? clamp(caret.anchor) : from + insertedLength;
|
||||
const head = caret ? clamp(caret.head) : anchor;
|
||||
return { changes, selection: { anchor, head } };
|
||||
};
|
||||
@@ -20,6 +20,8 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-content': {
|
||||
padding: '0',
|
||||
// Keep the drawn empty-document cursor inside the scroller's horizontal clip.
|
||||
paddingInlineStart: '1px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Large plain-text paste → virtual file attachment helpers.
|
||||
*
|
||||
* Detect when clipboard text is large enough that inserting it into the
|
||||
* composer would clutter the prompt, and build an in-memory text/plain File
|
||||
* the attachment pipeline can send like any other .txt attachment.
|
||||
*/
|
||||
|
||||
export const LARGE_TEXT_PASTE_CHAR_THRESHOLD = 2000;
|
||||
export const LARGE_TEXT_PASTE_LINE_THRESHOLD = 25;
|
||||
|
||||
const countLines = (text: string): number => {
|
||||
let lines = 1;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
if (text.charCodeAt(index) === 10) {
|
||||
lines += 1;
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether pasted plain text should be offered (or auto-handled) as a file
|
||||
* attachment instead of being inserted into the composer.
|
||||
*
|
||||
* Empty / whitespace-only pastes are never large. Thresholds are OR'd:
|
||||
* character count or line count is enough.
|
||||
*/
|
||||
export const isLargePlainTextPaste = (
|
||||
text: string,
|
||||
options?: {
|
||||
charThreshold?: number;
|
||||
lineThreshold?: number;
|
||||
},
|
||||
): boolean => {
|
||||
if (!text || !text.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const charThreshold = options?.charThreshold ?? LARGE_TEXT_PASTE_CHAR_THRESHOLD;
|
||||
const lineThreshold = options?.lineThreshold ?? LARGE_TEXT_PASTE_LINE_THRESHOLD;
|
||||
|
||||
if (text.length >= charThreshold) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return countLines(text) >= lineThreshold;
|
||||
};
|
||||
|
||||
export const createPastedContextFile = (text: string, filename: string): File => (
|
||||
new File([text], filename, {
|
||||
type: 'text/plain',
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Offer-id state for the large-text paste ask toast.
|
||||
*
|
||||
* The toast can outlive the paste event (duration Infinity), and a second
|
||||
* large paste can supersede an unanswered offer. These helpers keep that
|
||||
* invalidation pure so ChatInput only wires toast UI to attach/inline actions.
|
||||
*/
|
||||
|
||||
/** Allocate a new offer id, superseding any unanswered previous offer. */
|
||||
export const beginLargeTextPasteOffer = (activeOfferId: number): number => (
|
||||
activeOfferId + 1
|
||||
);
|
||||
|
||||
/**
|
||||
* Attempt to resolve an offer. Returns whether this call won the race, and the
|
||||
* next active id. A superseded or already-resolved offer is rejected so
|
||||
* dismiss/action cannot double-apply.
|
||||
*/
|
||||
export const resolveLargeTextPasteOffer = (
|
||||
activeOfferId: number,
|
||||
offerId: number,
|
||||
) => {
|
||||
if (offerId !== activeOfferId) {
|
||||
return { accepted: false, nextOfferId: activeOfferId };
|
||||
}
|
||||
return { accepted: true, nextOfferId: activeOfferId + 1 };
|
||||
};
|
||||
|
||||
/** Toast chrome: widen on desktop only; leave mobile full-width to Sonner. */
|
||||
export const LARGE_TEXT_PASTE_TOAST_CLASSNAME =
|
||||
'[&_[data-icon]]:!hidden sm:!min-w-[22rem] sm:!w-auto';
|
||||
@@ -33,6 +33,7 @@ export interface MobileComposerHolders {
|
||||
draftPickerOpen: boolean;
|
||||
issuePickerOpen: boolean;
|
||||
prPickerOpen: boolean;
|
||||
linearPickerOpen: boolean;
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
@@ -204,7 +205,8 @@ export function useMobileComposerShell(
|
||||
|| holders.controlsPanelOpen
|
||||
|| holders.attachMenuOpen
|
||||
|| holders.issuePickerOpen
|
||||
|| holders.prPickerOpen;
|
||||
|| holders.prPickerOpen
|
||||
|| holders.linearPickerOpen;
|
||||
|
||||
// Installed PWA (standalone): a focus() from a bare timeout is outside the
|
||||
// user gesture and iOS refuses to raise the keyboard for it (Safari
|
||||
@@ -212,7 +214,7 @@ export function useMobileComposerShell(
|
||||
// 'oc:mobile-overlay-closed' synchronously from the same React flush as the
|
||||
// click that closed it — refocus right there, while the gesture is live.
|
||||
const pickerDialogsOpenRef = React.useRef(false);
|
||||
pickerDialogsOpenRef.current = holders.issuePickerOpen || holders.prPickerOpen;
|
||||
pickerDialogsOpenRef.current = holders.issuePickerOpen || holders.prPickerOpen || holders.linearPickerOpen;
|
||||
const skipNextCloseRestoreRef = React.useRef(false);
|
||||
const openSheetCountRef = React.useRef(0);
|
||||
const holdFocusUntilRef = React.useRef(0);
|
||||
@@ -307,6 +309,7 @@ export function useMobileComposerShell(
|
||||
|| holders.draftPickerOpen
|
||||
|| holders.issuePickerOpen
|
||||
|| holders.prPickerOpen
|
||||
|| holders.linearPickerOpen
|
||||
|| holders.isDragging;
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -17,6 +17,15 @@ import React from 'react';
|
||||
import { isCapacitorApp } from '@/lib/platform';
|
||||
import type { ComposerEditorHandle } from '../editor/ComposerEditor';
|
||||
|
||||
// Android mobile browsers are the pan-mode holdouts this pin exists for on
|
||||
// the CHAT screen too: interactive-widget=resizes-content is ignored by a
|
||||
// fair share of Android WebView/Chrome builds, and unlike iOS Safari they do
|
||||
// not reliably reveal the focused field either — the composer just stays
|
||||
// behind the keyboard. iOS keeps its browser-native reveal on the chat
|
||||
// screen, so this stays Android-only there.
|
||||
// Callers are browser-only React effects, so navigator always exists here.
|
||||
const isAndroidBrowser = (): boolean => /Android/i.test(navigator.userAgent);
|
||||
|
||||
export interface MobileViewportPinOptions {
|
||||
isMobile: boolean;
|
||||
/** Composer expanded to fullscreen on mobile. */
|
||||
@@ -96,12 +105,14 @@ export function useMobileViewportPin(options: MobileViewportPinOptions): void {
|
||||
};
|
||||
}, [editorRef, formRef, isFullscreen, isMobile]);
|
||||
|
||||
// Draft screen with the keyboard up: anchor the normal-height composer to
|
||||
// the visible bottom. The chat screen does not need this — its own
|
||||
// focused-field reveal works there.
|
||||
// Keyboard up: anchor the normal-height composer to the visible bottom.
|
||||
// Draft screen on every mobile browser; chat screen only on Android,
|
||||
// where neither viewport resizing nor the focused-field reveal can be
|
||||
// relied on (iOS chat keeps the browser's own reveal).
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isMobile || isCapacitorApp()) return;
|
||||
if (!isDraftScreen || isFullscreen || !isFocused) return;
|
||||
if (isFullscreen || !isFocused) return;
|
||||
if (!isDraftScreen && !isAndroidBrowser()) return;
|
||||
const vv = window.visualViewport;
|
||||
const form = formRef.current;
|
||||
if (!vv || !form) return;
|
||||
|
||||
+14
@@ -40,6 +40,7 @@ const input = (overrides: Partial<OutgoingMessageInput> = {}): OutgoingMessageIn
|
||||
syntheticTexts: [],
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -203,6 +204,17 @@ describe('synthetic context', () => {
|
||||
.toEqual({ kind: 'github-issue', number: 3, title: 'Bug', url: 'https://x/issues/3' });
|
||||
});
|
||||
|
||||
test('a linked Linear issue is sent as context', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'fix it',
|
||||
linkedLinearIssue: { identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12', contextText: 'linear body' },
|
||||
}), deps());
|
||||
expect(result.additionalParts).toHaveLength(1);
|
||||
expect(result.additionalParts[0].text).toBe('linear body');
|
||||
expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY])
|
||||
.toEqual({ kind: 'linear-issue', identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12' });
|
||||
});
|
||||
|
||||
test('synthetic texts precede the linked references', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'x',
|
||||
@@ -255,6 +267,7 @@ describe('full assembly order', () => {
|
||||
syntheticTexts: ['synthetic'],
|
||||
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' },
|
||||
linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' },
|
||||
linkedLinearIssue: { identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12', contextText: 'linear' },
|
||||
}), deps());
|
||||
|
||||
expect(result.primaryText).toBe('q1');
|
||||
@@ -265,6 +278,7 @@ describe('full assembly order', () => {
|
||||
'issue',
|
||||
'pr-how',
|
||||
'pr-diff',
|
||||
'linear',
|
||||
'use: deploy',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface OutgoingMessageInput {
|
||||
syntheticTexts: readonly string[];
|
||||
linkedIssue: { number: number; title: string; url: string; contextText: string } | null;
|
||||
linkedPr: { number: number; title: string; url: string; instructions: string; context: string } | null;
|
||||
linkedLinearIssue: { identifier: string; title: string; url: string; contextText: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,6 +162,11 @@ export function buildOutgoingMessage(
|
||||
additionalParts.push(createContextPart({ kind: 'github-pr', number, title, url }, context));
|
||||
}
|
||||
|
||||
if (input.linkedLinearIssue) {
|
||||
const { identifier, title, url, contextText } = input.linkedLinearIssue;
|
||||
additionalParts.push(createContextPart({ kind: 'linear-issue', identifier, title, url }, contextText));
|
||||
}
|
||||
|
||||
const skillInstruction = deps.buildSkillInstruction(skillNames);
|
||||
if (skillInstruction) {
|
||||
additionalParts.push({ text: skillInstruction, synthetic: true });
|
||||
|
||||
@@ -104,3 +104,61 @@ export function shouldWrapSelectionAsLink(url: string, selected: string): boolea
|
||||
&& selected.trim().length > 0
|
||||
&& !selected.includes('](');
|
||||
}
|
||||
|
||||
const MARKDOWN_WRAP_PAIRS: Record<string, [string, string]> = {
|
||||
'`': ['`', '`'],
|
||||
'*': ['*', '*'],
|
||||
'_': ['_', '_'],
|
||||
'~': ['~', '~'],
|
||||
'(': ['(', ')'],
|
||||
'[': ['[', ']'],
|
||||
'{': ['{', '}'],
|
||||
'"': ['"', '"'],
|
||||
"'": ["'", "'"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Markdown source-mode conveniences handled before CodeMirror inserts a key.
|
||||
* The returned text change and selection belong to one editor transaction so
|
||||
* the caret cannot be applied against the previous document.
|
||||
*/
|
||||
export function getMarkdownAutoPairEdit(
|
||||
value: string,
|
||||
key: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number,
|
||||
): {
|
||||
from: number;
|
||||
to: number;
|
||||
insert: string;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
} | null {
|
||||
const pair = MARKDOWN_WRAP_PAIRS[key];
|
||||
if (selectionEnd > selectionStart && pair) {
|
||||
const selected = value.slice(selectionStart, selectionEnd);
|
||||
const [open, close] = pair;
|
||||
return {
|
||||
from: selectionStart,
|
||||
to: selectionEnd,
|
||||
insert: `${open}${selected}${close}`,
|
||||
selectionStart: selectionStart + open.length,
|
||||
selectionEnd: selectionEnd + open.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (key === '`' && selectionStart === selectionEnd) {
|
||||
const before = value.slice(0, selectionStart);
|
||||
if (/(^|\n)``$/.test(before)) {
|
||||
return {
|
||||
from: selectionStart,
|
||||
to: selectionEnd,
|
||||
insert: '`\n\n```',
|
||||
selectionStart: selectionStart + 2,
|
||||
selectionEnd: selectionStart + 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ type ComposerAttachmentControlsProps = {
|
||||
handlePickLocalFiles: () => void;
|
||||
openIssuePicker: () => void;
|
||||
openPrPicker: () => void;
|
||||
showLinearPicker?: boolean;
|
||||
openLinearPicker?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
onMenuOpenChange?: (open: boolean) => void;
|
||||
/** Mobile: open the attachment bottom sheet instead of the dropdown menu. */
|
||||
@@ -41,6 +43,8 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
|
||||
handlePickLocalFiles,
|
||||
openIssuePicker,
|
||||
openPrPicker,
|
||||
showLinearPicker,
|
||||
openLinearPicker,
|
||||
onOpenSettings,
|
||||
} = props;
|
||||
|
||||
@@ -114,6 +118,16 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
|
||||
<Icon name="git-pull-request"/>
|
||||
{t('chat.chatInput.actions.linkGithubPr')}
|
||||
</DropdownMenuItem>
|
||||
{showLinearPicker && openLinearPicker ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
requestAnimationFrame(openLinearPicker);
|
||||
}}
|
||||
>
|
||||
<Icon name="linear"/>
|
||||
{t('chat.chatInput.actions.linkLinearIssue')}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
@@ -136,6 +150,7 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
|
||||
prev.isVSCode === next.isVSCode
|
||||
&& prev.footerIconButtonClass === next.footerIconButtonClass
|
||||
&& prev.iconSizeClass === next.iconSizeClass
|
||||
&& prev.showLinearPicker === next.showLinearPicker
|
||||
&& prev.onOpenSettings === next.onOpenSettings
|
||||
&& prev.onMenuOpenChange === next.onMenuOpenChange
|
||||
&& prev.onOpenMobileSheet === next.onOpenMobileSheet
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface ComposerFooterProps {
|
||||
onPickLocalFiles: () => void;
|
||||
onOpenIssuePicker: () => void;
|
||||
onOpenPrPicker: () => void;
|
||||
showLinearPicker?: boolean;
|
||||
onOpenLinearPicker?: () => void;
|
||||
onOpenAttachSheet: () => void;
|
||||
onToggleExpandedInput: () => void;
|
||||
onTogglePermissionAutoAccept: () => void;
|
||||
@@ -94,6 +96,8 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
||||
onPickLocalFiles,
|
||||
onOpenIssuePicker,
|
||||
onOpenPrPicker,
|
||||
showLinearPicker,
|
||||
onOpenLinearPicker,
|
||||
onOpenAttachSheet,
|
||||
onToggleExpandedInput,
|
||||
onTogglePermissionAutoAccept,
|
||||
@@ -130,6 +134,8 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
||||
handlePickLocalFiles={onPickLocalFiles}
|
||||
openIssuePicker={onOpenIssuePicker}
|
||||
openPrPicker={onOpenPrPicker}
|
||||
showLinearPicker={showLinearPicker}
|
||||
openLinearPicker={onOpenLinearPicker}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onOpenMobileSheet={onOpenAttachSheet}
|
||||
/>
|
||||
@@ -199,6 +205,8 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
||||
handlePickLocalFiles={onPickLocalFiles}
|
||||
openIssuePicker={onOpenIssuePicker}
|
||||
openPrPicker={onOpenPrPicker}
|
||||
showLinearPicker={showLinearPicker}
|
||||
openLinearPicker={onOpenLinearPicker}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
<FocusModeButton
|
||||
|
||||
@@ -12,6 +12,7 @@ import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -26,6 +27,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
|
||||
import { useKeybind } from '@/hooks/useKeybind';
|
||||
import type { Theme } from '@/types/theme';
|
||||
import { normalizePath } from '../attachments/filePaths';
|
||||
import { getProjectDisplayLabel, type DraftTargetProject } from '../state/useDraftTarget';
|
||||
@@ -106,14 +108,48 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
onDirectoryChange,
|
||||
theme,
|
||||
} = props;
|
||||
const [openPicker, setOpenPicker] = React.useState<'project' | 'worktree' | null>(null);
|
||||
const projectTriggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const worktreeTriggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const handlePickerKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (openPicker === null || !shouldDismissDropdown(event)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpenPicker(null);
|
||||
};
|
||||
|
||||
useKeybind('open_draft_project_picker', () => {
|
||||
projectTriggerRef.current?.focus();
|
||||
setOpenPicker('project');
|
||||
});
|
||||
useKeybind('open_draft_worktree_picker', () => {
|
||||
if (!showBranchSelector) return false;
|
||||
worktreeTriggerRef.current?.focus();
|
||||
setOpenPicker('worktree');
|
||||
});
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
onProjectChange(projectId);
|
||||
setOpenPicker(null);
|
||||
};
|
||||
|
||||
const handleDirectoryChange = (directory: string) => {
|
||||
onDirectoryChange(directory);
|
||||
setOpenPicker(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
|
||||
<Select
|
||||
value={selectedProject.id}
|
||||
onValueChange={onProjectChange}
|
||||
open={openPicker === 'project'}
|
||||
onOpenChange={(open) => setOpenPicker(open ? 'project' : null)}
|
||||
onValueChange={handleProjectChange}
|
||||
disableGlobalShortcuts
|
||||
>
|
||||
<SelectTrigger
|
||||
ref={projectTriggerRef}
|
||||
onKeyDown={handlePickerKeyDown}
|
||||
size="sm"
|
||||
className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
|
||||
>
|
||||
@@ -123,9 +159,9 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
: <ProjectLabel project={selectedProject} theme={theme} />}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain fitContent>
|
||||
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain fitContent onKeyDown={handlePickerKeyDown}>
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id} className="max-w-[24rem] truncate">
|
||||
<SelectItem key={project.id} value={project.id} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||
<ProjectLabel project={project} theme={theme} />
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -135,9 +171,14 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
{showBranchSelector ? (
|
||||
<Select
|
||||
value={selectedDirectory ?? branchItems[0]?.value ?? normalizePath(selectedProject.path) ?? ''}
|
||||
onValueChange={onDirectoryChange}
|
||||
open={openPicker === 'worktree'}
|
||||
onOpenChange={(open) => setOpenPicker(open ? 'worktree' : null)}
|
||||
onValueChange={handleDirectoryChange}
|
||||
disableGlobalShortcuts
|
||||
>
|
||||
<SelectTrigger
|
||||
ref={worktreeTriggerRef}
|
||||
onKeyDown={handlePickerKeyDown}
|
||||
size="sm"
|
||||
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
|
||||
>
|
||||
@@ -145,11 +186,11 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
{selectedBranchLabel ?? t('chat.chatInput.branch')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48">
|
||||
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
|
||||
{projectRootBranchOption ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
|
||||
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} className="max-w-[24rem] truncate">
|
||||
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||
{projectRootBranchOption.label}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
@@ -168,13 +209,13 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
</button>
|
||||
</div>
|
||||
{worktreeBranchOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="max-w-[24rem] truncate">
|
||||
<SelectItem key={option.value} value={option.value} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||
{option.pending ? '⏳ ' : ''}{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
{selectedDirectory && !selectedBranchIsKnown ? (
|
||||
<SelectItem value={selectedDirectory} className="max-w-[24rem] truncate">
|
||||
<SelectItem value={selectedDirectory} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||
{selectedBranchLabel}
|
||||
</SelectItem>
|
||||
) : null}
|
||||
|
||||
@@ -5,7 +5,12 @@ import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn, isMacOS } from '@/lib/utils';
|
||||
import {
|
||||
formatShortcutForDisplay,
|
||||
getEffectiveShortcutCombo,
|
||||
} from '@/lib/shortcuts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
type FocusModeButtonProps = {
|
||||
footerIconButtonClass: string;
|
||||
@@ -17,6 +22,12 @@ type FocusModeButtonProps = {
|
||||
export const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) {
|
||||
const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props;
|
||||
const { t } = useI18n();
|
||||
const expandInputShortcutOverride = useUIStore((state) => state.shortcutOverrides.expand_input);
|
||||
const expandInputCombo = getEffectiveShortcutCombo(
|
||||
'expand_input',
|
||||
expandInputShortcutOverride === undefined ? undefined : { expand_input: expandInputShortcutOverride },
|
||||
);
|
||||
const shortcut = expandInputCombo ? formatShortcutForDisplay(expandInputCombo) : null;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
@@ -43,9 +54,7 @@ export const FocusModeButton = React.memo(function FocusModeButton(props: FocusM
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
<div className="flex flex-col gap-0.5 text-center">
|
||||
<span>{t('chat.chatInput.focusMode.label')}</span>
|
||||
<span className="font-mono opacity-60">
|
||||
{isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'}
|
||||
</span>
|
||||
{shortcut ? <span className="font-mono opacity-60">{shortcut}</span> : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface MobilePillComposerProps {
|
||||
onPickLocalFiles: () => void;
|
||||
onOpenIssuePicker: () => void;
|
||||
onOpenPrPicker: () => void;
|
||||
showLinearPicker?: boolean;
|
||||
onOpenLinearPicker?: () => void;
|
||||
onOpenAttachSheet: () => void;
|
||||
onStartDictation: () => void;
|
||||
onAbort: () => void;
|
||||
@@ -63,6 +65,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
|
||||
onPickLocalFiles,
|
||||
onOpenIssuePicker,
|
||||
onOpenPrPicker,
|
||||
showLinearPicker,
|
||||
onOpenLinearPicker,
|
||||
onOpenAttachSheet,
|
||||
onStartDictation,
|
||||
onAbort,
|
||||
@@ -95,6 +99,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
|
||||
handlePickLocalFiles={onPickLocalFiles}
|
||||
openIssuePicker={onOpenIssuePicker}
|
||||
openPrPicker={onOpenPrPicker}
|
||||
showLinearPicker={showLinearPicker}
|
||||
openLinearPicker={onOpenLinearPicker}
|
||||
onOpenMobileSheet={onOpenAttachSheet}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -103,7 +103,7 @@ const STYLE_CLASS: Record<AnyStyle, string> = {
|
||||
mentionAgent: 'text-[var(--status-success)]',
|
||||
mentionCommand: 'text-[var(--primary)]',
|
||||
mentionSnippet: 'text-[var(--status-warning)]',
|
||||
code: 'rounded-[3px] bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
|
||||
code: 'rounded-[6px] bg-[var(--markdown-inline-code-bg)] text-[var(--markdown-inline-code)]',
|
||||
codeFence: 'bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
|
||||
// A `~path` is written for the reader's benefit, not to attach anything —
|
||||
// it takes the same colour as a file mention, since it names the same kind
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { Part } from '@opencode-ai/sdk/v2'
|
||||
import { getFullText, getMessagePreview } from './messagePreview'
|
||||
import { CONTEXT_METADATA_KEY, type ContextPartPayload } from '@/lib/messages/contextParts'
|
||||
import { getFullText, getMessagePreview, getPromptPreviewText } from './messagePreview'
|
||||
|
||||
const textPart = (text: string): Part => ({ type: 'text', text } as Part)
|
||||
|
||||
// SAFETY: a synthetic context part as the composer builds it; the preview
|
||||
// helpers read only type, text, and metadata.
|
||||
const contextPart = (payload: ContextPartPayload, text: string): Part => ({
|
||||
id: 'prt_1',
|
||||
sessionID: 'ses_1',
|
||||
messageID: 'msg_1',
|
||||
type: 'text',
|
||||
text,
|
||||
synthetic: true,
|
||||
metadata: { [CONTEXT_METADATA_KEY]: payload },
|
||||
} as Part)
|
||||
|
||||
const chatQuote = (quote: string, text = ''): ContextPartPayload => ({ kind: 'chat-quote', quote, text })
|
||||
|
||||
const t = (key: string): string => (key === 'chat.message.context.chatQuote' ? 'Quoted from an earlier message' : key)
|
||||
|
||||
describe('messagePreview', () => {
|
||||
test('joins text parts for full text', () => {
|
||||
expect(getFullText([textPart('hello'), textPart('world')])).toBe('hello\nworld')
|
||||
@@ -18,4 +35,33 @@ describe('messagePreview', () => {
|
||||
expect(getMessagePreview([])).toBe('')
|
||||
expect(getFullText([{ type: 'file' } as Part])).toBe('')
|
||||
})
|
||||
|
||||
test('labels a quote-only message from its context part', () => {
|
||||
const parts = [contextPart(chatQuote('the anchored scroll bit'), 'Comment on this fragment...')]
|
||||
expect(getPromptPreviewText(parts, t)).toBe('Quoted from an earlier message: the anchored scroll bit')
|
||||
expect(getMessagePreview(parts, 160, t)).toBe('Quoted from an earlier message: the anchored scroll bit')
|
||||
})
|
||||
|
||||
test('prefers the quote comment over the quote itself', () => {
|
||||
const parts = [contextPart(chatQuote('the anchored scroll bit', 'why this?'), 'raw model text')]
|
||||
expect(getPromptPreviewText(parts, t)).toBe('Quoted from an earlier message: why this?')
|
||||
})
|
||||
|
||||
test('keeps the typed text when a message has both text and quotes', () => {
|
||||
const parts = [contextPart(chatQuote('quoted bit'), 'raw model text'), textPart('please explain')]
|
||||
expect(getPromptPreviewText(parts, t)).toBe('please explain')
|
||||
})
|
||||
|
||||
test('falls back to raw text without a translator', () => {
|
||||
const parts = [contextPart(chatQuote('quoted bit'), 'raw model text')]
|
||||
expect(getPromptPreviewText(parts)).toBe('raw model text')
|
||||
})
|
||||
|
||||
test('labels a Linear issue attachment from its identifier and title', () => {
|
||||
const parts = [contextPart(
|
||||
{ kind: 'linear-issue', identifier: 'ENG-12', title: 'Fix login', url: 'https://linear.app/eng-12' },
|
||||
'fetched issue body',
|
||||
)]
|
||||
expect(getPromptPreviewText(parts, t)).toBe('ENG-12 Fix login')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,133 @@
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import type { I18nKey, I18nParams } from '@/lib/i18n';
|
||||
import { readContextPart, type ContextPartPayload } from '@/lib/messages/contextParts';
|
||||
|
||||
type Translate = (key: I18nKey, params?: I18nParams) => string;
|
||||
|
||||
type TextPartLike = Part & { type: 'text'; text: string };
|
||||
|
||||
const isTextPart = (part: Part): part is TextPartLike => part.type === 'text' && typeof part.text === 'string';
|
||||
|
||||
export function getFullText(parts: Part[]): string {
|
||||
return parts
|
||||
.filter((p): p is Part & { type: 'text'; text: string } => p.type === 'text' && typeof p.text === 'string')
|
||||
.filter(isTextPart)
|
||||
.map((p) => p.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function getMessagePreview(parts: Part[], maxLength = 80): string {
|
||||
const full = getFullText(parts);
|
||||
const basename = (path: string): string => {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
return segments[segments.length - 1] ?? path;
|
||||
};
|
||||
|
||||
/** The caption a context attachment shows in the bubble, reused as a preview prefix. */
|
||||
const contextSummary = (payload: ContextPartPayload, t: Translate): string => {
|
||||
switch (payload.kind) {
|
||||
case 'code-comment': {
|
||||
const file = basename(payload.fileLabel);
|
||||
return payload.startLine === payload.endLine
|
||||
? t('chat.message.context.codeCommentLine', { file, line: payload.startLine })
|
||||
: t('chat.message.context.codeComment', { file, start: payload.startLine, end: payload.endLine });
|
||||
}
|
||||
case 'terminal':
|
||||
return t('chat.message.terminalContext', {
|
||||
terminal: payload.terminalLabel,
|
||||
start: payload.startLine,
|
||||
end: payload.endLine,
|
||||
});
|
||||
case 'browser-annotation':
|
||||
return t('chat.message.context.browserAnnotation', { page: payload.pageUrl });
|
||||
case 'pr-comment':
|
||||
return t('chat.message.context.prComment', { label: payload.label });
|
||||
case 'pr-check':
|
||||
return t('chat.message.context.prCheck', { label: payload.label });
|
||||
case 'file-quote': {
|
||||
const file = basename(payload.fileLabel);
|
||||
if (payload.startLine == null || payload.endLine == null) {
|
||||
return t('chat.message.context.fileQuote', { file });
|
||||
}
|
||||
return payload.startLine === payload.endLine
|
||||
? t('chat.message.context.codeCommentLine', { file, line: payload.startLine })
|
||||
: t('chat.message.context.codeComment', { file, start: payload.startLine, end: payload.endLine });
|
||||
}
|
||||
case 'chat-quote':
|
||||
return t('chat.message.context.chatQuote');
|
||||
case 'github-issue':
|
||||
return `#${payload.number} ${payload.title}`;
|
||||
case 'github-pr':
|
||||
return `#${payload.number} ${payload.title}`;
|
||||
case 'linear-issue':
|
||||
return `${payload.identifier} ${payload.title}`;
|
||||
}
|
||||
};
|
||||
|
||||
/** The quoted material behind a context attachment. */
|
||||
const contextBody = (payload: ContextPartPayload): string => {
|
||||
switch (payload.kind) {
|
||||
case 'code-comment':
|
||||
return payload.code;
|
||||
case 'terminal':
|
||||
return payload.output;
|
||||
case 'browser-annotation':
|
||||
return payload.prompt;
|
||||
case 'pr-comment':
|
||||
return payload.body;
|
||||
case 'pr-check':
|
||||
return payload.output;
|
||||
case 'file-quote':
|
||||
case 'chat-quote':
|
||||
return payload.quote;
|
||||
case 'github-issue':
|
||||
case 'github-pr':
|
||||
case 'linear-issue':
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* One preview line for a context attachment, mirroring the collapsed bubble:
|
||||
* the caption, then the user's comment when there is one, otherwise the quote.
|
||||
*/
|
||||
const contextPreview = (payload: ContextPartPayload, t: Translate): string => {
|
||||
const summary = contextSummary(payload, t);
|
||||
const comment = 'text' in payload ? payload.text.trim() : '';
|
||||
const detail = comment.length > 0 ? comment : contextBody(payload).trim();
|
||||
return detail.length > 0 ? `${summary}: ${detail}` : summary;
|
||||
};
|
||||
|
||||
/**
|
||||
* The text a user prompt shows in navigators: what the user typed, and — for
|
||||
* messages that are only attached context (a quoted message, a terminal
|
||||
* selection) — a label derived from that context, so such turns are never
|
||||
* label-less. Without a translator it falls back to the raw part text.
|
||||
*/
|
||||
export function getPromptPreviewText(parts: Part[], t?: Translate): string {
|
||||
const typed = parts
|
||||
.filter(isTextPart)
|
||||
.filter((p) => readContextPart(p) === null)
|
||||
.map((p) => p.text.trim())
|
||||
.filter((text) => text.length > 0);
|
||||
if (typed.length > 0) {
|
||||
return typed.join('\n');
|
||||
}
|
||||
|
||||
if (t) {
|
||||
const contextLines = parts
|
||||
.map((part) => readContextPart(part))
|
||||
.filter((payload): payload is ContextPartPayload => payload !== null)
|
||||
.map((payload) => contextPreview(payload, t))
|
||||
.filter((line) => line.length > 0);
|
||||
if (contextLines.length > 0) {
|
||||
return contextLines.join(' · ');
|
||||
}
|
||||
}
|
||||
|
||||
return getFullText(parts);
|
||||
}
|
||||
|
||||
export function getMessagePreview(parts: Part[], maxLength = 80, t?: Translate): string {
|
||||
const full = getPromptPreviewText(parts, t);
|
||||
const singleLine = full.replace(/\n/g, ' ');
|
||||
return singleLine.length > maxLength ? `${singleLine.slice(0, maxLength)}…` : singleLine;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getAnchoredTurnMetrics,
|
||||
getRowBottom,
|
||||
resolveChatListAnchoredEndSpace,
|
||||
resolveRealContentEndOffset,
|
||||
resolveTimelineIsAtEnd,
|
||||
type TimelineListMeasurementState,
|
||||
} from './timelineScrollAnchoring';
|
||||
@@ -183,6 +184,58 @@ describe('getAnchoredTurnMetrics', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRealContentEndOffset', () => {
|
||||
test('puts the last row bottom just above the composer overlay', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 1000],
|
||||
sizes: [1000, 200],
|
||||
scroll: 0,
|
||||
scrollLength: 700,
|
||||
});
|
||||
|
||||
expect(resolveRealContentEndOffset({ state, composerOverlayHeight: 180 })).toBe(680);
|
||||
});
|
||||
|
||||
test('ignores content length inflated by reserved end space or stale sizes', () => {
|
||||
// The list still reports a far larger content length than the measured
|
||||
// rows; the end offset must follow the rows, not that length.
|
||||
const state = buildState({
|
||||
positions: [0, 300],
|
||||
sizes: [300, 100],
|
||||
scroll: 900,
|
||||
scrollLength: 700,
|
||||
});
|
||||
|
||||
expect(resolveRealContentEndOffset({ state, composerOverlayHeight: 180 })).toBe(0);
|
||||
});
|
||||
|
||||
test('reserves extra slack below the content when asked', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 1000],
|
||||
sizes: [1000, 200],
|
||||
scrollLength: 700,
|
||||
});
|
||||
|
||||
expect(resolveRealContentEndOffset({
|
||||
state,
|
||||
composerOverlayHeight: 180,
|
||||
extraInset: CHAT_LIST_ANCHOR_OFFSET,
|
||||
})).toBe(696);
|
||||
});
|
||||
|
||||
test('returns null for an empty timeline and for unmeasured last rows', () => {
|
||||
expect(resolveRealContentEndOffset({
|
||||
state: buildState({ positions: [], sizes: [] }),
|
||||
composerOverlayHeight: 180,
|
||||
})).toBeNull();
|
||||
|
||||
expect(resolveRealContentEndOffset({
|
||||
state: buildState({ positions: [0, 100], sizes: [100] }),
|
||||
composerOverlayHeight: 180,
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTimelineIsAtEnd', () => {
|
||||
test('uses a tight distance band against the full content length', () => {
|
||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
|
||||
|
||||
@@ -108,6 +108,30 @@ export const getAnchoredTurnMetrics = ({
|
||||
};
|
||||
};
|
||||
|
||||
// The scroll offset that puts the LAST REAL ROW's bottom just above the
|
||||
// composer overlay. Distinct from the list's own end offset, which is derived
|
||||
// from the total content length: that length includes any reserved anchored
|
||||
// end space and, right after rows re-wrap on a width change, row sizes that
|
||||
// have not been re-measured yet. Scrolling to it then lands below the real
|
||||
// content and leaves a blank tail. `extraInset` reserves additional slack
|
||||
// below the content when a caller wants the row to sit clear of the edge.
|
||||
export const resolveRealContentEndOffset = ({
|
||||
state,
|
||||
composerOverlayHeight,
|
||||
extraInset = 0,
|
||||
}: {
|
||||
readonly state: TimelineListMeasurementState;
|
||||
readonly composerOverlayHeight: number;
|
||||
readonly extraInset?: number;
|
||||
}): number | null => {
|
||||
const lastIndex = state.data.length - 1;
|
||||
if (lastIndex < 0) return null;
|
||||
const lastBottom = getRowBottom(state, lastIndex);
|
||||
if (lastBottom === null) return null;
|
||||
const visibleLength = Math.max(0, state.scrollLength - composerOverlayHeight - extraInset);
|
||||
return Math.max(0, lastBottom - visibleLength);
|
||||
};
|
||||
|
||||
// "At the end" for follow purposes is a tight band, not the list's isNearEnd
|
||||
// (half a viewport): that band hid the scroll-to-bottom pill and re-armed
|
||||
// follow while the user had genuinely scrolled away, yanking them back on the
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { isFollowReleaseKey, isMiddleButtonPan, nestedScrollableConsumesWheelUp } from './timelineScrollIntent';
|
||||
|
||||
const key = (
|
||||
k: string,
|
||||
modifiers: Partial<Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>> = {},
|
||||
) => ({ key: k, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers });
|
||||
|
||||
describe('isFollowReleaseKey', () => {
|
||||
test('upward navigation keys release follow', () => {
|
||||
for (const k of ['ArrowUp', 'PageUp', 'Home']) expect(isFollowReleaseKey(key(k))).toBe(true);
|
||||
expect(isFollowReleaseKey(key(' ', { shiftKey: true }))).toBe(true);
|
||||
});
|
||||
|
||||
test('downward keys, plain space, and modified shortcuts do not', () => {
|
||||
for (const k of ['ArrowDown', 'PageDown', 'End', ' ', 'Pause', 'Enter']) {
|
||||
expect(isFollowReleaseKey(key(k))).toBe(false);
|
||||
}
|
||||
expect(isFollowReleaseKey(key('Home', { ctrlKey: true }))).toBe(false);
|
||||
expect(isFollowReleaseKey(key('ArrowUp', { metaKey: true }))).toBe(false);
|
||||
expect(isFollowReleaseKey(key('ArrowUp', { altKey: true }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The helpers only use Element#closest, scrollTop, and identity, so a minimal
|
||||
// DOM stand-in built on EventTarget is enough — no renderer or jsdom.
|
||||
class FakeElement extends EventTarget {
|
||||
scrollTop = 0;
|
||||
constructor(private readonly scrollable: boolean, private readonly parent: FakeElement | null = null) {
|
||||
super();
|
||||
}
|
||||
closest(selector: string): FakeElement | null {
|
||||
if (selector !== '[data-scrollable]') throw new Error(`unexpected selector ${selector}`);
|
||||
if (this.scrollable) return this;
|
||||
return this.parent?.closest(selector) ?? null;
|
||||
}
|
||||
}
|
||||
// SAFETY: the helpers narrow with `instanceof Element` / `instanceof HTMLElement`;
|
||||
// registering the fakes under those globals keeps the narrowing honest in bun.
|
||||
const installDomGlobals = () => {
|
||||
const previous = { Element: globalThis.Element, HTMLElement: globalThis.HTMLElement };
|
||||
Object.assign(globalThis, { Element: FakeElement, HTMLElement: FakeElement });
|
||||
return () => Object.assign(globalThis, previous);
|
||||
};
|
||||
// With the globals above installed, FakeElement IS the HTMLElement the helpers
|
||||
// narrow to; reading it back through the global bridges the static type without
|
||||
// asserting anything the runtime does not hold.
|
||||
const asRoot = (element: FakeElement): HTMLElement => {
|
||||
if (!(element instanceof globalThis.HTMLElement)) throw new Error('DOM globals not installed');
|
||||
return element;
|
||||
};
|
||||
|
||||
describe('nested scroller handling', () => {
|
||||
test('an upward wheel over a nested scroller with room above stays there', () => {
|
||||
const restore = installDomGlobals();
|
||||
try {
|
||||
const root = new FakeElement(false);
|
||||
const box = new FakeElement(true, root);
|
||||
const inner = new FakeElement(false, box);
|
||||
box.scrollTop = 40;
|
||||
expect(nestedScrollableConsumesWheelUp(asRoot(root), inner)).toBe(true);
|
||||
box.scrollTop = 0;
|
||||
expect(nestedScrollableConsumesWheelUp(asRoot(root), inner)).toBe(false);
|
||||
expect(nestedScrollableConsumesWheelUp(asRoot(root), new FakeElement(false, root))).toBe(false);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a middle-button press pans the timeline unless it lands in a nested scroller', () => {
|
||||
const restore = installDomGlobals();
|
||||
try {
|
||||
const root = new FakeElement(false);
|
||||
const row = new FakeElement(false, root);
|
||||
const box = new FakeElement(true, root);
|
||||
expect(isMiddleButtonPan(asRoot(root), { button: 1, target: row })).toBe(true);
|
||||
expect(isMiddleButtonPan(asRoot(root), { button: 1, target: box })).toBe(false);
|
||||
expect(isMiddleButtonPan(asRoot(root), { button: 0, target: row })).toBe(false);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// Gesture classification for the chat timeline's follow opt-out.
|
||||
//
|
||||
// The timeline releases live follow on REAL upward gestures only. Wheel and
|
||||
// touch carry their direction; this module answers the same question for the
|
||||
// inputs that do not: which keys mean "scroll up", when a middle-button press
|
||||
// starts a pan, and when an upward wheel belongs to a nested scroller (a tool
|
||||
// output box) that can still consume it. Pure functions, no DOM ownership,
|
||||
// so the rules are testable without a renderer.
|
||||
|
||||
// A nested scroller inside the timeline marks itself with this attribute
|
||||
// (see ToolPart). Wheel-up over it scrolls the box, not the conversation, for
|
||||
// as long as the box has room above.
|
||||
const NESTED_SCROLLABLE_SELECTOR = '[data-scrollable]';
|
||||
|
||||
export const isFollowReleaseKey = (
|
||||
event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>,
|
||||
): boolean => {
|
||||
// Modified keys are shortcuts, not navigation.
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return false;
|
||||
if (event.key === ' ') return event.shiftKey;
|
||||
return event.key === 'ArrowUp' || event.key === 'PageUp' || event.key === 'Home';
|
||||
};
|
||||
|
||||
const nestedScrollable = (root: HTMLElement, target: EventTarget | null): HTMLElement | null => {
|
||||
if (!(target instanceof Element)) return null;
|
||||
const nested = target.closest(NESTED_SCROLLABLE_SELECTOR);
|
||||
return nested instanceof HTMLElement && nested !== root ? nested : null;
|
||||
};
|
||||
|
||||
// An upward wheel over a nested scroller that still has content above stays
|
||||
// with that scroller; the timeline must not treat it as leaving the end.
|
||||
export const nestedScrollableConsumesWheelUp = (root: HTMLElement, target: EventTarget | null): boolean => {
|
||||
const nested = nestedScrollable(root, target);
|
||||
return nested !== null && nested.scrollTop > 0;
|
||||
};
|
||||
|
||||
// Middle-button press starts the platform's autoscroll pan (Windows/Linux
|
||||
// Chromium); the pan then scrolls without wheel events, so the press itself is
|
||||
// the gesture. Inside a nested scroller the pan belongs to that scroller.
|
||||
export const isMiddleButtonPan = (root: HTMLElement, event: Pick<MouseEvent, 'button' | 'target'>): boolean =>
|
||||
event.button === 1 && nestedScrollable(root, event.target) === null;
|
||||
@@ -97,6 +97,16 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
|
||||
input.assistantMessages.forEach((message) => {
|
||||
const finish = getMessageFinish(message);
|
||||
const messageHasTool = message.parts.some((part) => part.type === 'tool');
|
||||
// A turn blocked on a question never reaches finish === 'stop' (the
|
||||
// user must answer first). Treating the text the model produced
|
||||
// before the question as 'justification' would bury it inside the
|
||||
// collapsible Activity group — the context stays invisible until the
|
||||
// turn completes (OPE-199). Keep it inline like OpenCode.
|
||||
const messageHasQuestion = message.parts.some((part) => (
|
||||
part.type === 'tool'
|
||||
&& typeof part.tool === 'string'
|
||||
&& part.tool === 'question'
|
||||
));
|
||||
const messageIsCompactionSummary = isCompactionSummaryMessage(message);
|
||||
|
||||
message.parts.forEach((part, partIndex) => {
|
||||
@@ -137,6 +147,7 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
|
||||
input.showTextJustificationActivity
|
||||
&& part.type === 'text'
|
||||
&& text
|
||||
&& !messageHasQuestion
|
||||
&& (
|
||||
messageIsCompactionSummary
|
||||
|| (
|
||||
|
||||
@@ -221,4 +221,34 @@ describe('projectTurnRecords', () => {
|
||||
const finalActivity = turn?.activityParts.find((activity) => activity.messageId === 'a2');
|
||||
expect(finalActivity).toBe(undefined);
|
||||
});
|
||||
|
||||
test('keeps text inline (not justification) when a message is blocked on a pending question', () => {
|
||||
const user = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 });
|
||||
user.parts = [{ id: 'p1', type: 'text', text: 'prompt' } as Part];
|
||||
const assistant = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 });
|
||||
// The turn is blocked waiting for the user's answer: no finish and a
|
||||
// pending question tool part, with context text before the question.
|
||||
assistant.parts = [
|
||||
{ id: 'ap1', type: 'text', text: 'context before the question' } as Part,
|
||||
{
|
||||
id: 'ap2',
|
||||
type: 'tool',
|
||||
callID: 'c1',
|
||||
tool: 'question',
|
||||
state: { status: 'pending' },
|
||||
} as Part,
|
||||
];
|
||||
|
||||
const projection = projectTurnRecords([user, assistant], {
|
||||
showTextJustificationActivity: true,
|
||||
});
|
||||
|
||||
const turn = projection.turns[0];
|
||||
expect(turn).toBeDefined();
|
||||
const textActivity = turn?.activityParts.find((activity) => activity.partIndex === 0);
|
||||
expect(textActivity?.kind).not.toBe('justification');
|
||||
// The question tool itself still participates in the activity group.
|
||||
const questionActivity = turn?.activityParts.find((activity) => activity.partIndex === 1);
|
||||
expect(questionActivity?.kind).toBe('tool');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,9 +156,8 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
|
||||
row.setAttribute('data-md-code-line', '');
|
||||
|
||||
const number = document.createElement('span');
|
||||
number.setAttribute('data-md-code-line-number', '');
|
||||
number.setAttribute('data-md-code-line-number', String(index + 1));
|
||||
number.setAttribute('aria-hidden', 'true');
|
||||
number.textContent = String(index + 1);
|
||||
|
||||
const content = document.createElement('span');
|
||||
content.setAttribute('data-md-code-line-content', '');
|
||||
@@ -168,7 +167,6 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
|
||||
} else {
|
||||
content.textContent = sourceLine;
|
||||
}
|
||||
|
||||
row.append(number, content);
|
||||
fragment.appendChild(row);
|
||||
if (index < sourceLines.length - 1 || hasTrailingNewline) {
|
||||
@@ -543,6 +541,67 @@ const closeAllMenus = (container: HTMLElement): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const getContainingMarkdownCode = (node: Node): HTMLElement | null => {
|
||||
const element = node.nodeType === 1 ? node as Element : node.parentElement;
|
||||
return element?.closest<HTMLElement>('pre code[data-md-code-lines]') ?? null;
|
||||
};
|
||||
|
||||
const getMarkdownCodeSelectionText = (range: Range): string | null => {
|
||||
const code = getContainingMarkdownCode(range.startContainer);
|
||||
if (!code || code !== getContainingMarkdownCode(range.endContainer)) return null;
|
||||
// Line numbers are CSS-generated, so the DOM range is already the exact
|
||||
// source selection, including boundaries between rows and empty lines.
|
||||
return range.toString();
|
||||
};
|
||||
|
||||
type MarkdownCopyState = {
|
||||
registrations: number;
|
||||
handler: (event: ClipboardEvent) => void;
|
||||
menuHandler: (event: Event) => void;
|
||||
};
|
||||
|
||||
const markdownCopyStates = new WeakMap<Document, MarkdownCopyState>();
|
||||
|
||||
const registerMarkdownCodeCopy = (doc: Document): (() => void) => {
|
||||
let state = markdownCopyStates.get(doc);
|
||||
if (!state) {
|
||||
const getSelectedText = (): string | null => {
|
||||
const selection = doc.getSelection();
|
||||
if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null;
|
||||
return getMarkdownCodeSelectionText(selection.getRangeAt(0));
|
||||
};
|
||||
const handler = (event: ClipboardEvent) => {
|
||||
if (!event.clipboardData) return;
|
||||
const text = getSelectedText();
|
||||
if (text === null) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.clipboardData.setData('text/plain', text);
|
||||
};
|
||||
const menuHandler = (event: Event) => {
|
||||
const text = getSelectedText();
|
||||
if (text === null) return;
|
||||
event.preventDefault();
|
||||
void copyTextToClipboard(text);
|
||||
};
|
||||
state = { registrations: 0, handler, menuHandler };
|
||||
markdownCopyStates.set(doc, state);
|
||||
doc.addEventListener('copy', handler, true);
|
||||
doc.defaultView?.addEventListener('openchamber:copy', menuHandler);
|
||||
}
|
||||
state.registrations += 1;
|
||||
|
||||
return () => {
|
||||
const current = markdownCopyStates.get(doc);
|
||||
if (!current) return;
|
||||
current.registrations -= 1;
|
||||
if (current.registrations > 0) return;
|
||||
doc.removeEventListener('copy', current.handler, true);
|
||||
doc.defaultView?.removeEventListener('openchamber:copy', current.menuHandler);
|
||||
markdownCopyStates.delete(doc);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach a single delegated click listener for all in-markdown actions: code
|
||||
* copy, table copy/download menus, mermaid copy/download, loopback preview.
|
||||
@@ -552,6 +611,7 @@ export const attachMarkdownInteractions = (
|
||||
container: HTMLElement,
|
||||
ctx: DecorateContext,
|
||||
): (() => void) => {
|
||||
const unregisterCodeCopy = registerMarkdownCodeCopy(container.ownerDocument);
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
@@ -658,5 +718,8 @@ export const attachMarkdownInteractions = (
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
return () => container.removeEventListener('click', handleClick);
|
||||
return () => {
|
||||
unregisterCodeCopy();
|
||||
container.removeEventListener('click', handleClick);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { bundledLanguages, createHighlighter, type BundledLanguage, type ThemedToken } from 'shiki';
|
||||
import { bundledLanguages, createHighlighter, type BundledLanguage, type LanguageRegistration, type ThemedToken } from 'shiki';
|
||||
import { sanitizeTemplateCallGrammar } from '../../../lib/shiki/sanitizeTemplateCallGrammar';
|
||||
import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition';
|
||||
import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
|
||||
|
||||
@@ -60,11 +61,32 @@ self.onmessage = (event: MessageEvent<MarkdownWorkerRequest>) => {
|
||||
|
||||
type Instance = Awaited<ReturnType<typeof createHighlighter>>;
|
||||
|
||||
type BundledLanguageModule = { default: LanguageRegistration[] };
|
||||
|
||||
/**
|
||||
* Load a language, neutralizing the catastrophic JS/TS `template-call` rule
|
||||
* before it reaches the Oniguruma scanner (see sanitizeTemplateCallGrammar).
|
||||
*
|
||||
* Every bundled language is resolved and sanitized rather than a fixed id
|
||||
* list: Shiki keys the JS/TS grammars under aliases too (`js`, `ts`, `cjs`,
|
||||
* `mjs`, `mts`, `cts`), and embedding grammars (`vue`, `svelte`, `mdx`,
|
||||
* `astro`, `html`) ship them as extra entries in their own module. Sanitizing
|
||||
* every entry is free for the rest — `hasCatastrophicTemplateCall` returns the
|
||||
* grammar untouched when the rule is absent.
|
||||
*/
|
||||
const loadLanguageSafe = async (instance: Instance, lang: BundledLanguage): Promise<void> => {
|
||||
// SAFETY: every Shiki bundled-language module default-exports its grammar
|
||||
// array; `lang` is narrowed to a bundled id by the caller.
|
||||
const mod = (await bundledLanguages[lang]()) as BundledLanguageModule;
|
||||
const grammars = mod.default.map((grammar) => sanitizeTemplateCallGrammar(grammar));
|
||||
await instance.loadLanguage(...grammars);
|
||||
};
|
||||
|
||||
const resolveLanguage = async (instance: Instance, requested: string): Promise<string> => {
|
||||
let lang = requested in bundledLanguages ? requested : 'text';
|
||||
if (lang !== 'text' && !instance.getLoadedLanguages().includes(lang)) {
|
||||
try {
|
||||
await instance.loadLanguage(bundledLanguages[lang as BundledLanguage]);
|
||||
await loadLanguageSafe(instance, lang as BundledLanguage);
|
||||
} catch {
|
||||
lang = 'text';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Safety-net budget for a single Shiki worker tokenize request.
|
||||
* Healthy files finish well under this; catastrophic Oniguruma backtracking
|
||||
* must not run unbounded (openchamber/openchamber#2587).
|
||||
*/
|
||||
export const HIGHLIGHT_REQUEST_TIMEOUT_MS = 5_000;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import type { MarkdownWorkerRequest } from './markdown-worker-protocol';
|
||||
|
||||
/**
|
||||
* Hang safety for the markdown Shiki worker client
|
||||
* (openchamber/openchamber#2587, follow-up on #2618).
|
||||
*
|
||||
* Catastrophic Oniguruma backtracking is synchronous inside the worker, so the
|
||||
* only recovery is terminating it from this thread. Two properties matter and
|
||||
* neither is observable from the timeout constant alone: the hung request must
|
||||
* resolve `null` after the worker is terminated, and the block that caused it
|
||||
* must not be retried — a retry respawns a worker (Shiki + Oniguruma init) and
|
||||
* burns another full budget of a core on every render and every scroll past it.
|
||||
*/
|
||||
|
||||
const TEST_TIMEOUT_MS = 50;
|
||||
|
||||
mock.module('./markdown-shiki.worker.ts?worker&url', () => ({ default: 'blob:test-shiki-worker' }));
|
||||
mock.module('./markdown-worker-timeout', () => ({ HIGHLIGHT_REQUEST_TIMEOUT_MS: TEST_TIMEOUT_MS }));
|
||||
|
||||
/** A worker that accepts everything and answers nothing. */
|
||||
class SilentWorker {
|
||||
static created = 0;
|
||||
static terminated = 0;
|
||||
static messages: MarkdownWorkerRequest[] = [];
|
||||
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onmessageerror: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
SilentWorker.created += 1;
|
||||
}
|
||||
|
||||
postMessage(message: MarkdownWorkerRequest): void {
|
||||
SilentWorker.messages.push(message);
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
SilentWorker.terminated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* bun test has no `window` or `Worker`; defining the properties directly
|
||||
* installs the stubs without asserting they are the platform globals.
|
||||
* `SilentWorker` implements exactly the members `markdown-worker` uses:
|
||||
* postMessage, terminate, and the three handler slots.
|
||||
*/
|
||||
const installWorkerStub = (): void => {
|
||||
Object.defineProperty(globalThis, 'window', { value: {}, configurable: true, writable: true });
|
||||
Object.defineProperty(globalThis, 'Worker', { value: SilentWorker, configurable: true, writable: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Silent on the first instance, answering on every later one — so a request
|
||||
* that was only queued behind the hung one can be observed being replayed
|
||||
* against the replacement worker.
|
||||
*/
|
||||
class ReplayWorker {
|
||||
static created = 0;
|
||||
static terminated = 0;
|
||||
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onmessageerror: (() => void) | null = null;
|
||||
|
||||
private readonly answers: boolean;
|
||||
|
||||
constructor() {
|
||||
ReplayWorker.created += 1;
|
||||
this.answers = ReplayWorker.created > 1;
|
||||
}
|
||||
|
||||
postMessage(message: MarkdownWorkerRequest): void {
|
||||
if (!this.answers || message.type !== 'highlight') return;
|
||||
setTimeout(() => {
|
||||
this.onmessage?.(new MessageEvent('message', {
|
||||
data: { type: 'highlight', id: message.id, html: '<pre>ok</pre>' },
|
||||
}));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
ReplayWorker.terminated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Same reasoning as installWorkerStub. */
|
||||
const installReplayWorkerStub = (): void => {
|
||||
Object.defineProperty(globalThis, 'window', { value: {}, configurable: true, writable: true });
|
||||
Object.defineProperty(globalThis, 'Worker', { value: ReplayWorker, configurable: true, writable: true });
|
||||
};
|
||||
|
||||
describe('markdown-worker hang safety', () => {
|
||||
test('a hung block resolves null, terminates the worker, and is not retried', async () => {
|
||||
installWorkerStub();
|
||||
const { highlightCodeInWorker, resetMarkdownWorkerClientCacheForTests } = await import('./markdown-worker');
|
||||
resetMarkdownWorkerClientCacheForTests();
|
||||
|
||||
const code = 'const label = `Account ${index + 1}`;';
|
||||
|
||||
const first = await highlightCodeInWorker(code, 'javascript');
|
||||
expect(first).toBeNull();
|
||||
expect(SilentWorker.terminated).toBe(1);
|
||||
|
||||
const createdAfterFirst = SilentWorker.created;
|
||||
const messagesAfterFirst = SilentWorker.messages.length;
|
||||
|
||||
// Same content again: the timed-out key is memoized as failed, so nothing
|
||||
// reaches a worker and none is spawned.
|
||||
const second = await highlightCodeInWorker(code, 'javascript');
|
||||
expect(second).toBeNull();
|
||||
expect(SilentWorker.created).toBe(createdAfterFirst);
|
||||
expect(SilentWorker.messages.length).toBe(messagesAfterFirst);
|
||||
});
|
||||
|
||||
test('a timeout fails only the offending request and replays the queued one', async () => {
|
||||
installReplayWorkerStub();
|
||||
const { highlightCodeInWorker, resetMarkdownWorkerClientCacheForTests } = await import('./markdown-worker');
|
||||
resetMarkdownWorkerClientCacheForTests();
|
||||
|
||||
const results = await Promise.all([
|
||||
highlightCodeInWorker('const a = `one`;', 'javascript'),
|
||||
highlightCodeInWorker('const b = `two`;', 'javascript'),
|
||||
]);
|
||||
|
||||
// Whichever request owns the first timer is the offender; the other was
|
||||
// merely queued behind it and must survive on the replacement worker
|
||||
// rather than being cancelled with it.
|
||||
expect(results.filter((value) => value === null)).toHaveLength(1);
|
||||
expect(results.filter((value) => value === '<pre>ok</pre>')).toHaveLength(1);
|
||||
expect(ReplayWorker.terminated).toBe(1);
|
||||
expect(ReplayWorker.created).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,26 @@ import {
|
||||
utf16Bytes,
|
||||
} from './highlightResultCache';
|
||||
import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
|
||||
import { HIGHLIGHT_REQUEST_TIMEOUT_MS } from './markdown-worker-timeout';
|
||||
|
||||
// Main-thread client for the markdown Shiki worker. Moves syntax tokenization
|
||||
// Main-thread client for the markdown Shiki Web Worker. Moves syntax tokenization
|
||||
// off the UI thread: a closed code block is shipped to the worker, which returns
|
||||
// ready-to-splice Shiki HTML. On any failure (no worker support, worker crash,
|
||||
// tokenization error) the promise resolves to `null` and the caller keeps the
|
||||
// escaped plain-text code — highlighting never falls back onto the main thread.
|
||||
// tokenization error, or hang timeout) the promise resolves to `null` and the
|
||||
// caller keeps the escaped plain-text code — highlighting never falls back onto
|
||||
// the main thread.
|
||||
//
|
||||
// The per-request timeout exists because TextMate grammars can enter catastrophic
|
||||
// backtracking on the Oniguruma WASM engine (openchamber/openchamber#2587).
|
||||
// Matching is synchronous inside the worker, so the only way to reclaim its heap
|
||||
// is to terminate it from this thread once a request exceeds the budget.
|
||||
//
|
||||
// A timeout is scoped to the block that caused it: only that request resolves
|
||||
// `null`, and the requests that were merely queued behind it are re-dispatched
|
||||
// against the fresh worker. The timed-out key is memoized as failed, because a
|
||||
// block that hangs the grammar hangs it every time — without that, every
|
||||
// re-render and every scroll past the block would pay another worker spawn
|
||||
// (Shiki + Oniguruma init) plus the full timeout budget of a core.
|
||||
//
|
||||
// Results are memoized by content fingerprint (+ lang / theme). Unchanged
|
||||
// content must not re-enter the worker — that was the sustained ~40 msg/s
|
||||
@@ -29,12 +43,30 @@ import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse }
|
||||
// repaints via CSS and must not invalidate these entries. Only
|
||||
// `highlightTokens` resolves concrete colors, so only its key carries a theme.
|
||||
|
||||
type PendingResolver = (response: MarkdownWorkerResponse | null) => void;
|
||||
/**
|
||||
* Why a request stopped, kept distinct so a hang can be memoized while a
|
||||
* transient "no worker yet" failure is retried on the next render.
|
||||
*/
|
||||
type RequestOutcome =
|
||||
| { status: 'ok'; response: MarkdownWorkerResponse }
|
||||
| { status: 'failed' }
|
||||
| { status: 'timeout' };
|
||||
|
||||
type PendingResolver = (outcome: RequestOutcome) => void;
|
||||
|
||||
type PendingEntry = {
|
||||
resolve: PendingResolver;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
payload: MarkdownWorkerRequest;
|
||||
};
|
||||
|
||||
type CachedHighlight =
|
||||
| { type: 'highlight'; html: string }
|
||||
| { type: 'highlightLines'; lines: string[] }
|
||||
| { type: 'highlightTokens'; lines: MarkdownTokenRun[][] };
|
||||
| { type: 'highlightTokens'; lines: MarkdownTokenRun[][] }
|
||||
// A block that timed out the worker. Memoized so it is attempted once per
|
||||
// session instead of respawning a worker on every render.
|
||||
| { type: 'failed' };
|
||||
|
||||
const CLIENT_CACHE_MAX_ENTRIES = 2000;
|
||||
const CLIENT_CACHE_MAX_BYTES = 24 * 1024 * 1024;
|
||||
@@ -50,13 +82,18 @@ let worker: Worker | undefined;
|
||||
let workerCreation: Promise<Worker | undefined> | undefined;
|
||||
let workerObjectUrl: string | undefined;
|
||||
let nextId = 0;
|
||||
const pending = new Map<number, PendingResolver>();
|
||||
const pending = new Map<number, PendingEntry>();
|
||||
// Theme names whose full definition we've already shipped to the live worker, so
|
||||
// repeat tokenization sends only the name (not the whole theme object) again.
|
||||
const sentThemes = new Set<string>();
|
||||
|
||||
const clearPendingTimers = (): void => {
|
||||
pending.forEach((entry) => clearTimeout(entry.timer));
|
||||
};
|
||||
|
||||
const entryBytes = (key: string, value: CachedHighlight): number => {
|
||||
const keyBytes = utf16Bytes(key);
|
||||
if (value.type === 'failed') return keyBytes;
|
||||
if (value.type === 'highlight') return keyBytes + utf16Bytes(value.html);
|
||||
if (value.type === 'highlightLines') {
|
||||
let total = keyBytes;
|
||||
@@ -66,12 +103,8 @@ const entryBytes = (key: string, value: CachedHighlight): number => {
|
||||
return keyBytes + estimateTokenRunsBytes(value.lines);
|
||||
};
|
||||
|
||||
const failAll = (): void => {
|
||||
pending.forEach((resolve) => resolve(null));
|
||||
pending.clear();
|
||||
const disposeWorker = (): void => {
|
||||
sentThemes.clear();
|
||||
// Drop in-flight waiters; cached results remain valid (pure fn of inputs).
|
||||
inflight.clear();
|
||||
worker?.terminate();
|
||||
worker = undefined;
|
||||
workerCreation = undefined;
|
||||
@@ -81,6 +114,16 @@ const failAll = (): void => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Worker crash / message error: nothing in flight can still be answered. */
|
||||
const failAll = (): void => {
|
||||
clearPendingTimers();
|
||||
pending.forEach((entry) => entry.resolve({ status: 'failed' }));
|
||||
pending.clear();
|
||||
// Drop in-flight waiters; cached results remain valid (pure fn of inputs).
|
||||
inflight.clear();
|
||||
disposeWorker();
|
||||
};
|
||||
|
||||
const createWorker = async (): Promise<Worker | undefined> => {
|
||||
if (typeof window === 'undefined' || typeof Worker === 'undefined') return undefined;
|
||||
try {
|
||||
@@ -95,10 +138,11 @@ const createWorker = async (): Promise<Worker | undefined> => {
|
||||
const instance = new Worker(workerUrl, { type: 'module' });
|
||||
worker = instance;
|
||||
instance.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
|
||||
const resolve = pending.get(event.data.id);
|
||||
if (!resolve) return;
|
||||
const entry = pending.get(event.data.id);
|
||||
if (!entry) return;
|
||||
clearTimeout(entry.timer);
|
||||
pending.delete(event.data.id);
|
||||
resolve(event.data);
|
||||
entry.resolve({ status: 'ok', response: event.data });
|
||||
};
|
||||
instance.onerror = failAll;
|
||||
instance.onmessageerror = failAll;
|
||||
@@ -122,13 +166,56 @@ const getWorker = async (): Promise<Worker | undefined> => {
|
||||
return workerCreation;
|
||||
};
|
||||
|
||||
const request = async (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => {
|
||||
/**
|
||||
* Post one request and arm its timeout. The timer starts here, not when the
|
||||
* caller enqueued, so a request re-dispatched after someone else's hang gets a
|
||||
* whole budget on the fresh worker rather than an already-spent one.
|
||||
*/
|
||||
const dispatch = async (id: number, resolve: PendingResolver, payload: MarkdownWorkerRequest): Promise<void> => {
|
||||
const instance = await getWorker();
|
||||
if (!instance) return Promise.resolve(null);
|
||||
if (!instance) {
|
||||
resolve({ status: 'failed' });
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => handleTimeout(id), HIGHLIGHT_REQUEST_TIMEOUT_MS);
|
||||
pending.set(id, { resolve, timer, payload });
|
||||
instance.postMessage(payload);
|
||||
};
|
||||
|
||||
/**
|
||||
* One request exceeded the budget. Kill the worker so the WASM heap is freed
|
||||
* instead of growing until the renderer OOMs, fail only the offending request,
|
||||
* and replay the requests that were only waiting behind it.
|
||||
*/
|
||||
function handleTimeout(id: number): void {
|
||||
const offender = pending.get(id);
|
||||
if (!offender) return;
|
||||
console.warn(`Shiki worker highlight timed out after ${HIGHLIGHT_REQUEST_TIMEOUT_MS}ms; terminating worker`);
|
||||
|
||||
const survivors = Array.from(pending.entries()).filter(([pendingId]) => pendingId !== id);
|
||||
clearPendingTimers();
|
||||
pending.clear();
|
||||
disposeWorker();
|
||||
|
||||
offender.resolve({ status: 'timeout' });
|
||||
|
||||
for (const [survivorId, entry] of survivors) {
|
||||
// A `highlightTokens` payload whose theme was already shipped to the dead
|
||||
// worker cannot be replayed — the definition went with it, and the fresh
|
||||
// worker would reject the bare theme name.
|
||||
if (entry.payload.type === 'highlightTokens' && entry.payload.theme === undefined) {
|
||||
entry.resolve({ status: 'failed' });
|
||||
continue;
|
||||
}
|
||||
void dispatch(survivorId, entry.resolve, entry.payload);
|
||||
}
|
||||
}
|
||||
|
||||
const request = async (payload: (id: number) => MarkdownWorkerRequest): Promise<RequestOutcome> => {
|
||||
const id = ++nextId;
|
||||
return new Promise<MarkdownWorkerResponse | null>((resolve) => {
|
||||
pending.set(id, resolve);
|
||||
instance.postMessage(payload(id));
|
||||
const message = payload(id);
|
||||
return new Promise<RequestOutcome>((resolve) => {
|
||||
void dispatch(id, resolve, message);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -145,6 +232,12 @@ const coalesce = (
|
||||
return pendingRequest;
|
||||
};
|
||||
|
||||
const memoizeFailure = (key: string): CachedHighlight => {
|
||||
const entry: CachedHighlight = { type: 'failed' };
|
||||
resultCache.set(key, entry, entryBytes(key, entry));
|
||||
return entry;
|
||||
};
|
||||
|
||||
const cacheKeyFor = (kind: string, lang: string, code: string, themeName?: string): string => {
|
||||
const fp = contentFingerprint(code);
|
||||
return themeName === undefined ? `${kind}:${lang}:${fp}` : `${kind}:${themeName}:${lang}:${fp}`;
|
||||
@@ -164,11 +257,13 @@ export const highlightCodeInWorker = async (code: string, lang: string): Promise
|
||||
const key = cacheKeyFor('highlight', lang, code);
|
||||
const cached = resultCache.get(key);
|
||||
if (cached?.type === 'highlight') return cached.html;
|
||||
if (cached?.type === 'failed') return null;
|
||||
|
||||
const result = await coalesce(key, async () => {
|
||||
const response = await request((id) => ({ type: 'highlight', id, code, lang }));
|
||||
if (response?.type !== 'highlight') return null;
|
||||
const entry: CachedHighlight = { type: 'highlight', html: response.html };
|
||||
const outcome = await request((id) => ({ type: 'highlight', id, code, lang }));
|
||||
if (outcome.status === 'timeout') return memoizeFailure(key);
|
||||
if (outcome.status !== 'ok' || outcome.response.type !== 'highlight') return null;
|
||||
const entry: CachedHighlight = { type: 'highlight', html: outcome.response.html };
|
||||
resultCache.set(key, entry, entryBytes(key, entry));
|
||||
return entry;
|
||||
});
|
||||
@@ -184,11 +279,13 @@ export const highlightLinesInWorker = async (code: string, lang: string): Promis
|
||||
const key = cacheKeyFor('highlightLines', lang, code);
|
||||
const cached = resultCache.get(key);
|
||||
if (cached?.type === 'highlightLines') return cached.lines;
|
||||
if (cached?.type === 'failed') return null;
|
||||
|
||||
const result = await coalesce(key, async () => {
|
||||
const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
|
||||
if (response?.type !== 'highlightLines') return null;
|
||||
const entry: CachedHighlight = { type: 'highlightLines', lines: response.lines };
|
||||
const outcome = await request((id) => ({ type: 'highlightLines', id, code, lang }));
|
||||
if (outcome.status === 'timeout') return memoizeFailure(key);
|
||||
if (outcome.status !== 'ok' || outcome.response.type !== 'highlightLines') return null;
|
||||
const entry: CachedHighlight = { type: 'highlightLines', lines: outcome.response.lines };
|
||||
resultCache.set(key, entry, entryBytes(key, entry));
|
||||
return entry;
|
||||
});
|
||||
@@ -216,10 +313,11 @@ export const highlightTokensInWorker = async (
|
||||
const key = cacheKeyFor('highlightTokens', lang, code, themeName);
|
||||
const cached = resultCache.get(key);
|
||||
if (cached?.type === 'highlightTokens') return cached.lines;
|
||||
if (cached?.type === 'failed') return null;
|
||||
|
||||
const result = await coalesce(key, async () => {
|
||||
const needsTheme = !sentThemes.has(themeName);
|
||||
const response = await request((id) => ({
|
||||
const outcome = await request((id) => ({
|
||||
type: 'highlightTokens',
|
||||
id,
|
||||
code,
|
||||
@@ -227,9 +325,10 @@ export const highlightTokensInWorker = async (
|
||||
themeName,
|
||||
...(needsTheme ? { theme } : {}),
|
||||
}));
|
||||
if (response?.type !== 'highlightTokens') return null;
|
||||
if (outcome.status === 'timeout') return memoizeFailure(key);
|
||||
if (outcome.status !== 'ok' || outcome.response.type !== 'highlightTokens') return null;
|
||||
sentThemes.add(themeName);
|
||||
const entry: CachedHighlight = { type: 'highlightTokens', lines: response.lines };
|
||||
const entry: CachedHighlight = { type: 'highlightTokens', lines: outcome.response.lines };
|
||||
resultCache.set(key, entry, entryBytes(key, entry));
|
||||
return entry;
|
||||
});
|
||||
|
||||
@@ -19,6 +19,15 @@ const sanitizeHooks: {
|
||||
afterSanitizeAttributes?: (node: unknown) => void;
|
||||
} = {};
|
||||
|
||||
// Mirrors DOMPurify's default URI policy: approved schemes plus relative URLs.
|
||||
const DOMPURIFY_ALLOWED_URI_RE =
|
||||
// Keep this byte-aligned with DOMPurify's default IS_ALLOWED_URI expression.
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
|
||||
const URI_ATTRIBUTE_WHITESPACE_RE =
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g;
|
||||
|
||||
Object.assign(globalThis, {
|
||||
window: {},
|
||||
HTMLAnchorElement: TestAnchorElement,
|
||||
@@ -36,7 +45,10 @@ mock.module('dompurify', () => ({
|
||||
sanitizeHooks.uponSanitizeAttribute?.(anchor, data);
|
||||
sanitizeHooks.afterSanitizeAttributes?.(anchor);
|
||||
|
||||
return data.forceKeepAttr || /^(?:https?|mailto|tel):/i.test(href) ? attribute : '';
|
||||
const normalizedHref = href.replace(URI_ATTRIBUTE_WHITESPACE_RE, '');
|
||||
return data.forceKeepAttr || DOMPURIFY_ALLOWED_URI_RE.test(normalizedHref)
|
||||
? attribute
|
||||
: '';
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -279,3 +291,68 @@ describe('Markdown images', () => {
|
||||
expect(html).not.toContain('data-openchamber-markdown-image');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CJK-aware link parsing', () => {
|
||||
const hrefOf = (html: string): string | null => /<a\b[^>]*href="([^"]*)"/.exec(html)?.[1] ?? null;
|
||||
|
||||
test('bare URL followed by a CJK annotation trims the annotation from the href', () => {
|
||||
const html = renderMarkdownSync('访问 https://example.com/docs(中文说明)了解更多');
|
||||
expect(hrefOf(html)).toBe('https://example.com/docs');
|
||||
});
|
||||
|
||||
test('bare URL followed by CJK punctuation trims the punctuation', () => {
|
||||
expect(hrefOf(renderMarkdownSync('地址 https://example.com/guide,详见'))).toBe(
|
||||
'https://example.com/guide',
|
||||
);
|
||||
expect(hrefOf(renderMarkdownSync('官网 https://example.com。'))).toBe('https://example.com');
|
||||
});
|
||||
|
||||
test('correct links are unaffected', () => {
|
||||
expect(hrefOf(renderMarkdownSync('官方文档见 [这里](https://docs.example.com)(中文说明)'))).toBe(
|
||||
'https://docs.example.com',
|
||||
);
|
||||
expect(hrefOf(renderMarkdownSync('[下载](https://dl.example.com/安装包(正式版))'))).toBe(
|
||||
'https://dl.example.com/安装包(正式版)',
|
||||
);
|
||||
expect(hrefOf(renderMarkdownSync('[a](url(1))'))).toBe('url(1)');
|
||||
expect(hrefOf(renderMarkdownSync('[a](url "title")'))).toBe('url');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Escaped brackets versus display math', () => {
|
||||
// `\[...\]` is display math in LaTeX and an escaped bracket pair in
|
||||
// CommonMark. Prose escapes brackets far more often than it opens display
|
||||
// math mid-sentence, so math only wins when it owns its line.
|
||||
test('keeps escaped brackets inside a link as link text', () => {
|
||||
const html = renderMarkdownSync(
|
||||
'[OpenChamber session completed: OPE-316 \\[Bug\\] Opening files](https://example.com/?session=ses_1)',
|
||||
);
|
||||
expect(html).toContain('href="https://example.com/?session=ses_1"');
|
||||
expect(html).toContain('[Bug]');
|
||||
expect(html).not.toContain('katex');
|
||||
});
|
||||
|
||||
test('leaves escaped brackets in prose as literal brackets', () => {
|
||||
const html = renderMarkdownSync('Release \\[Bug\\] fixed in v2.');
|
||||
expect(html).toContain('[Bug]');
|
||||
expect(html).not.toContain('katex');
|
||||
});
|
||||
|
||||
// Verbatim body of a Linear status comment, which Linear itself renders as
|
||||
// one link while we used to split it into three blocks.
|
||||
test('renders a Linear comment with an escaped-bracket title as one link', () => {
|
||||
const html = renderMarkdownSync(
|
||||
'[OpenChamber session completed: OPE-316 \\[Bug\\] Opening files with template-literal'
|
||||
+ ' code triggers catastrophic backtracking → renderer OOM → black/frozen desktop app'
|
||||
+ ' (v1.17.2)](http://127.0.0.1:63418/?session=ses_fb0bb916effe26bQ1Ofr6Rv4Ei)',
|
||||
);
|
||||
expect(html.match(/<a /g)).toHaveLength(1);
|
||||
expect(html).toContain('[Bug]');
|
||||
expect(html).not.toContain('katex');
|
||||
});
|
||||
|
||||
test('still renders display math that owns its line', () => {
|
||||
expect(renderMarkdownSync('\\[x = y\\]')).toContain('katex');
|
||||
expect(renderMarkdownSync('Before\n\n\\[\nx = y\n\\]\n\nAfter')).toContain('katex');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Marked, marked, type Tokens } from 'marked';
|
||||
import markedLinkifyIt from 'marked-linkify-it';
|
||||
import remend from 'remend';
|
||||
import katex from 'katex';
|
||||
import DOMPurify from 'dompurify';
|
||||
@@ -313,15 +314,25 @@ const inlineMathExtension = {
|
||||
},
|
||||
};
|
||||
|
||||
// `\[` is display math in LaTeX, but it is also CommonMark's escape for a
|
||||
// literal `[`, and prose escapes brackets far more often than it opens display
|
||||
// math. Reading every `\[` as math turned text like
|
||||
// `[title \[Bug\] more](url)` into a KaTeX block that split the paragraph and
|
||||
// tore the link apart. Display math therefore has to own its line: it must
|
||||
// start one and its `\]` must end one. Anything mid-sentence stays an escape.
|
||||
const BLOCK_MATH_RE = /^[ \t]*\\\[([\s\S]+?)\\\][ \t]*(?:\n|$)/;
|
||||
const BLOCK_MATH_LINE_START_RE = /(?:^|\n)[ \t]*\\\[/;
|
||||
|
||||
const blockMathExtension = {
|
||||
name: 'blockMath',
|
||||
level: 'block' as const,
|
||||
start(src: string) {
|
||||
const index = src.indexOf('\\[');
|
||||
return index < 0 ? undefined : index;
|
||||
const match = BLOCK_MATH_LINE_START_RE.exec(src);
|
||||
// Point marked at the `\[` itself, never at the newline before it.
|
||||
return match ? match.index + match[0].length - 2 : undefined;
|
||||
},
|
||||
tokenizer(src: string): MathToken | undefined {
|
||||
const match = /^\\\[([\s\S]+?)\\\]/.exec(src);
|
||||
const match = BLOCK_MATH_RE.exec(src);
|
||||
if (!match) return undefined;
|
||||
return { type: 'blockMath', raw: match[0], text: match[1] ?? '' };
|
||||
},
|
||||
@@ -331,10 +342,15 @@ const blockMathExtension = {
|
||||
},
|
||||
};
|
||||
|
||||
const createParser = (imageMode: MarkdownImageMode) => new Marked().use({
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
extensions: [inlineMathExtension, blockMathExtension],
|
||||
// marked's GFM autolink swallows CJK punctuation after a bare URL, so switch
|
||||
// to marked-linkify-it, which treats Unicode punctuation as a URL boundary.
|
||||
// Plain CJK characters right after a URL are still consumed, matching GitHub.
|
||||
const createParser = (imageMode: MarkdownImageMode) => new Marked().use(
|
||||
markedLinkifyIt({ fuzzyLink: false }),
|
||||
{
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
extensions: [inlineMathExtension, blockMathExtension],
|
||||
renderer: {
|
||||
// Assistant output is untrusted. Markdown constructs still render as HTML,
|
||||
// but raw HTML must remain visible text so it cannot introduce active DOM
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
let markdownRendererModulePromise: Promise<typeof import('./MarkdownRendererImpl')> | null = null;
|
||||
type MarkdownRendererModule = typeof import('./MarkdownRendererImpl');
|
||||
|
||||
let markdownRendererModulePromise: Promise<MarkdownRendererModule> | null = null;
|
||||
let markdownRendererModule: MarkdownRendererModule | null = null;
|
||||
|
||||
export const loadMarkdownRendererModule = () => {
|
||||
markdownRendererModulePromise ??= import('./MarkdownRendererImpl').catch((error) => {
|
||||
markdownRendererModulePromise = null;
|
||||
throw error;
|
||||
});
|
||||
markdownRendererModulePromise ??= import('./MarkdownRendererImpl')
|
||||
.then((module) => {
|
||||
markdownRendererModule = module;
|
||||
return module;
|
||||
})
|
||||
.catch((error) => {
|
||||
markdownRendererModulePromise = null;
|
||||
throw error;
|
||||
});
|
||||
return markdownRendererModulePromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* The module once it has loaded, so a renderer can mount synchronously instead
|
||||
* of suspending. A lazy component that suspends — even on an already-resolved
|
||||
* promise — shows its fallback for a tick, and React then throttles the reveal
|
||||
* of every boundary that resolves in the following ~300ms, which is how a
|
||||
* freshly opened session showed user text first and assistant text a third of
|
||||
* a second later.
|
||||
*/
|
||||
export const getLoadedMarkdownRendererModule = () => markdownRendererModule;
|
||||
|
||||
export const preloadMarkdownRenderer = () => {
|
||||
void loadMarkdownRendererModule().catch(() => undefined);
|
||||
};
|
||||
|
||||
@@ -1343,16 +1343,6 @@ const AssistantMessageBody = React.memo(({
|
||||
return resolved ? { id: resolved.id, path: resolved.path } : null;
|
||||
}, [availableWorktreesByProject, canUseProjectPlanActions, currentSessionId, effectiveDirectory, getDirectoryForSession, projects]);
|
||||
|
||||
const hasTools = toolParts.length > 0;
|
||||
|
||||
const hasPendingTools = React.useMemo(() => {
|
||||
return toolParts.some((toolPart) => {
|
||||
const state = (toolPart as Record<string, unknown>).state as Record<string, unknown> | undefined ?? {};
|
||||
const status = state?.status;
|
||||
return status === 'pending' || status === 'running' || status === 'started';
|
||||
});
|
||||
}, [toolParts]);
|
||||
|
||||
const isActiveTool = React.useCallback((toolPart: ToolPartType): boolean => {
|
||||
const state = (toolPart as Record<string, unknown>).state as Record<string, unknown> | undefined ?? {};
|
||||
const status = state?.status;
|
||||
@@ -1381,42 +1371,6 @@ const AssistantMessageBody = React.memo(({
|
||||
return isActiveTool(toolPart) || isToolFinalized(toolPart);
|
||||
}, [isActiveTool, isToolFinalized]);
|
||||
|
||||
const allToolsFinalized = React.useMemo(() => {
|
||||
if (toolParts.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (hasPendingTools) {
|
||||
return false;
|
||||
}
|
||||
return toolParts.every((toolPart) => isToolFinalized(toolPart));
|
||||
}, [toolParts, hasPendingTools, isToolFinalized]);
|
||||
|
||||
const reasoningParts = React.useMemo(() => {
|
||||
return visibleParts.filter((part) => part.type === 'reasoning');
|
||||
}, [visibleParts]);
|
||||
|
||||
const reasoningComplete = React.useMemo(() => {
|
||||
if (reasoningParts.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return reasoningParts.every((part) => {
|
||||
const time = (part as Record<string, unknown>).time as { end?: number } | undefined;
|
||||
return typeof time?.end === 'number';
|
||||
});
|
||||
}, [reasoningParts]);
|
||||
|
||||
// Message is considered to have an "open step" if info.finish is not yet present
|
||||
const hasOpenStep = typeof messageFinish !== 'string';
|
||||
|
||||
const shouldHoldForReasoning =
|
||||
reasoningParts.length > 0 &&
|
||||
hasTools &&
|
||||
(hasPendingTools || hasOpenStep || !allToolsFinalized);
|
||||
|
||||
const shouldHoldTools = awaitingMessageCompletion
|
||||
|| (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized));
|
||||
const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning;
|
||||
|
||||
const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion;
|
||||
|
||||
const handleForkClick = React.useCallback(
|
||||
@@ -1676,7 +1630,16 @@ const AssistantMessageBody = React.memo(({
|
||||
&& hasAnchoredActivitySegments
|
||||
&& Boolean(toggleActivityGroup);
|
||||
|
||||
const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish;
|
||||
// A message that asked a question is blocked until the user answers — it
|
||||
// never reaches finish === 'stop', so the normal "defer text until final
|
||||
// output" rule would hide the context the model produced before the
|
||||
// question indefinitely (OPE-199). Render such messages' text inline,
|
||||
// matching OpenCode's display.
|
||||
const hasQuestionTool = React.useMemo(() => {
|
||||
return toolParts.some((toolPart) => toolPart.tool === 'question');
|
||||
}, [toolParts]);
|
||||
|
||||
const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish && !hasQuestionTool;
|
||||
const showErrorMessage = Boolean(errorMessage);
|
||||
const isPeekSurface = chatSurfaceMode === 'peek';
|
||||
const shouldShowMessageActions = hasCopyableText && !isPeekSurface;
|
||||
|
||||
@@ -18,7 +18,14 @@ import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown';
|
||||
import { focusChatInput } from '@/components/chat/composer/editor/dom';
|
||||
import { registerActiveSelectionToolbar } from '@/lib/addSelectionToChat';
|
||||
import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects';
|
||||
import {
|
||||
DESKTOP_MENU_FALLBACK_HEIGHT_PX,
|
||||
DESKTOP_MENU_FALLBACK_WIDTH_PX,
|
||||
getDesktopClampedX,
|
||||
getDesktopClampedY,
|
||||
} from './selectionMenuPosition';
|
||||
|
||||
interface TextSelectionMenuProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
@@ -42,8 +49,6 @@ const normalizeDistilledInsight = (insight: string): string => (
|
||||
insight.trim().replace(/^[-*+]\s+/, '').slice(0, PROJECT_NOTE_BODY_MAX_LENGTH)
|
||||
);
|
||||
|
||||
const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
|
||||
const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
|
||||
export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => {
|
||||
const { t } = useI18n();
|
||||
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
|
||||
@@ -102,11 +107,12 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const [isAddingToNotes, setIsAddingToNotes] = React.useState(false);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const menuWidthRef = React.useRef(DESKTOP_MENU_FALLBACK_WIDTH_PX);
|
||||
const menuHeightRef = React.useRef(DESKTOP_MENU_FALLBACK_HEIGHT_PX);
|
||||
const pendingSelectionRef = React.useRef<SelectionPayload | null>(null);
|
||||
const openRafRef = React.useRef<number | null>(null);
|
||||
const mouseUpTimeoutRef = React.useRef<number | null>(null);
|
||||
const isMenuVisibleRef = React.useRef(false);
|
||||
const createSession = useSessionUIStore((state) => state.createSession);
|
||||
const activeAddToChatCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
|
||||
const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
||||
@@ -156,6 +162,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
activeAddToChatCleanupRef.current?.();
|
||||
activeAddToChatCleanupRef.current = null;
|
||||
if (openRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(openRafRef.current);
|
||||
openRafRef.current = null;
|
||||
@@ -169,6 +177,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
const hideMenu = React.useCallback(() => {
|
||||
pendingSelectionRef.current = null;
|
||||
activeAddToChatCleanupRef.current?.();
|
||||
activeAddToChatCleanupRef.current = null;
|
||||
setCommentRects(null);
|
||||
|
||||
if (!isMenuVisibleRef.current) {
|
||||
@@ -191,23 +201,29 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
isMenuVisibleRef.current = false;
|
||||
}, []);
|
||||
|
||||
const getDesktopClampedX = React.useCallback((anchorX: number) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return anchorX;
|
||||
}
|
||||
const getClampedX = React.useCallback((anchorX: number) => (
|
||||
typeof window === 'undefined'
|
||||
? anchorX
|
||||
: getDesktopClampedX(anchorX, window.innerWidth, menuWidthRef.current)
|
||||
), []);
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const menuWidth = menuWidthRef.current;
|
||||
const halfWidth = menuWidth / 2;
|
||||
const minX = DESKTOP_MENU_SIDE_MARGIN_PX + halfWidth;
|
||||
const maxX = viewportWidth - DESKTOP_MENU_SIDE_MARGIN_PX - halfWidth;
|
||||
const getClampedY = React.useCallback((anchorY: number) => (
|
||||
typeof window === 'undefined'
|
||||
? anchorY
|
||||
: getDesktopClampedY(anchorY, window.innerHeight, menuHeightRef.current)
|
||||
), []);
|
||||
|
||||
if (minX > maxX) {
|
||||
return viewportWidth / 2;
|
||||
}
|
||||
const addMarkdownToChat = React.useCallback((markdownText: string) => {
|
||||
const markdownBlock = wrapMarkdownSelectionForChat(markdownText);
|
||||
setPendingInputText(markdownBlock, 'append');
|
||||
|
||||
return Math.min(Math.max(anchorX, minX), maxX);
|
||||
}, []);
|
||||
hideMenu();
|
||||
|
||||
window.getSelection()?.removeAllRanges();
|
||||
queueMicrotask(() => {
|
||||
focusChatInput();
|
||||
});
|
||||
}, [hideMenu, setPendingInputText]);
|
||||
|
||||
const showMenu = React.useCallback(() => {
|
||||
if (!pendingSelectionRef.current) return;
|
||||
@@ -215,11 +231,19 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current;
|
||||
const shouldAnimateIn = !position.show;
|
||||
|
||||
activeAddToChatCleanupRef.current?.();
|
||||
activeAddToChatCleanupRef.current = registerActiveSelectionToolbar({
|
||||
addToChat: () => addMarkdownToChat(markdownText),
|
||||
dismiss: hideMenu,
|
||||
});
|
||||
|
||||
// Position menu above the selection
|
||||
const menuX = isMobile
|
||||
? rect.left + rect.width / 2
|
||||
: getDesktopClampedX(rect.left + rect.width / 2);
|
||||
const menuY = rect.top - 10;
|
||||
: getClampedX(rect.left + rect.width / 2);
|
||||
const menuY = isMobile
|
||||
? rect.top - 10
|
||||
: getClampedY(rect.top - 10);
|
||||
|
||||
setSelectedText(plainText);
|
||||
setSelectedTextMarkdown(markdownText);
|
||||
@@ -241,7 +265,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
openRafRef.current = null;
|
||||
});
|
||||
}
|
||||
}, [getDesktopClampedX, isMobile, position.show]);
|
||||
}, [addMarkdownToChat, getClampedX, getClampedY, hideMenu, isMobile, position.show]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!position.show || isMobile || !menuRef.current) {
|
||||
@@ -249,16 +273,28 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
}
|
||||
|
||||
const measuredWidth = menuRef.current.offsetWidth;
|
||||
if (!Number.isFinite(measuredWidth) || measuredWidth <= 0 || measuredWidth === menuWidthRef.current) {
|
||||
const measuredHeight = menuRef.current.offsetHeight;
|
||||
const widthChanged = Number.isFinite(measuredWidth) && measuredWidth > 0 && measuredWidth !== menuWidthRef.current;
|
||||
const heightChanged = Number.isFinite(measuredHeight) && measuredHeight > 0 && measuredHeight !== menuHeightRef.current;
|
||||
if (!widthChanged && !heightChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
menuWidthRef.current = measuredWidth;
|
||||
if (widthChanged) {
|
||||
menuWidthRef.current = measuredWidth;
|
||||
}
|
||||
if (heightChanged) {
|
||||
menuHeightRef.current = measuredHeight;
|
||||
}
|
||||
setPosition((prev) => ({
|
||||
...prev,
|
||||
x: getDesktopClampedX(prev.x),
|
||||
x: getClampedX(prev.x),
|
||||
y: getClampedY(prev.y),
|
||||
}));
|
||||
}, [getDesktopClampedX, isMobile, position.show]);
|
||||
// Entering comment mode and typing into the comment box both grow the
|
||||
// popup, so remeasuring on those keeps the cached height (and the Y clamp
|
||||
// built from it) honest.
|
||||
}, [commentMode, commentText, getClampedX, getClampedY, isMobile, position.show]);
|
||||
|
||||
// The desktop popup hangs above its anchor, so a tall comment box near the
|
||||
// top of the chat can climb over the app header. On the desktop shell the
|
||||
@@ -287,7 +323,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const handleViewportResize = () => {
|
||||
setPosition((prev) => ({
|
||||
...prev,
|
||||
x: getDesktopClampedX(prev.x),
|
||||
x: getClampedX(prev.x),
|
||||
y: getClampedY(prev.y),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -295,7 +332,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleViewportResize);
|
||||
};
|
||||
}, [getDesktopClampedX, isMobile, position.show]);
|
||||
}, [getClampedX, getClampedY, isMobile, position.show]);
|
||||
|
||||
const handleSelectionChange = React.useCallback(() => {
|
||||
// While the comment input is open, clicking or typing in it collapses the
|
||||
@@ -428,18 +465,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
const handleAddToChat = React.useCallback(() => {
|
||||
if (!selectedTextMarkdown) return;
|
||||
|
||||
const markdownBlock = wrapMarkdownSelectionForChat(selectedTextMarkdown);
|
||||
setPendingInputText(markdownBlock, 'append');
|
||||
|
||||
hideMenu();
|
||||
|
||||
// Clear selection
|
||||
window.getSelection()?.removeAllRanges();
|
||||
queueMicrotask(() => {
|
||||
focusChatInput();
|
||||
});
|
||||
}, [selectedTextMarkdown, setPendingInputText, hideMenu]);
|
||||
addMarkdownToChat(selectedTextMarkdown);
|
||||
}, [addMarkdownToChat, selectedTextMarkdown]);
|
||||
|
||||
const handleOpenComment = React.useCallback(() => {
|
||||
if (!selectedTextMarkdown) return;
|
||||
@@ -473,18 +500,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
});
|
||||
}, [addContextDraft, commentText, currentSessionId, effectiveDirectory, hideMenu, newSessionDraftOpen, selectedMessageId, selectedTextMarkdown]);
|
||||
|
||||
const handleCreateNewSession = React.useCallback(async () => {
|
||||
if (!selectedText) return;
|
||||
|
||||
const session = await createSession(undefined, null, null);
|
||||
if (session) {
|
||||
setPendingInputText(selectedText, 'replace');
|
||||
}
|
||||
|
||||
hideMenu();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}, [selectedText, createSession, setPendingInputText, hideMenu]);
|
||||
|
||||
const currentSession = React.useMemo(() => {
|
||||
if (!currentSessionId) {
|
||||
return null;
|
||||
@@ -686,22 +701,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToInput')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCreateNewSession}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
|
||||
'text-sm font-medium leading-tight',
|
||||
'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]',
|
||||
'active:opacity-80',
|
||||
'transition-opacity duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.newSessionWithSelection')}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="chat-new" className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.newSession')}</span>
|
||||
</button>
|
||||
|
||||
{!isVSCodeRuntime() ? (
|
||||
<button
|
||||
onClick={handleAddToNotes}
|
||||
@@ -763,39 +762,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
{t('chat.textSelection.actions.comment')}
|
||||
</button>
|
||||
|
||||
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
|
||||
|
||||
<button
|
||||
onClick={handleAddToChat}
|
||||
className={cn(
|
||||
'px-3.5 py-1.5 rounded-full',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.addToCurrentChat')}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.textSelection.actions.addToInput')}
|
||||
</button>
|
||||
|
||||
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
|
||||
|
||||
<button
|
||||
onClick={handleCreateNewSession}
|
||||
className={cn(
|
||||
'px-3.5 py-1.5 rounded-full',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.newSessionWithSelection')}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.textSelection.actions.newSession')}
|
||||
</button>
|
||||
|
||||
{!isVSCodeRuntime() ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
DESKTOP_MENU_FALLBACK_HEIGHT_PX,
|
||||
DESKTOP_MENU_FALLBACK_WIDTH_PX,
|
||||
DESKTOP_MENU_SIDE_MARGIN_PX,
|
||||
getDesktopClampedX,
|
||||
getDesktopClampedY,
|
||||
} from '../selectionMenuPosition';
|
||||
|
||||
const VIEWPORT_WIDTH = 1024;
|
||||
const VIEWPORT_HEIGHT = 768;
|
||||
const MENU_WIDTH = DESKTOP_MENU_FALLBACK_WIDTH_PX;
|
||||
const MENU_HEIGHT = DESKTOP_MENU_FALLBACK_HEIGHT_PX;
|
||||
|
||||
// Regression coverage for issue #2257: selecting a long assistant response
|
||||
// across a scroll boundary makes range.getBoundingClientRect().top negative,
|
||||
// and the unclamped anchor (rect.top - 10) placed the menu above the viewport.
|
||||
describe('getDesktopClampedY (issue #2257)', () => {
|
||||
test('keeps the menu on screen when the selection starts above the viewport', () => {
|
||||
const clamped = getDesktopClampedY(-210, VIEWPORT_HEIGHT, MENU_HEIGHT);
|
||||
expect(clamped).toBe(DESKTOP_MENU_SIDE_MARGIN_PX + MENU_HEIGHT);
|
||||
});
|
||||
|
||||
test('keeps the menu fully visible for selections near the top edge', () => {
|
||||
// The menu renders with translate(-50%, -100%), so it extends upward from
|
||||
// the anchor; anchors smaller than margin + menu height clip the menu.
|
||||
const clamped = getDesktopClampedY(5, VIEWPORT_HEIGHT, MENU_HEIGHT);
|
||||
expect(clamped).toBe(DESKTOP_MENU_SIDE_MARGIN_PX + MENU_HEIGHT);
|
||||
});
|
||||
|
||||
test('clamps anchors below the viewport back to the bottom margin', () => {
|
||||
const clamped = getDesktopClampedY(VIEWPORT_HEIGHT + 500, VIEWPORT_HEIGHT, MENU_HEIGHT);
|
||||
expect(clamped).toBe(VIEWPORT_HEIGHT - DESKTOP_MENU_SIDE_MARGIN_PX);
|
||||
});
|
||||
|
||||
test('leaves in-viewport anchors unchanged', () => {
|
||||
expect(getDesktopClampedY(300, VIEWPORT_HEIGHT, MENU_HEIGHT)).toBe(300);
|
||||
expect(getDesktopClampedY(MENU_HEIGHT + DESKTOP_MENU_SIDE_MARGIN_PX, VIEWPORT_HEIGHT, MENU_HEIGHT))
|
||||
.toBe(MENU_HEIGHT + DESKTOP_MENU_SIDE_MARGIN_PX);
|
||||
});
|
||||
|
||||
test('falls back to the viewport middle when the viewport is shorter than the menu', () => {
|
||||
const tinyViewportHeight = MENU_HEIGHT;
|
||||
expect(getDesktopClampedY(10, tinyViewportHeight, MENU_HEIGHT)).toBe(tinyViewportHeight / 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDesktopClampedX', () => {
|
||||
test('clamps anchors past the left edge to the left margin', () => {
|
||||
const clamped = getDesktopClampedX(-500, VIEWPORT_WIDTH, MENU_WIDTH);
|
||||
expect(clamped).toBe(DESKTOP_MENU_SIDE_MARGIN_PX + MENU_WIDTH / 2);
|
||||
});
|
||||
|
||||
test('clamps anchors past the right edge to the right margin', () => {
|
||||
const clamped = getDesktopClampedX(VIEWPORT_WIDTH + 500, VIEWPORT_WIDTH, MENU_WIDTH);
|
||||
expect(clamped).toBe(VIEWPORT_WIDTH - DESKTOP_MENU_SIDE_MARGIN_PX - MENU_WIDTH / 2);
|
||||
});
|
||||
|
||||
test('leaves in-viewport anchors unchanged', () => {
|
||||
expect(getDesktopClampedX(VIEWPORT_WIDTH / 2, VIEWPORT_WIDTH, MENU_WIDTH)).toBe(VIEWPORT_WIDTH / 2);
|
||||
});
|
||||
|
||||
test('falls back to the viewport middle when the viewport is narrower than the menu', () => {
|
||||
const tinyViewportWidth = MENU_WIDTH / 2;
|
||||
expect(getDesktopClampedX(10, tinyViewportWidth, MENU_WIDTH)).toBe(tinyViewportWidth / 2);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { readContextPart } from '@/lib/messages/contextParts';
|
||||
|
||||
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
|
||||
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
|
||||
const LINEAR_ISSUE_CONTEXT_PREFIX = 'Linear issue context (JSON)';
|
||||
|
||||
type GitHubIssueContextPayload = {
|
||||
issue?: {
|
||||
@@ -20,6 +21,14 @@ type GitHubPrContextPayload = {
|
||||
};
|
||||
};
|
||||
|
||||
type LinearIssueContextPayload = {
|
||||
issue?: {
|
||||
identifier?: unknown;
|
||||
title?: unknown;
|
||||
url?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
const isPositiveNumber = (value: unknown): value is number => {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
||||
};
|
||||
@@ -79,6 +88,24 @@ const buildGitHubAttachmentPart = (text: string): Part | null => {
|
||||
} as Part;
|
||||
}
|
||||
|
||||
const linearPayload = parseSyntheticJsonPayload<LinearIssueContextPayload>(text, LINEAR_ISSUE_CONTEXT_PREFIX);
|
||||
if (linearPayload) {
|
||||
const issue = linearPayload.issue;
|
||||
const identifier = issue?.identifier;
|
||||
const title = issue?.title;
|
||||
const url = issue?.url;
|
||||
if (typeof identifier !== 'string' || identifier.trim().length === 0 || typeof title !== 'string' || typeof url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'file',
|
||||
mime: 'application/vnd.openchamber.linear-issue-link',
|
||||
filename: `${identifier}: ${title}`,
|
||||
url,
|
||||
} as Part;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -106,7 +133,8 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
||||
const normalizedText = text.trimStart();
|
||||
return shouldKeepSyntheticUserText(text, planModeEnabled)
|
||||
|| normalizedText.startsWith(GITHUB_ISSUE_CONTEXT_PREFIX)
|
||||
|| normalizedText.startsWith(GITHUB_PR_CONTEXT_PREFIX);
|
||||
|| normalizedText.startsWith(GITHUB_PR_CONTEXT_PREFIX)
|
||||
|| normalizedText.startsWith(LINEAR_ISSUE_CONTEXT_PREFIX);
|
||||
})
|
||||
.map((part) => {
|
||||
const rawPart = part as Record<string, unknown>;
|
||||
@@ -119,10 +147,18 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
||||
|
||||
if (synthetic) {
|
||||
const contextPayload = readContextPart(part);
|
||||
if (contextPayload?.kind === 'github-issue' || contextPayload?.kind === 'github-pr') {
|
||||
if (contextPayload?.kind === 'github-issue' || contextPayload?.kind === 'github-pr' || contextPayload?.kind === 'linear-issue') {
|
||||
// SAFETY: same display-only file-part shape the legacy
|
||||
// buildGitHubAttachmentPart produces; consumed by
|
||||
// FileAttachment, which matches on the mime type.
|
||||
if (contextPayload.kind === 'linear-issue') {
|
||||
return {
|
||||
type: 'file',
|
||||
mime: 'application/vnd.openchamber.linear-issue-link',
|
||||
filename: `${contextPayload.identifier}: ${contextPayload.title}`,
|
||||
url: contextPayload.url,
|
||||
} as Part;
|
||||
}
|
||||
return {
|
||||
type: 'file',
|
||||
mime: contextPayload.kind === 'github-issue'
|
||||
|
||||
@@ -87,8 +87,10 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
||||
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.
|
||||
- `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render.
|
||||
- The rich tool diff preview lives in `ToolPartDiffPreview.tsx` and is lazy-loaded from `ToolPart`. It is the only tool-card piece that imports the `@pierre/diffs` + Shiki rendering stack, keeping that stack out of the eager chat startup graph. While its chunk loads (first rendered diff only) the plain-text patch from `PlainDiffFallback.tsx` renders as the Suspense fallback, mirroring the preview's error fallback. `ToolPart` itself must not statically import `@pierre/diffs` runtime modules or `@/lib/shiki/appThemeRegistry`.
|
||||
- The `@pierre/diffs` stack is knowingly unprotected against the JS/TS `template-call` backtracking that OOM'd the renderer in openchamber/openchamber#2587. Our own markdown Shiki worker sanitizes every grammar it loads (`@/lib/shiki/sanitizeTemplateCallGrammar`), but the diff worker pool runs `preferredHighlighter: 'shiki-wasm'` (`DiffWorkerProvider.tsx`) and resolves its languages by id through `@pierre/diffs`' own registry — `langs` accepts `SupportedLanguages` strings only, so there is no seam to hand it a pre-sanitized `LanguageRegistration`. A pathological template literal inside a rendered diff can therefore still hang that pool's Oniguruma engine. The available levers are upstream (a `langs` overload accepting grammar objects) or switching that pool to the JS regex engine; neither is done.
|
||||
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its output viewport grows with the content up to `46vh`, then scrolls and follows new output until the user scrolls up; following resumes when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering.
|
||||
- Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`).
|
||||
- Reasoning streaming presentation derives from the live stream phase (`streaming`/`cooldown`), never from missing persisted timing: a cached part without `time.end` is not live, and a part whose `time.end` is set never streams (issue #2020).
|
||||
|
||||
## "I want to change description for Perplexity" (example recipe)
|
||||
|
||||
@@ -125,10 +127,11 @@ Why: only navigation tools use the compact static path; all other tools need obs
|
||||
annotations, PR comments/checks): `UserContextPart.tsx`. `UserTextPart`
|
||||
routes to it when the part's metadata carries an `openchamberContext`
|
||||
payload (see `lib/messages/contextParts.ts`, which owns both the send-time
|
||||
builder and the read-back parser). Linked GitHub issues/PRs are instead
|
||||
converted to link file-parts in `normalizeUserDisplayParts.ts`. Legacy
|
||||
pre-metadata messages still render via text sniffing (`<terminal_context>`
|
||||
blocks, `GitHub issue context (JSON)` prefixes).
|
||||
builder and the read-back parser). Linked GitHub issues/PRs and Linear
|
||||
issues are instead converted to link file-parts in
|
||||
`normalizeUserDisplayParts.ts`. Legacy pre-metadata messages still render
|
||||
via text sniffing (`<terminal_context>` blocks, `GitHub issue context (JSON)`
|
||||
and `Linear issue context (JSON)` prefixes).
|
||||
- Tools: `ToolPart.tsx`, `ToolPartDiffPreview.tsx`, `PlainDiffFallback.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
|
||||
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
|
||||
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
|
||||
|
||||
@@ -1,9 +1,66 @@
|
||||
import React from 'react';
|
||||
import React, { act } from 'react';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Window } from 'happy-dom';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
import { ReasoningTimelineBlock } from './ReasoningPart';
|
||||
import ReasoningPart, { ReasoningTimelineBlock } from './ReasoningPart';
|
||||
import type { StreamPhase } from '../types';
|
||||
|
||||
type ReasoningPartFixture = Extract<Part, { type: 'reasoning' }>;
|
||||
|
||||
/**
|
||||
* Mounts a real client root against a happy-dom document so mount/unmount
|
||||
* lifecycle is observable. bun test shares globalThis across a file, so the
|
||||
* globals React DOM reads are defined here and restored afterwards; defining
|
||||
* them directly avoids asserting that happy-dom's objects are the platform
|
||||
* `Window`/`Document`.
|
||||
*/
|
||||
const DOM_GLOBAL_NAMES = [
|
||||
'window',
|
||||
'document',
|
||||
'navigator',
|
||||
'Node',
|
||||
'Element',
|
||||
'HTMLElement',
|
||||
'IS_REACT_ACT_ENVIRONMENT',
|
||||
] as const;
|
||||
|
||||
const installDomStub = () => {
|
||||
const happyWindow = new Window({ url: 'http://localhost' });
|
||||
const previous = DOM_GLOBAL_NAMES.map(
|
||||
(name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const,
|
||||
);
|
||||
const values = {
|
||||
window: happyWindow,
|
||||
document: happyWindow.document,
|
||||
navigator: happyWindow.navigator,
|
||||
Node: happyWindow.Node,
|
||||
Element: happyWindow.Element,
|
||||
HTMLElement: happyWindow.HTMLElement,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
};
|
||||
for (const name of DOM_GLOBAL_NAMES) {
|
||||
Object.defineProperty(globalThis, name, { value: values[name], configurable: true, writable: true });
|
||||
}
|
||||
|
||||
// Read back through the global bindings just installed, so the container is
|
||||
// typed as the DOM element React expects rather than happy-dom's own class.
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
|
||||
return {
|
||||
container,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// A reasoning text whose summary (first 120 chars) fits in the header but
|
||||
// whose expanded body content should only appear when the disclosure is open.
|
||||
@@ -113,3 +170,129 @@ describe('ReasoningTimelineBlock', () => {
|
||||
expect(markup).not.toContain('<!-- -->');
|
||||
});
|
||||
});
|
||||
|
||||
// Regression tests for issue #2020: a persisted reasoning part must not be
|
||||
// presented as live streaming just because cached data lacks `time.end` or a
|
||||
// stream phase. Live activity derives from the live stream phase only.
|
||||
describe('ReasoningPart streaming gating (issue #2020)', () => {
|
||||
// Short enough (< 80 chars) that the collapsed header summary contains the
|
||||
// complete text, letting us assert full content on first paint.
|
||||
const SHORT_REASONING = 'Persisted reasoning text that is already fully available.';
|
||||
|
||||
const BUSY_INDICATOR = 'animate-busy-pulse';
|
||||
|
||||
const makeReasoningPart = (
|
||||
time: ReasoningPartFixture['time'],
|
||||
text: string = SHORT_REASONING,
|
||||
): ReasoningPartFixture => ({
|
||||
id: 'prt_reasoning_2020',
|
||||
sessionID: 'ses_2020',
|
||||
messageID: 'msg_2020',
|
||||
type: 'reasoning',
|
||||
text,
|
||||
time,
|
||||
});
|
||||
|
||||
// Server rendering reads the UI store's initial state, which is
|
||||
// chatRenderMode 'live' — the mode in which the streaming presentation is
|
||||
// reachable and the issue reproduces.
|
||||
const renderPart = (part: ReasoningPartFixture, streamPhase?: StreamPhase): string =>
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider>
|
||||
<ReasoningPart part={part} messageId="msg_2020" streamPhase={streamPhase} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
test('reasoning without time.end and without a live stream phase renders complete, not streaming', () => {
|
||||
// Freshly opened completed session: cached part never received `time.end`
|
||||
// and no message-level stream phase is available. The full text is already
|
||||
// local, so the block must render as finished content on first paint.
|
||||
const markup = renderPart(makeReasoningPart({ start: 1_000 }), undefined);
|
||||
|
||||
expect(markup).not.toContain(BUSY_INDICATOR);
|
||||
expect(markup).toContain('aria-expanded="false"');
|
||||
expect(markup).toContain(SHORT_REASONING);
|
||||
});
|
||||
|
||||
test('reasoning without time.end in a completed message renders complete, not streaming', () => {
|
||||
const markup = renderPart(makeReasoningPart({ start: 1_000 }), 'completed');
|
||||
|
||||
expect(markup).not.toContain(BUSY_INDICATOR);
|
||||
expect(markup).toContain('aria-expanded="false"');
|
||||
expect(markup).toContain(SHORT_REASONING);
|
||||
});
|
||||
|
||||
test('reasoning with time.end is never treated as streaming, even when the phase claims streaming', () => {
|
||||
const markup = renderPart(makeReasoningPart({ start: 1_000, end: 2_000 }), 'streaming');
|
||||
|
||||
expect(markup).not.toContain(BUSY_INDICATOR);
|
||||
expect(markup).toContain('aria-expanded="false"');
|
||||
expect(markup).toContain(SHORT_REASONING);
|
||||
});
|
||||
|
||||
test('live in-progress reasoning still renders as streaming', () => {
|
||||
// Genuinely live: the message-level stream phase reports streaming and the
|
||||
// part has not ended. The block auto-expands and shows the busy indicator.
|
||||
const markup = renderPart(makeReasoningPart({ start: 1_000 }), 'streaming');
|
||||
|
||||
expect(markup).toContain(BUSY_INDICATOR);
|
||||
expect(markup).toContain('aria-expanded="true"');
|
||||
});
|
||||
|
||||
test('a live part with no committed text yet shows the busy header and no empty summary', () => {
|
||||
// The streaming early-return keeps the block mounted before the block-level
|
||||
// reveal commits a first line. The header must read as busy and must not
|
||||
// paint an empty summary row.
|
||||
const markup = renderPart(makeReasoningPart({ start: 1_000 }, ''), 'streaming');
|
||||
const withText = renderPart(makeReasoningPart({ start: 1_000 }), undefined);
|
||||
|
||||
expect(markup).toContain(BUSY_INDICATOR);
|
||||
expect(markup).toContain('role="button"');
|
||||
// The summary span carries `title="<summary>"`; with no text there must be
|
||||
// no summary span at all rather than an empty one.
|
||||
expect(withText).toContain('title="');
|
||||
expect(markup).not.toContain('title="');
|
||||
});
|
||||
|
||||
test('remounting a completed reasoning part does not re-trigger the streaming presentation', async () => {
|
||||
// renderToStaticMarkup cannot observe this: it has no mount lifecycle, so
|
||||
// comparing two server renders is true by construction. Mount, unmount and
|
||||
// remount a real client root instead, watching the busy indicator across
|
||||
// every commit.
|
||||
const dom = installDomStub();
|
||||
const part = makeReasoningPart({ start: 1_000 });
|
||||
const busySeen: boolean[] = [];
|
||||
const root = createRoot(dom.container);
|
||||
|
||||
const renderTree = () =>
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
null,
|
||||
React.createElement(ReasoningPart, { part, messageId: 'msg_2020', streamPhase: undefined }),
|
||||
);
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(renderTree());
|
||||
});
|
||||
busySeen.push(dom.container.innerHTML.includes(BUSY_INDICATOR));
|
||||
expect(dom.container.textContent).toContain(SHORT_REASONING);
|
||||
|
||||
await act(async () => {
|
||||
root.render(null);
|
||||
});
|
||||
await act(async () => {
|
||||
root.render(renderTree());
|
||||
});
|
||||
busySeen.push(dom.container.innerHTML.includes(BUSY_INDICATOR));
|
||||
|
||||
expect(busySeen).toEqual([false, false]);
|
||||
expect(dom.container.textContent).toContain(SHORT_REASONING);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,7 +261,11 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!text || text.trim().length === 0) {
|
||||
// While genuinely streaming, the busy header must appear as soon as
|
||||
// reasoning starts even before the block-level reveal (commitStreamedText)
|
||||
// has committed a first complete line — otherwise "Thinking…" never shows
|
||||
// for the first moments of a short, single-paragraph response.
|
||||
if (!isStreaming && (!text || text.trim().length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -430,8 +434,12 @@ const ReasoningPart = React.memo(({
|
||||
const rawText = partWithText.text || partWithText.content || '';
|
||||
const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]);
|
||||
const time = partWithText.time;
|
||||
const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed';
|
||||
const isStreaming = chatRenderMode === 'live' && canBeStreaming && typeof time?.end !== 'number';
|
||||
// Live activity derives from the live stream phase, never from the absence
|
||||
// of persisted timing data: cached parts may lack `time.end` even though
|
||||
// the message finished long ago (issue #2020). A part that has ended is
|
||||
// never streaming, even while the rest of the message still streams.
|
||||
const isLiveStreamPhase = streamPhase === 'streaming' || streamPhase === 'cooldown';
|
||||
const isStreaming = chatRenderMode === 'live' && isLiveStreamPhase && typeof time?.end !== 'number';
|
||||
const throttledTextRaw = useStreamingTextThrottle({
|
||||
text: textContent,
|
||||
isStreaming,
|
||||
@@ -441,9 +449,11 @@ const ReasoningPart = React.memo(({
|
||||
// never mutates in place.
|
||||
const throttledText = isStreaming ? commitStreamedText(throttledTextRaw) : throttledTextRaw;
|
||||
|
||||
// Show reasoning even if time.end isn't set yet (during streaming)
|
||||
// Only hide if there's no text content
|
||||
if (!throttledText || throttledText.trim().length === 0) {
|
||||
// Show reasoning even if time.end isn't set yet (during streaming).
|
||||
// While genuinely streaming, keep the block mounted even before the
|
||||
// block-level reveal commits a first line, so the busy header appears
|
||||
// immediately instead of waiting on committed text.
|
||||
if (!isStreaming && (!throttledText || throttledText.trim().length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getStreamingOutputAppend, getToolOutput, renderTerminalOutput } from './toolOutput';
|
||||
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
||||
import { tryParseJsonOutput } from '../toolRenderers';
|
||||
import { parseDiffToUnified, tryParseJsonOutput } from '../toolRenderers';
|
||||
import { getStreamingThrottleText } from '../../hooks/useStreamingTextThrottle';
|
||||
import { getToolDescriptionFallback } from './toolRenderUtils';
|
||||
|
||||
@@ -42,6 +42,29 @@ describe('getToolOutput', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDiffToUnified', () => {
|
||||
test('handles a streamed diff with a bare Index header', () => {
|
||||
expect(parseDiffToUnified('Index:')).toEqual([]);
|
||||
expect(parseDiffToUnified('Index:\n@@ -1,1 +1,1 @@\n-old\n+new')).toEqual([
|
||||
{
|
||||
file: 'file',
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: 'removed', lineNumber: 1, content: 'old' },
|
||||
{ type: 'added', lineNumber: 1, content: 'new' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('preserves spaces when extracting the indexed filename', () => {
|
||||
const [hunk] = parseDiffToUnified('Index: src/my file.ts\n@@ -1,1 +1,1 @@\n-old\n+new');
|
||||
|
||||
expect(hunk?.file).toBe('my file.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTerminalOutput', () => {
|
||||
test('renders carriage-return progress updates as their latest value', () => {
|
||||
expect(renderTerminalOutput('Downloading 10%\r\u001B[2KDownloading 90%')).toBe('Downloading 90%');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMobileAppActions } from '@/apps/mobileAppContext';
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import { QuestionMarkdown } from '../../QuestionMarkdown';
|
||||
import { MessageFilesDisplay } from '../../FileAttachment';
|
||||
import { getToolMetadata } from '@/lib/toolHelpers';
|
||||
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion, FilePart } from '@opencode-ai/sdk/v2';
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
renderTodoOutput,
|
||||
tryParseJsonOutput,
|
||||
coerceToText,
|
||||
capToolOutputText,
|
||||
} from '../toolRenderers';
|
||||
import { JsonTreeViewer } from '@/components/ui/JsonTreeViewer';
|
||||
import { JsonSummaryView } from './JsonSummaryView';
|
||||
@@ -44,9 +46,9 @@ import {
|
||||
buildTaskSummaryEntriesFromSession,
|
||||
normalizeTaskSummaryEntries,
|
||||
parseTaskMetadataBlock,
|
||||
prepareTaskToolOutput,
|
||||
readTaskSessionIdFromOutput,
|
||||
readTaskSessionIdFromRecord,
|
||||
stripTaskMetadataFromOutput,
|
||||
type TaskToolSummaryEntry,
|
||||
} from './taskToolModel';
|
||||
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
||||
@@ -59,6 +61,8 @@ import {
|
||||
getPatchText,
|
||||
getPrimaryDiffFromMetadata,
|
||||
getPrimaryToolPath,
|
||||
getToolFallbackDiff,
|
||||
resolveToolQuickOpenTarget,
|
||||
type DiffPatchEntry,
|
||||
} from './toolDiffUtils';
|
||||
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
|
||||
@@ -605,11 +609,15 @@ const getToolOutputText = (
|
||||
part: ToolPartType,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): string => {
|
||||
// Cap oversized payloads before JSON.parse / syntax highlighting / DOM work
|
||||
// so a single huge tool output can't trigger a V8 Zone-allocation OOM that
|
||||
// hard-crashes the renderer (issue #2265).
|
||||
const capped = capToolOutputText(output);
|
||||
if (part.tool === 'bash') {
|
||||
return output;
|
||||
return capped;
|
||||
}
|
||||
|
||||
return formatEditOutput(output, part.tool, metadata);
|
||||
return formatEditOutput(capped, part.tool, metadata);
|
||||
};
|
||||
|
||||
const StreamingPlainTextOutput: React.FC<{ output: string }> = ({ output }) => {
|
||||
@@ -998,9 +1006,7 @@ const TaskToolSummary: React.FC<{
|
||||
const showToolFileIcons = useUIStore((state) => state.showToolFileIcons);
|
||||
const runtime = React.useContext(RuntimeAPIContext);
|
||||
|
||||
const trimmedOutput = typeof output === 'string'
|
||||
? stripTaskMetadataFromOutput(output)
|
||||
: '';
|
||||
const trimmedOutput = prepareTaskToolOutput(output);
|
||||
const hasOutput = trimmedOutput.length > 0;
|
||||
const [isOutputExpanded, setIsOutputExpanded] = React.useState(false);
|
||||
|
||||
@@ -1244,12 +1250,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
});
|
||||
const outputString = isStreamingBash ? throttledOutputString : rawOutputString;
|
||||
const attachments = stateWithData.attachments;
|
||||
const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined;
|
||||
const diffContent = getPatchText((metadata as { patch?: unknown } | undefined)?.patch)
|
||||
?? getPatchText(metadata?.diff)
|
||||
?? getPatchText(fileDiff?.patch)
|
||||
?? getPatchText(fileDiff?.diff)
|
||||
?? null;
|
||||
const diffContent = getToolFallbackDiff(metadata) ?? null;
|
||||
const diffEntries = React.useMemo(
|
||||
() => getDiffPatchEntries(metadata, diffContent ?? undefined, (path) => getRelativePath(path, currentDirectory)),
|
||||
[currentDirectory, diffContent, metadata]
|
||||
@@ -1407,7 +1408,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
<div className="space-y-2">
|
||||
{parsedQA.map((qa, index) => (
|
||||
<div key={index} className="space-y-0.5">
|
||||
<div className="typography-micro text-muted-foreground">{qa.question}</div>
|
||||
<QuestionMarkdown content={qa.question} size="micro" className="text-muted-foreground" />
|
||||
<div className="typography-meta text-foreground whitespace-pre-wrap">{qa.answer}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1444,7 +1445,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
{q.header ? (
|
||||
<div className="typography-micro text-muted-foreground">{coerceToText(q.header)}</div>
|
||||
) : null}
|
||||
<div className="typography-meta text-foreground">{coerceToText(q.question)}</div>
|
||||
<QuestionMarkdown content={coerceToText(q.question)} size="meta" className="text-foreground" />
|
||||
{Array.isArray(q.options) && q.options.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{q.options.map((opt) => (
|
||||
@@ -1965,6 +1966,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
return null;
|
||||
}, [descriptionPath, normalizedPartTool, stateWithData, input]);
|
||||
const runtime = React.useContext(RuntimeAPIContext);
|
||||
const mobileActions = useMobileAppActions();
|
||||
|
||||
const openApplyPatchFile = (file: Record<string, unknown>, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!runtime?.editor) {
|
||||
@@ -2030,6 +2032,9 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
};
|
||||
|
||||
const handleMainKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
// Nested buttons (quick-open, copy) handle their own Enter/Space; the row
|
||||
// must not swallow the key and toggle instead.
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
@@ -2037,6 +2042,52 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
handleMainClick(event);
|
||||
};
|
||||
|
||||
// Quick-open target for the file-link icon in the tool header. Resolves the
|
||||
// primary file path (and, for diff tools, the first changed line + diff) so
|
||||
// the user can open the file in the side panel (web/desktop) or editor
|
||||
// (VS Code) without expanding the tool card. Reuses the same path helpers as
|
||||
// handleMainClick above; the difference is the web fallback — handleMainClick
|
||||
// only opens when runtime.editor is available, this icon also falls back to
|
||||
// useUIStore.openContextFile{AtLine} so the file opens in the right pane.
|
||||
const quickOpenTarget = React.useMemo<{ absolutePath: string; line?: number; toolDiff?: string; toolName: string } | null>(() => {
|
||||
if (isTaskTool) return null;
|
||||
const toolName = normalizedPartTool || part.tool;
|
||||
const target = resolveToolQuickOpenTarget(toolName, input, metadata);
|
||||
if (!target) return null;
|
||||
return {
|
||||
absolutePath: toAbsoluteFilePath(currentDirectory, target.filePath),
|
||||
line: target.line,
|
||||
toolDiff: target.patch,
|
||||
toolName,
|
||||
};
|
||||
}, [isTaskTool, normalizedPartTool, part.tool, input, metadata, currentDirectory]);
|
||||
|
||||
const openQuickTarget = () => {
|
||||
if (!quickOpenTarget) return;
|
||||
const { absolutePath, line, toolDiff, toolName } = quickOpenTarget;
|
||||
if (runtime?.editor) {
|
||||
if (runtime.runtime.isVSCode && toolDiff && (toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch')) {
|
||||
const label = `${getRelativePath(absolutePath, currentDirectory)} (changes)`;
|
||||
void runtime.editor.openDiff('', absolutePath, label, { line, patch: toolDiff });
|
||||
return;
|
||||
}
|
||||
runtime.editor.openFile(absolutePath, line);
|
||||
return;
|
||||
}
|
||||
const uiStore = useUIStore.getState();
|
||||
if (typeof line === 'number' && Number.isFinite(line)) {
|
||||
uiStore.openContextFileAtLine(currentDirectory, absolutePath, Math.max(1, Math.trunc(line)), 1);
|
||||
} else {
|
||||
uiStore.openContextFile(currentDirectory, absolutePath);
|
||||
}
|
||||
mobileActions?.openFiles();
|
||||
};
|
||||
|
||||
const handleQuickOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
openQuickTarget();
|
||||
};
|
||||
|
||||
const iconStyle = !isTaskTool && isError ? TOOL_ERROR_ICON_STYLE : TOOL_NORMAL_ICON_STYLE;
|
||||
const titleStyle = !isTaskTool && isError ? TOOL_ERROR_TITLE_STYLE : TOOL_NORMAL_TITLE_STYLE;
|
||||
const shouldRenderTaskSummary = useDeferredExpandedContent(isTaskTool && (taskSummaryEntries.length > 0 || isActive || shouldTreatAsFinalized || !!taskSessionId));
|
||||
@@ -2130,7 +2181,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
{isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className={cn('flex items-center min-w-0 flex-1', quickOpenTarget ? 'gap-1' : 'gap-2')}>
|
||||
<MinDurationShineText
|
||||
active={Boolean(isActive && !isError)}
|
||||
minDurationMs={300}
|
||||
@@ -2140,6 +2191,21 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
>
|
||||
{displayName}
|
||||
</MinDurationShineText>
|
||||
{quickOpenTarget ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuickOpen}
|
||||
className={cn(
|
||||
'flex-shrink-0 inline-flex h-4 w-4 items-center justify-center rounded transition-opacity hover:bg-[var(--surface-hover)]',
|
||||
'opacity-60 hover:opacity-100 focus-visible:opacity-100',
|
||||
)}
|
||||
style={{ color: 'var(--tools-icon)' }}
|
||||
title={t('chat.toolPart.openFile')}
|
||||
aria-label={t('chat.toolPart.openFile')}
|
||||
>
|
||||
<Icon name="external-link" className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{normalizedPartTool === 'bash' && typeof effectiveTimeStart === 'number' ? (
|
||||
<span className={cn('flex-shrink-0 tabular-nums text-muted-foreground/80', TOOL_ROW_DESCRIPTION_CLASS)}>
|
||||
|
||||
@@ -185,6 +185,7 @@ const UserContextPart: React.FC<{
|
||||
);
|
||||
case 'github-issue':
|
||||
case 'github-pr':
|
||||
case 'linear-issue':
|
||||
// Rendered as link attachments by normalizeUserDisplayParts.
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||
import {
|
||||
buildTaskSummaryEntriesFromSession,
|
||||
parseTaskMetadataBlock,
|
||||
prepareTaskToolOutput,
|
||||
readTaskSessionIdFromRecord,
|
||||
readTaskSessionIdFromOutput,
|
||||
} from './taskToolModel';
|
||||
import { TOOL_OUTPUT_MAX_CHARS } from '../toolRenderers';
|
||||
|
||||
describe('taskToolModel', () => {
|
||||
test('reads the current OpenCode running-state identity contract', () => {
|
||||
@@ -39,4 +41,19 @@ describe('taskToolModel', () => {
|
||||
state: { status: 'completed', title: undefined, input: { filePath: 'a.ts' } },
|
||||
}]);
|
||||
});
|
||||
|
||||
test('strips task metadata and caps oversized task output before markdown rendering', () => {
|
||||
const oversized = 'x'.repeat(TOOL_OUTPUT_MAX_CHARS + 5_000);
|
||||
const output = `${oversized}\n<task_metadata>{"sessionID":"child-1"}</task_metadata>`;
|
||||
const prepared = prepareTaskToolOutput(output);
|
||||
|
||||
expect(prepared.length).toBeLessThan(oversized.length);
|
||||
expect(prepared).toContain('output truncated');
|
||||
expect(prepared).not.toContain('task_metadata');
|
||||
});
|
||||
|
||||
test('leaves normal task output untouched', () => {
|
||||
expect(prepareTaskToolOutput('done\n<task_metadata>{"sessionID":"child-1"}</task_metadata>')).toBe('done');
|
||||
expect(prepareTaskToolOutput(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { MessageRecord } from '@/lib/messageCompletion';
|
||||
|
||||
import { capToolOutputText } from '../toolRenderers';
|
||||
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
||||
|
||||
export type TaskToolSummaryEntry = {
|
||||
@@ -131,3 +132,12 @@ export const buildTaskSummaryEntriesFromSession = (messages: MessageRecord[]): T
|
||||
export const stripTaskMetadataFromOutput = (output: string): string => {
|
||||
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
|
||||
};
|
||||
|
||||
// The task tool renders its output through the markdown parser instead of the
|
||||
// shared tool-output path, so it needs the same size guard as
|
||||
// `getToolOutputText` (issue #2265): an unbounded single string reaching the
|
||||
// parser can exhaust V8's Zone allocator and crash the renderer.
|
||||
export const prepareTaskToolOutput = (output: string | undefined): string => {
|
||||
if (!output) return '';
|
||||
return capToolOutputText(stripTaskMetadataFromOutput(output));
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
extractFirstChangedLineFromDiff,
|
||||
getApplyPatchFilePath,
|
||||
getDiffPatchEntries,
|
||||
getFirstChangedLineFromMetadata,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
getPrimaryDiffFromMetadata,
|
||||
getPrimaryToolPath,
|
||||
getRenderablePatchInfo,
|
||||
resolveToolQuickOpenTarget,
|
||||
} from './toolDiffUtils';
|
||||
|
||||
const identity = (path: string) => path;
|
||||
@@ -203,4 +205,52 @@ describe('toolDiffUtils', () => {
|
||||
expect(entries[0]?.renderMode).toBe('text');
|
||||
expect(entries[0]?.patch).toContain('@@');
|
||||
});
|
||||
test('resolves the quick-open target from the same entry the expanded card renders', () => {
|
||||
const patch = [
|
||||
'--- a/src/file.ts',
|
||||
'+++ b/src/file.ts',
|
||||
'@@ -10,3 +12,4 @@',
|
||||
' context',
|
||||
'+added',
|
||||
].join('\n');
|
||||
const metadata = {
|
||||
files: [{
|
||||
filePath: '/workspace/project/src/file.ts',
|
||||
relativePath: 'src/file.ts',
|
||||
patch,
|
||||
type: 'update',
|
||||
}],
|
||||
};
|
||||
const entries = getDiffPatchEntries(metadata, undefined, identity);
|
||||
|
||||
expect(resolveToolQuickOpenTarget('apply_patch', undefined, metadata)).toEqual({
|
||||
filePath: '/workspace/project/src/file.ts',
|
||||
line: extractFirstChangedLineFromDiff(entries[0]?.patch ?? ''),
|
||||
patch: entries[0]?.patch,
|
||||
});
|
||||
});
|
||||
|
||||
test('picks the entry matching the primary path in a multi-file apply_patch', () => {
|
||||
const firstPatch = ['--- a/src/a.ts', '+++ b/src/a.ts', '@@ -1,2 +1,3 @@', ' a', '+first'].join('\n');
|
||||
const secondPatch = ['--- a/src/b.ts', '+++ b/src/b.ts', '@@ -30,2 +40,3 @@', ' b', '+second'].join('\n');
|
||||
const metadata = {
|
||||
files: [
|
||||
{ filePath: '/workspace/project/src/a.ts', relativePath: 'src/a.ts', patch: firstPatch, type: 'delete' },
|
||||
{ filePath: '/workspace/project/src/b.ts', relativePath: 'src/b.ts', patch: secondPatch, type: 'update' },
|
||||
],
|
||||
};
|
||||
const target = resolveToolQuickOpenTarget('apply_patch', undefined, metadata);
|
||||
|
||||
expect(target?.filePath).toBe('/workspace/project/src/b.ts');
|
||||
expect(target?.line).toBe(41);
|
||||
});
|
||||
|
||||
test('reports no line when the tool has no diff entry', () => {
|
||||
expect(resolveToolQuickOpenTarget('write', { filePath: '/workspace/project/src/new.ts' }, undefined))
|
||||
.toEqual({ filePath: '/workspace/project/src/new.ts', line: undefined, patch: undefined });
|
||||
});
|
||||
|
||||
test('returns no quick-open target without a primary path', () => {
|
||||
expect(resolveToolQuickOpenTarget('bash', { command: 'ls' }, undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,6 +261,15 @@ export const getPrimaryDiffFromMetadata = (
|
||||
return getPatchText(metadata.patch) ?? getPatchText(metadata.diff);
|
||||
};
|
||||
|
||||
/** Top-level patch a tool card falls back to when metadata carries no per-file entries. */
|
||||
export const getToolFallbackDiff = (metadata: Record<string, unknown> | undefined): string | undefined => {
|
||||
const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined;
|
||||
return getPatchText(metadata?.patch)
|
||||
?? getPatchText(metadata?.diff)
|
||||
?? getPatchText(fileDiff?.patch)
|
||||
?? getPatchText(fileDiff?.diff);
|
||||
};
|
||||
|
||||
export const extractFirstChangedLineFromDiff = (diffText: string): number | undefined => {
|
||||
if (!diffText) {
|
||||
return undefined;
|
||||
@@ -330,6 +339,33 @@ export const getFirstChangedLineFromMetadata = (
|
||||
return firstPatch ? extractFirstChangedLineFromDiff(firstPatch) : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Quick-open target for a tool card: the primary mutated file plus the diff
|
||||
* entry the expanded card renders for it. Both the collapsed header icon and
|
||||
* the expanded "open file" button resolve their line from the same entry
|
||||
* patch, so they always land on the same line.
|
||||
*/
|
||||
export const resolveToolQuickOpenTarget = (
|
||||
toolName: string,
|
||||
input: Record<string, unknown> | undefined,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): { filePath: string; line?: number; patch?: string } | null => {
|
||||
const filePath = getPrimaryToolPath(toolName, input, metadata);
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = getDiffPatchEntries(metadata, getToolFallbackDiff(metadata), (path) => path);
|
||||
const matchedEntry = entries.find((entry) => entry.filePath === filePath)
|
||||
?? (entries.length === 1 ? entries[0] : undefined);
|
||||
const patch = matchedEntry?.patch;
|
||||
return {
|
||||
filePath,
|
||||
line: patch ? extractFirstChangedLineFromDiff(patch) : undefined,
|
||||
patch,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeParsedPath = (path: string | undefined): string => {
|
||||
const trimmed = (path ?? '').trim().replace(/\t.*$/, '');
|
||||
if (!trimmed || trimmed === '/dev/null') {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
|
||||
export const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
|
||||
export const DESKTOP_MENU_FALLBACK_HEIGHT_PX = 38;
|
||||
|
||||
export const getDesktopClampedX = (anchorX: number, viewportWidth: number, menuWidth: number): number => {
|
||||
const halfWidth = menuWidth / 2;
|
||||
const minX = DESKTOP_MENU_SIDE_MARGIN_PX + halfWidth;
|
||||
const maxX = viewportWidth - DESKTOP_MENU_SIDE_MARGIN_PX - halfWidth;
|
||||
|
||||
if (minX > maxX) {
|
||||
return viewportWidth / 2;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(anchorX, minX), maxX);
|
||||
};
|
||||
|
||||
// The desktop menu renders with `transform: translate(-50%, -100%)`, so the
|
||||
// anchor Y marks the menu's bottom edge and the menu extends `menuHeight`
|
||||
// upward from it. The minimum keeps the whole menu below the top margin.
|
||||
export const getDesktopClampedY = (anchorY: number, viewportHeight: number, menuHeight: number): number => {
|
||||
const minY = DESKTOP_MENU_SIDE_MARGIN_PX + menuHeight;
|
||||
const maxY = viewportHeight - DESKTOP_MENU_SIDE_MARGIN_PX;
|
||||
|
||||
if (minY > maxY) {
|
||||
return viewportHeight / 2;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(anchorY, minY), maxY);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
|
||||
import { capToolOutputText, TOOL_OUTPUT_MAX_CHARS } from './toolRenderers';
|
||||
|
||||
// Regression coverage for issue #2265: the desktop renderer hard-crashes with a
|
||||
// V8 "Zone Allocation failed" OOM when a tool returns oversized external content
|
||||
// (e.g. a fetched Google Slides page with full-resolution base64 images inlined),
|
||||
// because the whole payload previously flowed through JSON.parse / syntax
|
||||
// highlighting / DOM rendering as a single unbounded JS string. capToolOutputText
|
||||
// is the bounded size guard that runs before any of that work.
|
||||
describe('capToolOutputText (issue #2265 renderer OOM guard)', () => {
|
||||
test('exposes a sane positive default cap', () => {
|
||||
expect(typeof TOOL_OUTPUT_MAX_CHARS).toBe('number');
|
||||
expect(TOOL_OUTPUT_MAX_CHARS).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('returns short output unchanged', () => {
|
||||
const output = 'hello world';
|
||||
expect(capToolOutputText(output)).toBe(output);
|
||||
});
|
||||
|
||||
test('returns output at exactly the cap unchanged', () => {
|
||||
const output = 'a'.repeat(TOOL_OUTPUT_MAX_CHARS);
|
||||
expect(capToolOutputText(output)).toBe(output);
|
||||
expect(capToolOutputText(output).length).toBe(TOOL_OUTPUT_MAX_CHARS);
|
||||
});
|
||||
|
||||
test('caps oversized output and never emits the full string', () => {
|
||||
const oversized = 'x'.repeat(TOOL_OUTPUT_MAX_CHARS + 10_000);
|
||||
const capped = capToolOutputText(oversized);
|
||||
|
||||
// The pathological full-size string must not survive to the renderer.
|
||||
expect(capped.length).toBeLessThan(oversized.length);
|
||||
// Head of the payload is preserved for the user.
|
||||
expect(capped.startsWith('x'.repeat(1000))).toBe(true);
|
||||
// A truncation notice is appended so the truncation is visible.
|
||||
expect(capped).toContain('output truncated');
|
||||
expect(capped).toContain('10000 more characters');
|
||||
});
|
||||
|
||||
test('honors a custom cap', () => {
|
||||
const output = 'abcdefghij'; // 10 chars
|
||||
const capped = capToolOutputText(output, 4);
|
||||
expect(capped.startsWith('abcd')).toBe(true);
|
||||
expect(capped).toContain('output truncated');
|
||||
// Only the first 4 chars of the original body are retained.
|
||||
expect(capped).not.toContain('efghij');
|
||||
});
|
||||
|
||||
test('simulated large webfetch payload is bounded well below original size', () => {
|
||||
// ~6MB single string, matching the 5MB-20MB Zone-allocation trigger range
|
||||
// described in the issue (a Slides page with embedded base64 images).
|
||||
const base64Blob = 'QUJD'.repeat(1_500_000); // 6,000,000 chars
|
||||
const capped = capToolOutputText(base64Blob);
|
||||
|
||||
expect(base64Blob.length).toBeGreaterThan(5_000_000);
|
||||
expect(capped.length).toBeLessThan(TOOL_OUTPUT_MAX_CHARS + 256);
|
||||
expect(capped).toContain('renderer from running out of memory');
|
||||
});
|
||||
|
||||
test('non-string input is returned unchanged (defensive)', () => {
|
||||
// @ts-expect-error verifying runtime robustness against non-string inputs
|
||||
expect(capToolOutputText(undefined)).toBeUndefined();
|
||||
// @ts-expect-error verifying runtime robustness against non-string inputs
|
||||
expect(capToolOutputText(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,28 @@ export const coerceToText = (value: unknown, fallback = ''): string => {
|
||||
}
|
||||
};
|
||||
|
||||
// Guards the renderer process against V8 "Zone Allocation failed" OOM crashes
|
||||
// (issue #2265). When a tool returns oversized external content — e.g. a fetched
|
||||
// web page with full-resolution base64 images inlined — the entire payload flows
|
||||
// through this module as a single JS string that is JSON.parsed, syntax
|
||||
// highlighted, and attached to the DOM. A large enough single string exceeds
|
||||
// V8's Zone allocator and hard-crashes the renderer before any virtualization or
|
||||
// CSS clip can help. Capping the string length before that work happens keeps a
|
||||
// useful head of the output while preventing the pathological allocation.
|
||||
export const TOOL_OUTPUT_MAX_CHARS = 512 * 1024;
|
||||
|
||||
export const capToolOutputText = (
|
||||
output: string,
|
||||
maxChars: number = TOOL_OUTPUT_MAX_CHARS,
|
||||
): string => {
|
||||
if (typeof output !== 'string' || output.length <= maxChars) {
|
||||
return output;
|
||||
}
|
||||
const omitted = output.length - maxChars;
|
||||
const notice = `\n\n… [output truncated: ${omitted} more characters not shown to prevent the renderer from running out of memory]`;
|
||||
return output.slice(0, maxChars) + notice;
|
||||
};
|
||||
|
||||
const hasLspDiagnostics = (output: string): boolean => {
|
||||
if (!output) return false;
|
||||
return output.includes('<diagnostics')
|
||||
@@ -575,7 +597,7 @@ export const parseDiffToUnified = (diffText: string): UnifiedDiffHunk[] => {
|
||||
|
||||
if (line.startsWith('Index:') || line.startsWith('===') || line.startsWith('---') || line.startsWith('+++')) {
|
||||
if (line.startsWith('Index:')) {
|
||||
currentFile = line.split(' ')[1].split('/').pop() || 'file';
|
||||
currentFile = line.slice('Index:'.length).trim().split('/').pop() || 'file';
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Coordinates the first paint of a freshly opened session so the timeline
|
||||
* appears as one finished picture instead of arriving in pieces.
|
||||
*
|
||||
* Renderers that mount with a provisional paint (markdown whose blocks are not
|
||||
* in the settled cache yet, so code is unhighlighted) take a hold while they
|
||||
* catch up. The timeline stays invisible while any hold is open, then reveals
|
||||
* everything at once. The gate accepts holds only during the opening commit:
|
||||
* rows that mount later, while scrolling, must never hide the timeline.
|
||||
*
|
||||
* A hold that never releases must not hide the chat forever, so the owner
|
||||
* reveals after `TIMELINE_REVEAL_CAP_MS` regardless.
|
||||
*/
|
||||
export type TimelineRevealGate = {
|
||||
/** Take a hold; returns the release. Returns null once the gate is closed. */
|
||||
hold: () => (() => void) | null;
|
||||
/** Stops accepting holds. Existing holds still count. */
|
||||
close: () => void;
|
||||
readonly holds: number;
|
||||
/** Called when the last hold releases, if the gate is closed by then. */
|
||||
onEmpty: (() => void) | null;
|
||||
};
|
||||
|
||||
export const TIMELINE_REVEAL_CAP_MS = 250;
|
||||
|
||||
export const createTimelineRevealGate = (): TimelineRevealGate => {
|
||||
let holds = 0;
|
||||
let accepting = true;
|
||||
const gate: TimelineRevealGate = {
|
||||
hold: () => {
|
||||
if (!accepting) return null;
|
||||
holds += 1;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
holds -= 1;
|
||||
if (holds === 0 && !accepting) gate.onEmpty?.();
|
||||
};
|
||||
},
|
||||
close: () => {
|
||||
accepting = false;
|
||||
},
|
||||
get holds() {
|
||||
return holds;
|
||||
},
|
||||
onEmpty: null,
|
||||
};
|
||||
return gate;
|
||||
};
|
||||
|
||||
export const TimelineRevealGateContext = React.createContext<TimelineRevealGate | null>(null);
|
||||
@@ -95,11 +95,11 @@ which requests only providers enabled for this panel.
|
||||
|
||||
| Block | Source | Notes |
|
||||
|---|---|---|
|
||||
| Context + cost | `contextUsage.ts` over `useSessionMessages`, `Session.cost` | see below — the store getters cannot serve this |
|
||||
| Context + cost | `contextUsage.ts` over `useSessionMessages`; cost via `useSubagentCostRollup` (own cost + every descendant subagent, recursively) | see below — the store getters cannot serve this |
|
||||
| Branch, ahead/behind, attention | `useGitStore` directory state | warmed via `runBackgroundNetworkTask(ensureStatus)` and refreshed from Git mutation hints |
|
||||
| Changed files | `useGitStore` status `files` + `diffStats` | working tree, not session-authored edits |
|
||||
| PR + checks | `useFreshestPrVisualSummaryForBranch` | **read-only**; follows the freshest remote-keyed entry for the branch |
|
||||
| Subagents | child sessions from `useAllLiveSessions` (`parentID`) + `useAllSessionStatuses` | |
|
||||
| Subagents | child sessions from `useAllLiveSessions` (`parentID`) + `useAllSessionStatuses`; per-row cost from `useSubagentCostRollup`'s `perChildCost` (each child's own subtree total, so nested subagent-of-subagent cost rolls up under its immediate parent row) | |
|
||||
| Subagent blockers | directory `permission` / `question` maps | one subscription covers every child |
|
||||
| Usage | `components/usage/usageGroups.ts` over `useQuotaStore` | grouping shared with the mobile popover; presentation is not |
|
||||
| Linked threads | `lib/linkedIssues.ts` over session metadata | written by the flows that attach an issue or PR |
|
||||
@@ -319,8 +319,10 @@ Stored in session metadata as a **snapshot** (`lib/linkedIssues.ts`, namespace
|
||||
pinned messages. Number, title, url, author and avatar only — the body,
|
||||
comments and state belong to GitHub, and mirroring them would mean owning their
|
||||
staleness. The stored title can drift; that is the price of a store that never
|
||||
needs refreshing. The row opens the real thread, which is where current state
|
||||
lives.
|
||||
needs refreshing. A GitHub row opens github.com. A Linear row opens the
|
||||
right-hand Linear panel when Linear is connected on desktop/web; otherwise it
|
||||
opens the Linear URL (no rail in VS Code or the phone shell, and none while
|
||||
disconnected).
|
||||
|
||||
Writes happen **after** the send promise resolves and are deliberately
|
||||
swallowed on failure: the message went out, and a missing bookkeeping entry
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Icon } from '@/components/icon/Icon';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { useSession } from '@/sync/sync-context';
|
||||
import { getLinkedIssues } from '@/lib/linkedIssues';
|
||||
import { getLinkedIssues, canOpenLinearIssueInContextPanel } from '@/lib/linkedIssues';
|
||||
import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi';
|
||||
import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||
@@ -12,6 +12,10 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import { resolveProjectContextId } from '@/lib/projectContextApi';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useMobileAppActions } from '@/apps/mobileAppContext';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
|
||||
import { useReportWorkStatusPresence } from './presenceContext';
|
||||
import { resolveDraftPinnedKnowledge } from './draftKnowledge';
|
||||
@@ -32,6 +36,11 @@ type Props = {
|
||||
*/
|
||||
export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory }) => {
|
||||
const { t } = useI18n();
|
||||
const { linear } = useRuntimeAPIs();
|
||||
const linearConnected = useLinearAuthStore((state) => state.status?.connected === true);
|
||||
const mobileActions = useMobileAppActions();
|
||||
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
||||
const setLinearIssueFocus = useUIStore((state) => state.setLinearIssueFocus);
|
||||
|
||||
const session = useSession(sessionId ?? '', directory ?? undefined);
|
||||
const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft);
|
||||
@@ -138,6 +147,23 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
const pinnedCount = visibleKnowledge.notes.length + visibleKnowledge.plans.length;
|
||||
|
||||
const linked = React.useMemo(() => getLinkedIssues(session), [session]);
|
||||
const openLinkedIssue = React.useCallback((entry: (typeof linked)[number]) => {
|
||||
if (
|
||||
entry.kind === 'linear'
|
||||
&& directory
|
||||
&& canOpenLinearIssueInContextPanel({
|
||||
linearAvailable: Boolean(linear),
|
||||
linearConnected,
|
||||
inDedicatedMobileShell: mobileActions != null,
|
||||
directory,
|
||||
})
|
||||
) {
|
||||
setLinearIssueFocus(entry.identifier);
|
||||
openContextPanelTab(directory, { mode: 'linear' });
|
||||
return;
|
||||
}
|
||||
window.open(entry.url, '_blank', 'noopener,noreferrer');
|
||||
}, [directory, linear, linearConnected, mobileActions, openContextPanelTab, setLinearIssueFocus]);
|
||||
// Connected servers only. A disabled server contributes nothing to the
|
||||
// context, so counting it here contradicts the MCP section right above,
|
||||
// which shows the same servers switched off.
|
||||
@@ -158,8 +184,8 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
// The heading names what is distinctive about this session when there is
|
||||
// something — an attached thread — and falls back to the ambient counts
|
||||
// when there is not. `1 · 33 · 2` said nothing without opening the section.
|
||||
const issueCount = linked.filter((entry) => entry.kind === 'issue').length;
|
||||
const prCount = linked.length - issueCount;
|
||||
const issueCount = linked.filter((entry) => entry.kind === 'issue' || entry.kind === 'linear').length;
|
||||
const prCount = linked.filter((entry) => entry.kind === 'pull').length;
|
||||
const summaryParts: string[] = [];
|
||||
if (issueCount > 0) {
|
||||
summaryParts.push(issueCount === 1
|
||||
@@ -206,17 +232,23 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
<img src={entry.authorAvatarUrl} alt="" className="size-4 shrink-0 rounded-full" loading="lazy" />
|
||||
) : (
|
||||
<Icon
|
||||
name={entry.kind === 'pull' ? 'git-pull-request' : 'error-warning'}
|
||||
name={entry.kind === 'pull' ? 'git-pull-request' : entry.kind === 'linear' ? 'linear' : 'error-warning'}
|
||||
className="size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
label={entry.title}
|
||||
muted
|
||||
// The stored snapshot is enough to render; the live thread only ever
|
||||
// exists on github.com.
|
||||
onClick={() => window.open(entry.url, '_blank', 'noopener,noreferrer')}
|
||||
ariaLabel={t('chat.workStatus.linkedIssues.open', { number: entry.number })}
|
||||
value={<WorkStatusValue tone="muted">{`#${entry.number}`}</WorkStatusValue>}
|
||||
// GitHub threads still live on github.com. A Linear issue opens in
|
||||
// the right-hand panel when that rail exists; otherwise the Linear URL.
|
||||
onClick={() => openLinkedIssue(entry)}
|
||||
ariaLabel={entry.kind === 'linear'
|
||||
? t('chat.workStatus.linkedIssues.openLinear', { identifier: entry.identifier })
|
||||
: t('chat.workStatus.linkedIssues.open', { number: entry.number })}
|
||||
value={(
|
||||
<WorkStatusValue tone="muted">
|
||||
{entry.kind === 'linear' ? entry.identifier : `#${entry.number}`}
|
||||
</WorkStatusValue>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ type Props = {
|
||||
directory: string | null;
|
||||
};
|
||||
|
||||
const MCP_STATUS_MAX_AGE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* MCP servers with their connection switches, reusing the dropdown's own
|
||||
* connect/disconnect actions.
|
||||
@@ -23,17 +25,19 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
|
||||
const mcpStatus = useMcpStore(
|
||||
React.useCallback((state) => state.getStatusForDirectory(directory), [directory]),
|
||||
);
|
||||
const refreshMcp = useMcpStore((state) => state.refresh);
|
||||
const ensureMcpFresh = useMcpStore((state) => state.ensureFresh);
|
||||
const connect = useMcpStore((state) => state.connect);
|
||||
const disconnect = useMcpStore((state) => state.disconnect);
|
||||
const [busyServer, setBusyServer] = React.useState<string | null>(null);
|
||||
|
||||
// The panel must not depend on the header dropdown having been mounted or
|
||||
// opened to know its MCP servers. Silent and background-gated, so it cannot
|
||||
// compete with chat bootstrap traffic for sockets.
|
||||
// compete with chat bootstrap traffic for sockets. The section remounts on
|
||||
// every session switch, so it only asks for a status that is missing or
|
||||
// older than a minute; connect/disconnect/auth refresh on their own.
|
||||
React.useEffect(() => {
|
||||
void runBackgroundNetworkTask(() => refreshMcp({ directory, silent: true }));
|
||||
}, [directory, refreshMcp]);
|
||||
void runBackgroundNetworkTask(() => ensureMcpFresh({ directory, silent: true, maxAgeMs: MCP_STATUS_MAX_AGE_MS }));
|
||||
}, [directory, ensureMcpFresh]);
|
||||
|
||||
const mcpServers = React.useMemo(
|
||||
() => Object.entries(mcpStatus ?? {}).sort(([left], [right]) => left.localeCompare(right)),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user