merge main to pick up German locale for custom provider keys
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
@@ -9,8 +9,11 @@ export const IpadSidebarResizeHandle: React.FC<{
|
||||
handleProps: React.HTMLAttributes<HTMLDivElement>;
|
||||
}> = ({ side, isResizing, ariaLabel, handleProps }) => (
|
||||
<div
|
||||
// z-50 AND rendered after the panel content: panes bring their own
|
||||
// full-cover overlays at z-50 (the file editor, for one), and a handle
|
||||
// underneath them is simply not there for the finger.
|
||||
className={cn(
|
||||
'absolute inset-y-0 z-30 w-6 cursor-col-resize touch-none',
|
||||
'absolute inset-y-0 z-50 w-6 cursor-col-resize touch-none',
|
||||
side === 'left' ? 'right-0' : 'left-0',
|
||||
)}
|
||||
role="separator"
|
||||
|
||||
+194
-343
@@ -1,18 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
import { McpIcon } from '@/components/icons/McpIcon';
|
||||
import { McpDropdownContent } from '@/components/mcp/McpDropdown';
|
||||
import { AboutSettings } from '@/components/sections/openchamber/AboutSettings';
|
||||
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
|
||||
import { MobileAppUpdateToast } from '@/components/update/MobileAppUpdateToast';
|
||||
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { ChatView } from '@/components/views/ChatView';
|
||||
import { PlanView } from '@/components/views/PlanView';
|
||||
import { SettingsView } from '@/components/views/SettingsView';
|
||||
import { TerminalView } from '@/components/views/TerminalView';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
|
||||
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
@@ -24,9 +20,9 @@ import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { useOrientation } from '@/lib/device';
|
||||
import { readTabletLayout, useOrientation, useTabletLayout } from '@/lib/device';
|
||||
import { useHardwareKeyboard } from '@/lib/hardwareKeyboard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { isIPadApp } from '@/lib/platform';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
@@ -36,9 +32,8 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useGitStatus, useGitStore } from '@/stores/useGitStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useMcpConfigStore, type McpDraft } from '@/stores/useMcpConfigStore';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import {
|
||||
listProjectWorktrees,
|
||||
@@ -51,21 +46,16 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { SyncProvider } from '@/sync/sync-context';
|
||||
|
||||
import { SyncAppEffects } from './AppEffects';
|
||||
import { ProjectContextPanel } from '@/components/layout/RightSidebarTabs';
|
||||
import { MobileChangesSurface } from './MobileChangesSurface';
|
||||
import { MobileFilesSurface } from './MobileFilesSurface';
|
||||
import { BusyDots } from '@/components/chat/message/parts/BusyDots';
|
||||
import { MobileConnectionWelcome, type MobileConnectionNotice } from './MobileConnectionWelcome';
|
||||
import { MobileHeader } from './MobileHeader';
|
||||
import { MobileInstancesSurface } from './MobileInstancesSurface';
|
||||
import { MobileOverflowMenu, type OverflowItem } from './MobileOverflowMenu';
|
||||
import { MobileSessionsSheet } from './MobileSessionsSheet';
|
||||
import { MobileFullscreenSurface } from './MobileFullscreenSurface';
|
||||
import { MobileWorkspaceDrawer, type MobileWorkspaceTab } from './MobileWorkspaceDrawer';
|
||||
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
|
||||
import { autoConnectLastInstance, getAutoConnectTargetLabel, reprobeActiveConnection, type AutoConnectOutcome } from './mobileConnections';
|
||||
import { isCapacitorMobileApp, useNativeAndroidBackButton, useNativeMobileChrome, useNativeMobileLifecycle } from './mobileNativeChrome';
|
||||
import { normalizePath } from './mobilePaths';
|
||||
import { reconnectAppForTransportSwitch, resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
|
||||
import { useAppFontEffects } from './useAppFontEffects';
|
||||
import { useFontsReady } from './useFontsReady';
|
||||
@@ -76,6 +66,7 @@ import { IpadSidebarResizeHandle } from './IpadSidebarResizeHandle';
|
||||
import {
|
||||
IPAD_LEFT_SIDEBAR_WIDTH,
|
||||
IPAD_RIGHT_SIDEBAR_WIDTH,
|
||||
IPAD_WORKSPACE_SIDEBAR_MAX_WIDTH,
|
||||
useIpadSidebarResize,
|
||||
} from './ipadSidebarResize';
|
||||
|
||||
@@ -101,12 +92,11 @@ type MobileAppProps = {
|
||||
|
||||
const NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS = 1_000;
|
||||
|
||||
/** The fullscreen overlay surfaces reachable from the overflow menu. Exactly
|
||||
one can be open at a time — opening another replaces it, closing returns
|
||||
to the chat. The sessions drawer, the workspace drawer (Changes / Files /
|
||||
Terminal tabs on phones), and the overflow menu are separate layers.
|
||||
'terminal' is iPad-only here — phones get it as a workspace tab. */
|
||||
type MobileSurface = 'terminal' | 'mcp' | 'notes' | 'instances' | 'settings' | 'update';
|
||||
/** The fullscreen app-level surfaces, reachable from the sessions drawer
|
||||
footer. Exactly one can be open at a time — opening another replaces it,
|
||||
closing returns to the chat. The sessions drawer and the workspace drawer
|
||||
(Changes / Files / Terminal / Notes / MCP) are separate layers. */
|
||||
type MobileSurface = 'instances' | 'settings' | 'update';
|
||||
|
||||
const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onActiveConnectionDeleted }) => {
|
||||
const { t } = useI18n();
|
||||
@@ -116,26 +106,20 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
// open/close so the right-edge swipe reopens where the user left off.
|
||||
const [workspaceOpen, setWorkspaceOpen] = React.useState(false);
|
||||
const [workspaceTab, setWorkspaceTab] = React.useState<MobileWorkspaceTab>('changes');
|
||||
const [isMcpRefreshing, setIsMcpRefreshing] = React.useState(false);
|
||||
// A plan opened from the Project notes surface, shown as a second fullscreen
|
||||
// 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<{ path: string; title: string } | null>(null);
|
||||
const [settingsInitialMobileStage, setSettingsInitialMobileStage] = React.useState<'nav' | 'page-content'>('nav');
|
||||
const [overflowOpen, setOverflowOpen] = React.useState(false);
|
||||
// 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);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const wideChatLayoutEnabled = useUIStore((state) => state.wideChatLayoutEnabled);
|
||||
const updateAvailable = useUpdateStore((state) => state.available);
|
||||
const updateRuntimeType = useUpdateStore((state) => state.runtimeType);
|
||||
const showCapacitorOnlyFeatures = React.useMemo(() => isCapacitorMobileApp(), []);
|
||||
const mcpServers = useMcpConfigStore((state) => state.mcpServers);
|
||||
const setMcpDraft = useMcpConfigStore((state) => state.setMcpDraft);
|
||||
const setSelectedMcp = useMcpConfigStore((state) => state.setSelectedMcp);
|
||||
const refreshMcpStatus = useMcpStore((state) => state.refresh);
|
||||
const loadMcpConfigs = useMcpConfigStore((state) => state.loadMcpConfigs);
|
||||
const gitStatus = useGitStatus(normalizePath(currentDirectory) || null);
|
||||
const dirtyChangeCount = gitStatus?.files?.length ?? 0;
|
||||
|
||||
// NOTE: pendingChangesDiff is intentionally NOT cleared on close — it keys
|
||||
// the persistent Changes pane in the workspace drawer, and clearing it would
|
||||
@@ -158,76 +142,93 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
openSurface('settings');
|
||||
}, [openSurface]);
|
||||
|
||||
// iPad (Capacitor): sessions live in a persistent full-height left sidebar
|
||||
// and Changes/Files in a right sidebar, instead of phone sheets/surfaces.
|
||||
const isIPad = React.useMemo(() => isIPadApp(), []);
|
||||
// Tablet: sessions live in a persistent full-height left sidebar instead of
|
||||
// the phone's drawer. Everything else — the workspace drawer, the header, the
|
||||
// app-level surfaces — is shared with phones.
|
||||
//
|
||||
// A SIZE class, not a device check: an unfolded book foldable is a tablet
|
||||
// until it is folded shut, and the shell keeps running across that change.
|
||||
const { enabled: isTabletLayout, roomyForPanels } = useTabletLayout();
|
||||
const orientation = useOrientation();
|
||||
const isPortrait = orientation === 'portrait';
|
||||
const [ipadSidebarOpen, setIpadSidebarOpen] = React.useState(isIPad && !isPortrait);
|
||||
const [ipadRightPanel, setIpadRightPanel] = React.useState<'files' | 'changes' | null>(null);
|
||||
const hasHardwareKeyboard = useHardwareKeyboard();
|
||||
const [sidebarOpen, setSidebarOpen] = React.useState(() => readTabletLayout().roomyForPanels);
|
||||
|
||||
const toggleIpadSidebar = React.useCallback(() => {
|
||||
const willOpen = !ipadSidebarOpen;
|
||||
// Portrait doesn't fit both side panels next to a usable chat column:
|
||||
// opening one closes the other (iPadOS behaves the same way).
|
||||
if (willOpen && isPortrait) setIpadRightPanel(null);
|
||||
setIpadSidebarOpen(willOpen);
|
||||
}, [ipadSidebarOpen, isPortrait]);
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
setSidebarOpen((current: boolean) => !current);
|
||||
}, []);
|
||||
|
||||
// Folding shut (or losing the room for a side-by-side layout) must not leave
|
||||
// a sidebar open over a phone-width screen.
|
||||
React.useEffect(() => {
|
||||
if (!isTabletLayout) setSidebarOpen(false);
|
||||
}, [isTabletLayout]);
|
||||
|
||||
const openFilesSurface = React.useCallback(() => {
|
||||
if (isIPad) {
|
||||
setPendingChangesDiff(null);
|
||||
setIpadRightPanel('files');
|
||||
if (isPortrait) setIpadSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
setPendingChangesDiff(null);
|
||||
setWorkspaceTab('files');
|
||||
setWorkspaceOpen(true);
|
||||
}, [isIPad, isPortrait]);
|
||||
}, []);
|
||||
|
||||
const openChangesSurface = React.useCallback((diff: { path: string; staged: boolean } | null = null) => {
|
||||
setPendingChangesDiff(diff);
|
||||
if (isIPad) {
|
||||
setIpadRightPanel('changes');
|
||||
if (isPortrait) setIpadSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
setWorkspaceTab('changes');
|
||||
setWorkspaceOpen(true);
|
||||
}, [isIPad, isPortrait]);
|
||||
|
||||
const closeIpadRightPanel = React.useCallback(() => {
|
||||
setIpadRightPanel(null);
|
||||
setPendingChangesDiff(null);
|
||||
}, []);
|
||||
|
||||
const toggleIpadRightPanel = React.useCallback((panel: 'files' | 'changes') => {
|
||||
if (ipadRightPanel === panel) {
|
||||
closeIpadRightPanel();
|
||||
return;
|
||||
}
|
||||
if (panel === 'files') openFilesSurface();
|
||||
else openChangesSurface();
|
||||
}, [closeIpadRightPanel, ipadRightPanel, openChangesSurface, openFilesSurface]);
|
||||
|
||||
// Keep the right panel's content mounted through the width-collapse
|
||||
// animation; drop it once the panel is fully closed.
|
||||
const lastIpadRightPanelRef = React.useRef<'files' | 'changes'>('changes');
|
||||
if (ipadRightPanel) lastIpadRightPanelRef.current = ipadRightPanel;
|
||||
const [ipadRightContentMounted, setIpadRightContentMounted] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
if (!isIPad) return;
|
||||
if (ipadRightPanel) {
|
||||
setIpadRightContentMounted(true);
|
||||
return;
|
||||
}
|
||||
const id = window.setTimeout(() => setIpadRightContentMounted(false), 240);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [ipadRightPanel, isIPad]);
|
||||
const renderedIpadRightPanel = ipadRightPanel ?? lastIpadRightPanelRef.current;
|
||||
|
||||
const leftResize = useIpadSidebarResize('left', 'openchamber.ipad.leftSidebarWidth', IPAD_LEFT_SIDEBAR_WIDTH);
|
||||
const rightResize = useIpadSidebarResize('right', 'openchamber.ipad.rightSidebarWidth', IPAD_RIGHT_SIDEBAR_WIDTH);
|
||||
const rightResize = useIpadSidebarResize(
|
||||
'right',
|
||||
'openchamber.ipad.rightSidebarWidth',
|
||||
IPAD_RIGHT_SIDEBAR_WIDTH,
|
||||
IPAD_WORKSPACE_SIDEBAR_MAX_WIDTH,
|
||||
);
|
||||
// The workspace becomes a real side panel only where the screen can host the
|
||||
// sidebar, the panel AND a readable chat at once. Everywhere else — a tablet
|
||||
// in portrait, and an unfolded foldable in EITHER orientation, since its long
|
||||
// side is barely wider than a tablet's short one — it stays the full-cover
|
||||
// drawer, which is the layout that actually works at that width.
|
||||
const workspaceAsPanel = roomyForPanels;
|
||||
const workspacePanelWidth = workspaceAsPanel && workspaceOpen ? rightResize.width : 0;
|
||||
const sidebarWidth = isTabletLayout && sidebarOpen ? leftResize.width : 0;
|
||||
|
||||
// Publish the chat column's insets so overlays portaled to <body> (model
|
||||
// picker, directory picker, every MobileOverlayPanel) can center on the CHAT
|
||||
// rather than on the window. Zero on phones, where the two are the same.
|
||||
React.useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--oc-chat-inset-left', `${sidebarWidth}px`);
|
||||
root.style.setProperty('--oc-chat-inset-right', `${workspacePanelWidth}px`);
|
||||
return () => {
|
||||
root.style.removeProperty('--oc-chat-inset-left');
|
||||
root.style.removeProperty('--oc-chat-inset-right');
|
||||
};
|
||||
}, [sidebarWidth, workspacePanelWidth]);
|
||||
|
||||
// Wide chat layout: the shared chat columns key off this root class, but only
|
||||
// the desktop App set it — so on a tablet, where the chat column is finally
|
||||
// wide enough for the setting to mean something, it did nothing. Applied for
|
||||
// every mobile surface; on a phone the viewport is narrower than even the
|
||||
// normal clamp, so it is a no-op there.
|
||||
React.useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('wide-chat-layout', wideChatLayoutEnabled);
|
||||
return () => root.classList.remove('wide-chat-layout');
|
||||
}, [wideChatLayoutEnabled]);
|
||||
|
||||
// The draft screen keeps its starter chips while the keyboard is up when
|
||||
// there is room for both: a tablet in portrait, or any tablet orientation
|
||||
// with a hardware keyboard (then no software keyboard eats the screen at
|
||||
// all). Landscape on the software keyboard still hides them — see mobile.css.
|
||||
React.useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const keep = isTabletLayout && (isPortrait || hasHardwareKeyboard);
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('oc-keep-draft-starters', keep);
|
||||
return () => root.classList.remove('oc-keep-draft-starters');
|
||||
}, [hasHardwareKeyboard, isTabletLayout, isPortrait]);
|
||||
|
||||
const mobileActions = React.useMemo<MobileAppActions>(
|
||||
() => ({
|
||||
@@ -246,7 +247,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
const deepLinkHandlers = React.useMemo(
|
||||
() => ({
|
||||
openSessions: () => {
|
||||
if (isIPad) setIpadSidebarOpen(true);
|
||||
if (isTabletLayout) setSidebarOpen(true);
|
||||
else setSessionsSheetOpen(true);
|
||||
},
|
||||
openView: (target: 'files' | 'mcp' | 'instances' | 'update') => {
|
||||
@@ -254,8 +255,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
openFilesSurface();
|
||||
return;
|
||||
}
|
||||
// Phones host MCP as a workspace tab now; iPad still uses the surface.
|
||||
if (target === 'mcp' && !isIPad) {
|
||||
if (target === 'mcp') {
|
||||
setWorkspaceTab('mcp');
|
||||
setWorkspaceOpen(true);
|
||||
return;
|
||||
@@ -270,37 +270,25 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
openSettingsSurface(section ? 'page-content' : 'nav');
|
||||
},
|
||||
}),
|
||||
[isIPad, openChangesSurface, openFilesSurface, openSettingsSurface, openSurface, setSettingsPage],
|
||||
[isTabletLayout, openChangesSurface, openFilesSurface, openSettingsSurface, openSurface, setSettingsPage],
|
||||
);
|
||||
useDeepLinkHandlers(deepLinkHandlers);
|
||||
|
||||
// Edge swipes on the chat: left edge opens the sessions drawer (the iPad
|
||||
// sidebar on iPad), right edge reopens the most recent overflow surface
|
||||
// (the last right panel on iPad).
|
||||
// Edge swipes on the chat: left edge opens the sessions drawer (the
|
||||
// persistent sidebar on a tablet), right edge the workspace drawer.
|
||||
const chatMainRef = React.useRef<HTMLElement>(null);
|
||||
useEdgeSwipe(chatMainRef, {
|
||||
onLeftEdgeSwipe: () => {
|
||||
if (isIPad) setIpadSidebarOpen(true);
|
||||
if (isTabletLayout) setSidebarOpen(true);
|
||||
else setSessionsSheetOpen(true);
|
||||
},
|
||||
onRightEdgeSwipe: () => {
|
||||
if (isIPad) {
|
||||
if (lastIpadRightPanelRef.current === 'files') openFilesSurface();
|
||||
else openChangesSurface();
|
||||
return;
|
||||
}
|
||||
setWorkspaceOpen(true);
|
||||
},
|
||||
onRightEdgeSwipe: () => setWorkspaceOpen(true),
|
||||
});
|
||||
|
||||
// Top-most layer first: a plan or fullscreen surface can now sit ABOVE a
|
||||
// drawer (opened from the drawer footer / workspace tabs), so they close
|
||||
// before the drawers underneath.
|
||||
// Top-most layer first: a plan or fullscreen surface can sit ABOVE a drawer
|
||||
// (opened from the drawer footer / workspace tabs), so they close before the
|
||||
// drawers underneath.
|
||||
const handleNativeBack = React.useCallback(() => {
|
||||
if (overflowOpen) {
|
||||
setOverflowOpen(false);
|
||||
return true;
|
||||
}
|
||||
if (openPlan) {
|
||||
setOpenPlan(null);
|
||||
return true;
|
||||
@@ -318,7 +306,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [activeSurface, closeSurface, closeWorkspace, openPlan, overflowOpen, sessionsSheetOpen, workspaceOpen]);
|
||||
}, [activeSurface, closeSurface, closeWorkspace, openPlan, sessionsSheetOpen, workspaceOpen]);
|
||||
|
||||
useNativeAndroidBackButton(handleNativeBack);
|
||||
|
||||
@@ -329,6 +317,22 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
&& updateAvailable
|
||||
&& (updateRuntimeType === 'desktop' || updateRuntimeType === 'web');
|
||||
|
||||
// Tablets pack the app-level pages (settings, instances, a plan) into a
|
||||
// centered dialog instead of covering the whole screen.
|
||||
const surfaceVariant = isTabletLayout ? 'dialog' as const : 'fullscreen' as const;
|
||||
|
||||
// App-level footer of the sessions list — the same on a phone drawer and a
|
||||
// tablet sidebar: connected instance, pending web update, settings.
|
||||
const sessionsFooter = React.useMemo(
|
||||
() => ({
|
||||
instanceLabel: showCapacitorOnlyFeatures ? getAutoConnectTargetLabel() : null,
|
||||
onOpenInstances: showCapacitorOnlyFeatures ? () => openSurface('instances') : undefined,
|
||||
onOpenSettings: () => openSettingsSurface('nav'),
|
||||
onOpenUpdate: showUpdateItem ? () => openSurface('update') : undefined,
|
||||
}),
|
||||
[openSettingsSurface, openSurface, showCapacitorOnlyFeatures, showUpdateItem],
|
||||
);
|
||||
|
||||
const openMcpCreateSettings = React.useCallback(() => {
|
||||
const baseName = 'new-mcp-server';
|
||||
let newName = baseName;
|
||||
@@ -361,70 +365,6 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
openSettingsSurface('page-content');
|
||||
}, [mcpServers, openSettingsSurface, setMcpDraft, setSelectedMcp, setSettingsPage]);
|
||||
|
||||
const refreshMcpOverlay = React.useCallback(() => {
|
||||
if (isMcpRefreshing) return;
|
||||
setIsMcpRefreshing(true);
|
||||
const directory = currentDirectory || null;
|
||||
const minSpinPromise = new Promise((resolve) => window.setTimeout(resolve, 500));
|
||||
void Promise.all([
|
||||
refreshMcpStatus({ directory, silent: true }),
|
||||
loadMcpConfigs({ force: true }),
|
||||
minSpinPromise,
|
||||
]).finally(() => setIsMcpRefreshing(false));
|
||||
}, [currentDirectory, isMcpRefreshing, loadMcpConfigs, refreshMcpStatus]);
|
||||
|
||||
const overflowItems: OverflowItem[] = React.useMemo(
|
||||
() => {
|
||||
const items: OverflowItem[] = [];
|
||||
// Phones get Files/Changes/Terminal as workspace-drawer tabs; the iPad
|
||||
// exposes Files/Changes as header shortcuts and keeps Terminal here.
|
||||
if (isIPad) {
|
||||
items.push({
|
||||
key: 'terminal',
|
||||
icon: 'terminal',
|
||||
label: t('mobile.menu.terminal'),
|
||||
onSelect: () => openSurface('terminal'),
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
key: 'mcp',
|
||||
iconNode: <McpIcon className="size-5 shrink-0 text-muted-foreground" />,
|
||||
label: t('mobile.menu.mcp'),
|
||||
onSelect: () => openSurface('mcp'),
|
||||
});
|
||||
items.push({
|
||||
key: 'notes',
|
||||
icon: 'sticky-note',
|
||||
label: t('contextRail.surface.notes'),
|
||||
onSelect: () => openSurface('notes'),
|
||||
});
|
||||
if (showCapacitorOnlyFeatures) {
|
||||
items.push({
|
||||
key: 'instances',
|
||||
icon: 'server',
|
||||
label: t('mobile.menu.instances'),
|
||||
onSelect: () => openSurface('instances'),
|
||||
});
|
||||
}
|
||||
if (showUpdateItem) {
|
||||
items.push({
|
||||
key: 'update',
|
||||
icon: 'download',
|
||||
label: t('mobile.menu.update'),
|
||||
onSelect: () => openSurface('update'),
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
key: 'settings',
|
||||
icon: 'settings-3',
|
||||
label: t('mobile.menu.settings'),
|
||||
onSelect: () => openSettingsSurface('nav'),
|
||||
});
|
||||
return items;
|
||||
},
|
||||
[isIPad, openSettingsSurface, openSurface, showCapacitorOnlyFeatures, showUpdateItem, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<DedicatedMobileAppProvider actions={mobileActions}>
|
||||
<div
|
||||
@@ -434,17 +374,21 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
{/* iPad: persistent full-height sessions sidebar; the chat column and
|
||||
its header butt against it (iPadOS-style split layout). Always
|
||||
mounted so open/close animates width, same as the desktop Sidebar. */}
|
||||
{isIPad ? (
|
||||
{isTabletLayout ? (
|
||||
<aside
|
||||
ref={leftResize.asideRef}
|
||||
className={cn(
|
||||
'relative flex h-full shrink-0 flex-col overflow-hidden border-r border-border/70 bg-sidebar will-change-[width] motion-reduce:transition-none',
|
||||
!ipadSidebarOpen && 'border-r-0',
|
||||
// bg-background, not bg-sidebar: the session/worktree/project
|
||||
// rows are opaque bg-background (the swipe actions live under
|
||||
// them), so a tinted sidebar would show through as row-shaped
|
||||
// bands. Same surface as the phone drawer.
|
||||
'relative flex h-full shrink-0 flex-col overflow-hidden border-r border-border/70 bg-background will-change-[width] motion-reduce:transition-none',
|
||||
!sidebarOpen && 'border-r-0',
|
||||
)}
|
||||
style={{
|
||||
width: ipadSidebarOpen ? leftResize.width : 0,
|
||||
minWidth: ipadSidebarOpen ? leftResize.width : 0,
|
||||
maxWidth: ipadSidebarOpen ? leftResize.width : 0,
|
||||
width: sidebarOpen ? leftResize.width : 0,
|
||||
minWidth: sidebarOpen ? leftResize.width : 0,
|
||||
maxWidth: sidebarOpen ? leftResize.width : 0,
|
||||
['--oc-ipad-sidebar-width' as string]: `${leftResize.width}px`,
|
||||
overflowX: 'clip',
|
||||
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
||||
@@ -452,22 +396,14 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
transitionDuration: '200ms',
|
||||
transitionTimingFunction: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
}}
|
||||
aria-hidden={!ipadSidebarOpen}
|
||||
aria-hidden={!sidebarOpen}
|
||||
data-page-scroll-lock="true"
|
||||
>
|
||||
{ipadSidebarOpen ? (
|
||||
<IpadSidebarResizeHandle
|
||||
side="left"
|
||||
isResizing={leftResize.isResizing}
|
||||
ariaLabel={t('sidebar.resize.leftPanelAria')}
|
||||
handleProps={leftResize.handleProps}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full shrink-0 flex-col transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
|
||||
leftResize.isResizing && 'pointer-events-none',
|
||||
!ipadSidebarOpen && 'pointer-events-none select-none opacity-0',
|
||||
!sidebarOpen && 'pointer-events-none select-none opacity-0',
|
||||
)}
|
||||
style={{ width: 'var(--oc-ipad-sidebar-width)', overflowX: 'hidden' }}
|
||||
>
|
||||
@@ -475,32 +411,34 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
<MobileSessionsSheet
|
||||
open
|
||||
variant="sidebar"
|
||||
// The surface asks to close after picking a session/project or
|
||||
// creating a worktree; the persistent landscape sidebar stays
|
||||
// put, portrait gives the space back to the chat.
|
||||
// The surface asks to close after picking a session/project
|
||||
// or creating a worktree: give the space back to the chat
|
||||
// where the sidebar is a guest, keep it put where it is not.
|
||||
onOpenChange={(value) => {
|
||||
if (!value && isPortrait) setIpadSidebarOpen(false);
|
||||
if (!value && !roomyForPanels) setSidebarOpen(false);
|
||||
}}
|
||||
footer={sessionsFooter}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
{/* After the content, not before it: panes stack their own overlays
|
||||
and the handle has to sit above every one of them. */}
|
||||
{sidebarOpen ? (
|
||||
<IpadSidebarResizeHandle
|
||||
side="left"
|
||||
isResizing={leftResize.isResizing}
|
||||
ariaLabel={t('sidebar.resize.leftPanelAria')}
|
||||
handleProps={leftResize.handleProps}
|
||||
/>
|
||||
) : null}
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col" data-page-scroll-lock="true">
|
||||
<MobileHeader
|
||||
onOpenSessions={() => (isIPad ? toggleIpadSidebar() : setSessionsSheetOpen(true))}
|
||||
// Phones dropped the overflow menu: its items live in the sessions
|
||||
// drawer footer and the workspace tabs now. iPad keeps it until
|
||||
// the dedicated iPad layout pass.
|
||||
onOpenMenu={isIPad ? () => setOverflowOpen(true) : undefined}
|
||||
onOpenWorkspace={isIPad ? undefined : () => setWorkspaceOpen(true)}
|
||||
surfaceShortcuts={isIPad ? {
|
||||
activePanel: ipadRightPanel,
|
||||
changesDirty: dirtyChangeCount > 0,
|
||||
onToggleFiles: () => toggleIpadRightPanel('files'),
|
||||
onToggleChanges: () => toggleIpadRightPanel('changes'),
|
||||
} : undefined}
|
||||
onOpenSessions={() => (isTabletLayout ? toggleSidebar() : setSessionsSheetOpen(true))}
|
||||
onOpenWorkspace={() => setWorkspaceOpen(true)}
|
||||
compactTitle={isTabletLayout}
|
||||
/>
|
||||
<main ref={chatMainRef} className="relative min-h-0 flex-1 overflow-hidden" data-page-scroll-lock="true">
|
||||
<div className="h-full w-full">
|
||||
@@ -511,20 +449,33 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* iPad: Changes/Files live in a full-height right sidebar instead of
|
||||
the phone's fullscreen surfaces. Width animates like the desktop
|
||||
RightSidebar; content stays mounted through the collapse. */}
|
||||
{isIPad ? (
|
||||
{/* Mounted permanently on phones (parked off-screen while closed) so
|
||||
the sessions/worktree state stays warm and the drawer opens with
|
||||
data already on screen — see MobileSessionsDrawerContainer. */}
|
||||
{!isTabletLayout ? (
|
||||
<MobileSessionsSheet
|
||||
open={sessionsSheetOpen}
|
||||
onOpenChange={setSessionsSheetOpen}
|
||||
footer={sessionsFooter}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Tablet: the workspace lives inside an animated aside so landscape
|
||||
gets a real sidebar. The drawer element keeps its position in the
|
||||
tree across rotation — only its `variant` changes — so the mounted
|
||||
panes (open diff, edited file, attached terminal) survive it. In
|
||||
portrait the drawer portals itself out and this aside stays at 0. */}
|
||||
{isTabletLayout ? (
|
||||
<aside
|
||||
ref={rightResize.asideRef}
|
||||
className={cn(
|
||||
'relative flex h-full shrink-0 flex-col overflow-hidden border-l border-border/70 bg-background will-change-[width] motion-reduce:transition-none',
|
||||
!ipadRightPanel && 'border-l-0',
|
||||
!workspacePanelWidth && 'border-l-0',
|
||||
)}
|
||||
style={{
|
||||
width: ipadRightPanel ? rightResize.width : 0,
|
||||
minWidth: ipadRightPanel ? rightResize.width : 0,
|
||||
maxWidth: ipadRightPanel ? rightResize.width : 0,
|
||||
width: workspacePanelWidth,
|
||||
minWidth: workspacePanelWidth,
|
||||
maxWidth: workspacePanelWidth,
|
||||
['--oc-ipad-sidebar-width' as string]: `${rightResize.width}px`,
|
||||
overflowX: 'clip',
|
||||
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
||||
@@ -532,10 +483,31 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
transitionDuration: '200ms',
|
||||
transitionTimingFunction: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
}}
|
||||
aria-hidden={!ipadRightPanel}
|
||||
aria-hidden={!workspacePanelWidth}
|
||||
data-page-scroll-lock="true"
|
||||
>
|
||||
{ipadRightPanel ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full min-h-0 shrink-0 flex-col transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
|
||||
rightResize.isResizing && 'pointer-events-none',
|
||||
!workspacePanelWidth && 'pointer-events-none select-none opacity-0',
|
||||
)}
|
||||
style={{ width: 'var(--oc-ipad-sidebar-width)', overflowX: 'hidden' }}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<MobileWorkspaceDrawer
|
||||
open={workspaceOpen}
|
||||
onClose={closeWorkspace}
|
||||
tab={workspaceTab}
|
||||
onTabChange={setWorkspaceTab}
|
||||
pendingChangesDiff={pendingChangesDiff}
|
||||
onOpenPlan={setOpenPlan}
|
||||
onOpenMcpSettings={openMcpCreateSettings}
|
||||
variant={workspaceAsPanel ? 'panel' : 'drawer'}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
{workspacePanelWidth ? (
|
||||
<IpadSidebarResizeHandle
|
||||
side="right"
|
||||
isResizing={rightResize.isResizing}
|
||||
@@ -543,61 +515,8 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
handleProps={rightResize.handleProps}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full shrink-0 flex-col transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
|
||||
rightResize.isResizing && 'pointer-events-none',
|
||||
!ipadRightPanel && 'pointer-events-none select-none opacity-0',
|
||||
)}
|
||||
style={{ width: 'var(--oc-ipad-sidebar-width)', overflowX: 'hidden' }}
|
||||
>
|
||||
{ipadRightContentMounted ? (
|
||||
<ErrorBoundary>
|
||||
{renderedIpadRightPanel === 'files' ? (
|
||||
<MobileFilesSurface onClose={closeIpadRightPanel} />
|
||||
) : (
|
||||
<MobileChangesSurface
|
||||
onClose={closeIpadRightPanel}
|
||||
initialDiffPath={pendingChangesDiff?.path ?? null}
|
||||
initialDiffStaged={pendingChangesDiff?.staged === true}
|
||||
/>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
{isIPad ? (
|
||||
<MobileOverflowMenu
|
||||
open={overflowOpen}
|
||||
onClose={() => setOverflowOpen(false)}
|
||||
items={overflowItems}
|
||||
rightOffset={ipadRightPanel ? rightResize.width : 0}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Mounted permanently on phones (parked off-screen while closed) so
|
||||
the sessions/worktree state stays warm and the drawer opens with
|
||||
data already on screen — see MobileSessionsDrawerContainer. */}
|
||||
{!isIPad ? (
|
||||
<MobileSessionsSheet
|
||||
open={sessionsSheetOpen}
|
||||
onOpenChange={setSessionsSheetOpen}
|
||||
footer={{
|
||||
instanceLabel: showCapacitorOnlyFeatures ? getAutoConnectTargetLabel() : null,
|
||||
onOpenInstances: showCapacitorOnlyFeatures ? () => openSurface('instances') : undefined,
|
||||
onOpenSettings: () => openSettingsSurface('nav'),
|
||||
onOpenUpdate: showUpdateItem ? () => openSurface('update') : undefined,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Mounted only while open (like the sessions sheet) so each surface
|
||||
computes its safe-area / fixed-position layout fresh on open. Keeping
|
||||
them always-mounted left a stale startup layout, which made the
|
||||
top-inset dimming appear only intermittently on iOS. */}
|
||||
{!isIPad ? (
|
||||
) : (
|
||||
<MobileWorkspaceDrawer
|
||||
open={workspaceOpen}
|
||||
onClose={closeWorkspace}
|
||||
@@ -607,87 +526,13 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
onOpenPlan={setOpenPlan}
|
||||
onOpenMcpSettings={openMcpCreateSettings}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{activeSurface === 'terminal' ? (
|
||||
<MobileFullscreenSurface
|
||||
open
|
||||
onClose={closeSurface}
|
||||
ariaLabel={t('mobile.menu.terminal')}
|
||||
title={t('mobile.menu.terminal')}
|
||||
disableEscapeDismiss
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<TerminalView visible />
|
||||
</ErrorBoundary>
|
||||
</MobileFullscreenSurface>
|
||||
) : null}
|
||||
|
||||
{activeSurface === 'mcp' ? (
|
||||
<MobileFullscreenSurface
|
||||
open
|
||||
onClose={closeSurface}
|
||||
ariaLabel={t('mcpDropdown.title')}
|
||||
title={t('mcpDropdown.title')}
|
||||
trailing={(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
onClick={openMcpCreateSettings}
|
||||
aria-label={t('settings.mcp.sidebar.actions.addServerTitle')}
|
||||
title={t('settings.mcp.sidebar.actions.addServerTitle')}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="add" className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
onClick={refreshMcpOverlay}
|
||||
disabled={isMcpRefreshing}
|
||||
aria-label={t('mcpDropdown.actions.refreshAria')}
|
||||
title={t('mcpDropdown.actions.refreshAria')}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="refresh" className={cn('size-5', isMcpRefreshing && 'animate-spin')} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<McpDropdownContent
|
||||
active
|
||||
className="h-full"
|
||||
listClassName="max-h-none"
|
||||
hideHeader
|
||||
mobileListDensity
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</MobileFullscreenSurface>
|
||||
) : null}
|
||||
|
||||
{activeSurface === 'notes' ? (
|
||||
<MobileFullscreenSurface
|
||||
open
|
||||
onClose={closeSurface}
|
||||
ariaLabel={t('contextRail.surface.notes')}
|
||||
title={t('contextRail.surface.notes')}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<ProjectContextPanel
|
||||
onActionComplete={closeSurface}
|
||||
onOpenPlan={setOpenPlan}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</MobileFullscreenSurface>
|
||||
) : null}
|
||||
|
||||
{/* Layered above whichever surface opened it — the notes fullscreen
|
||||
surface (iPad) or the workspace drawer's Notes tab (phones). */}
|
||||
{/* Layered above the workspace drawer's Notes tab, which opened it. */}
|
||||
{openPlan ? (
|
||||
<MobileFullscreenSurface
|
||||
open
|
||||
variant={surfaceVariant}
|
||||
onClose={() => setOpenPlan(null)}
|
||||
ariaLabel={openPlan.title}
|
||||
title={openPlan.title}
|
||||
@@ -707,6 +552,8 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
{activeSurface === 'instances' && showCapacitorOnlyFeatures ? (
|
||||
<MobileFullscreenSurface
|
||||
open
|
||||
variant={surfaceVariant}
|
||||
dialogAlign="app"
|
||||
onClose={closeSurface}
|
||||
ariaLabel={t('mobile.menu.instances')}
|
||||
title={t('mobile.menu.instances')}
|
||||
@@ -721,6 +568,8 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
{activeSurface === 'settings' ? (
|
||||
<MobileFullscreenSurface
|
||||
open
|
||||
variant={surfaceVariant}
|
||||
dialogAlign="app"
|
||||
onClose={closeSurface}
|
||||
ariaLabel={t('mobile.menu.settings')}
|
||||
headerless
|
||||
@@ -744,6 +593,8 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
{activeSurface === 'update' ? (
|
||||
<MobileFullscreenSurface
|
||||
open
|
||||
variant={surfaceVariant}
|
||||
dialogAlign="app"
|
||||
onClose={closeSurface}
|
||||
ariaLabel={t('mobile.menu.update')}
|
||||
title={t('mobile.menu.update')}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { cn } from '@/lib/utils';
|
||||
import { connectionDisplayUrl, useMobileConnection } from './mobileConnections';
|
||||
import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
|
||||
import { mobileConnectionInputClass, mobileInputKeyboardProps } from './mobileConnectionUi';
|
||||
import { MobileQrConnectionLoading, MobileQrScannerOverlay } from './MobileQrScannerOverlay';
|
||||
|
||||
export type MobileConnectionNotice = {
|
||||
kind: 'unreachable' | 'auth-expired';
|
||||
@@ -27,6 +28,8 @@ export const MobileConnectionWelcome: React.FC<{
|
||||
const [connectionName, setConnectionName] = React.useState('');
|
||||
const [clientToken, setClientToken] = React.useState('');
|
||||
const [isScanning, setIsScanning] = React.useState(false);
|
||||
const [isCompletingScan, setIsCompletingScan] = React.useState(false);
|
||||
const scanAbortRef = React.useRef<AbortController | null>(null);
|
||||
const qrScanSupported = React.useMemo(() => isQrScanSupported(), []);
|
||||
// QR pairing is the primary flow; the manual URL form stays collapsed unless
|
||||
// scanning is unavailable (web build) or the user asks for it.
|
||||
@@ -60,19 +63,27 @@ export const MobileConnectionWelcome: React.FC<{
|
||||
}, [conn]);
|
||||
|
||||
const handleScanQr = React.useCallback(async () => {
|
||||
if (isScanning || isBusy) return;
|
||||
if (scanAbortRef.current || isBusy) return;
|
||||
conn.setError(null);
|
||||
setIsScanning(true);
|
||||
const controller = new AbortController();
|
||||
scanAbortRef.current = controller;
|
||||
try {
|
||||
const result = await scanConnectionQr();
|
||||
const result = await scanConnectionQr({ signal: controller.signal });
|
||||
if (scanAbortRef.current === controller) {
|
||||
scanAbortRef.current = null;
|
||||
setIsScanning(false);
|
||||
}
|
||||
switch (result.status) {
|
||||
case 'ok':
|
||||
setIsCompletingScan(true);
|
||||
setServerUrl(result.url);
|
||||
if (result.label) setConnectionName(result.label);
|
||||
if (result.clientToken) setClientToken(result.clientToken);
|
||||
await conn.connect({ url: result.url, clientToken: result.clientToken, label: result.label });
|
||||
break;
|
||||
case 'pairing':
|
||||
setIsCompletingScan(true);
|
||||
await conn.redeemPairingConnection(result.pairing);
|
||||
break;
|
||||
case 'permission-denied':
|
||||
@@ -92,9 +103,15 @@ export const MobileConnectionWelcome: React.FC<{
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
setIsCompletingScan(false);
|
||||
if (scanAbortRef.current === controller) {
|
||||
scanAbortRef.current = null;
|
||||
setIsScanning(false);
|
||||
}
|
||||
}
|
||||
}, [conn, isBusy, isScanning, t]);
|
||||
}, [conn, isBusy, t]);
|
||||
|
||||
React.useEffect(() => () => scanAbortRef.current?.abort(), []);
|
||||
|
||||
const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -107,6 +124,9 @@ export const MobileConnectionWelcome: React.FC<{
|
||||
}, [conn]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isScanning ? <MobileQrScannerOverlay onCancel={() => scanAbortRef.current?.abort()} /> : null}
|
||||
{isCompletingScan ? <MobileQrConnectionLoading /> : null}
|
||||
<main className="oc-keyboard-fill-screen flex min-h-dvh flex-col overflow-y-auto bg-background px-6 pb-[calc(var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))+28px)] pt-[calc(var(--safe-area-inset-top,env(safe-area-inset-top,0px))+28px)] text-foreground">
|
||||
<div className="m-auto flex w-full max-w-[360px] shrink-0 flex-col items-center gap-9 py-8">
|
||||
<div className="flex flex-col items-center gap-5 text-center">
|
||||
@@ -297,5 +317,6 @@ export const MobileConnectionWelcome: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,6 +35,18 @@ export type MobileFullscreenSurfaceProps = {
|
||||
/** Drop the header's bottom divider (quiet single-page surfaces). */
|
||||
noHeaderBorder?: boolean;
|
||||
ariaLabel?: string;
|
||||
/**
|
||||
* `dialog` packs the same surface into a centered card over a scrim instead
|
||||
* of covering the app. Tablets use it: a settings or instances page stretched
|
||||
* across a 13" screen is mostly empty space, and losing the chat entirely for
|
||||
* an app-level page is a heavier context switch than the content deserves.
|
||||
*/
|
||||
variant?: 'fullscreen' | 'dialog';
|
||||
/**
|
||||
* What the dialog centers on. App-level pages (settings, instances) belong to
|
||||
* the whole window; content that came out of the chat column stays with it.
|
||||
*/
|
||||
dialogAlign?: 'chat' | 'app';
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -51,6 +63,8 @@ export const MobileFullscreenSurface: React.FC<MobileFullscreenSurfaceProps> = (
|
||||
headerless = false,
|
||||
noHeaderBorder = false,
|
||||
ariaLabel,
|
||||
variant = 'fullscreen',
|
||||
dialogAlign = 'chat',
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -148,15 +162,29 @@ export const MobileFullscreenSurface: React.FC<MobileFullscreenSurfaceProps> = (
|
||||
|
||||
if (!open || !rootRef.current) return null;
|
||||
|
||||
return createPortal(
|
||||
const isDialog = variant === 'dialog';
|
||||
|
||||
const surface = (
|
||||
<section
|
||||
ref={surfaceRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={-1}
|
||||
className="oc-keyboard-inset-surface fixed inset-0 z-50 flex flex-col bg-background text-foreground"
|
||||
style={{
|
||||
className={cn(
|
||||
'flex flex-col bg-background text-foreground',
|
||||
isDialog
|
||||
? 'h-[min(88dvh,860px)] w-full max-w-[720px] overflow-hidden rounded-2xl border border-border/70 shadow-[0_24px_64px_rgb(0_0_0_/_0.32)]'
|
||||
: 'oc-keyboard-inset-surface fixed inset-0 z-50',
|
||||
)}
|
||||
style={isDialog ? {
|
||||
// Scale/fade instead of the push slide: the card is not a navigation
|
||||
// step, and a settled `transform: none` keeps it off its own
|
||||
// compositing layer (iOS clips those to the safe-area viewport).
|
||||
opacity: entered ? 1 : 0,
|
||||
transform: entered ? 'none' : 'scale(0.97)',
|
||||
transition: `opacity ${ENTER_DURATION_MS}ms ease-out, transform ${ENTER_DURATION_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`,
|
||||
} : {
|
||||
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
||||
// Push-style enter: slide in from the right edge; settled state drops
|
||||
// the transform entirely so the surface isn't kept on a compositing
|
||||
@@ -165,7 +193,7 @@ export const MobileFullscreenSurface: React.FC<MobileFullscreenSurfaceProps> = (
|
||||
transition: `transform ${ENTER_DURATION_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`,
|
||||
}}
|
||||
onTransitionEnd={(event) => {
|
||||
// Reveal content exactly when the enter slide ends — not on a fixed timer.
|
||||
// Reveal content exactly when the enter transition ends — not on a fixed timer.
|
||||
if (entered && event.target === event.currentTarget && event.propertyName === 'transform') {
|
||||
setContentReady(true);
|
||||
}
|
||||
@@ -214,7 +242,29 @@ export const MobileFullscreenSurface: React.FC<MobileFullscreenSurfaceProps> = (
|
||||
) : null}
|
||||
</div>
|
||||
<style>{'@keyframes oc-surface-content-in { from { opacity: 0 } to { opacity: 1 } }'}</style>
|
||||
</section>,
|
||||
</section>
|
||||
);
|
||||
|
||||
if (!isDialog) return createPortal(surface, rootRef.current);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="oc-keyboard-inset-surface fixed inset-0 z-50 flex items-center justify-center p-4 transition-opacity duration-200 ease-out"
|
||||
style={{
|
||||
background: 'rgb(0 0 0 / 0.45)',
|
||||
opacity: entered ? 1 : 0,
|
||||
// 'chat' matches every other mobile overlay (the sessions sidebar keeps
|
||||
// its width); 'app' ignores the panels and centers on the window.
|
||||
paddingLeft: dialogAlign === 'chat' ? 'max(1rem, var(--oc-chat-inset-left, 0px))' : '1rem',
|
||||
paddingRight: dialogAlign === 'chat' ? 'max(1rem, var(--oc-chat-inset-right, 0px))' : '1rem',
|
||||
paddingTop: 'max(1rem, var(--oc-safe-area-top, 0px))',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="flex w-full max-w-[720px] justify-center" onClick={(event) => event.stopPropagation()}>
|
||||
{surface}
|
||||
</div>
|
||||
</div>,
|
||||
rootRef.current,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,23 +10,14 @@ import { useSession } from '@/sync/sync-context';
|
||||
import { MobileSessionMetadataButton } from './MobileSessionMetadata';
|
||||
import { MobileSessionSwitcher } from './MobileSessionSwitcher';
|
||||
|
||||
export type MobileHeaderSurfaceShortcuts = {
|
||||
activePanel: 'files' | 'changes' | null;
|
||||
changesDirty: boolean;
|
||||
onToggleFiles: () => void;
|
||||
onToggleChanges: () => void;
|
||||
};
|
||||
|
||||
export const MobileHeader: React.FC<{
|
||||
onOpenSessions: () => void;
|
||||
/** iPad only for now: the legacy overflow menu. Phones distribute its items
|
||||
across the sessions drawer footer and the workspace drawer tabs. */
|
||||
onOpenMenu?: () => void;
|
||||
/** Phone only: opens the right workspace drawer (Changes / Files / Terminal). */
|
||||
onOpenWorkspace?: () => void;
|
||||
/** iPad only: Files/Changes header shortcuts that toggle the right sidebar. */
|
||||
surfaceShortcuts?: MobileHeaderSurfaceShortcuts;
|
||||
}> = ({ onOpenSessions, onOpenMenu, onOpenWorkspace, surfaceShortcuts }) => {
|
||||
/** Opens the right workspace drawer (Changes / Files / Terminal / Notes / MCP). */
|
||||
onOpenWorkspace: () => void;
|
||||
/** Tablet: size the title trigger to its text instead of the free width, so
|
||||
a wide header doesn't turn the switcher into a full-width tap target. */
|
||||
compactTitle?: boolean;
|
||||
}> = ({ onOpenSessions, onOpenWorkspace, compactTitle = false }) => {
|
||||
const { t } = useI18n();
|
||||
const [metadataOpen, setMetadataOpen] = React.useState(false);
|
||||
const [switcherOpen, setSwitcherOpen] = React.useState(false);
|
||||
@@ -57,12 +48,6 @@ export const MobileHeader: React.FC<{
|
||||
onOpenSessions();
|
||||
}, [onOpenSessions]);
|
||||
|
||||
const handleOpenMenu = React.useCallback(() => {
|
||||
setMetadataOpen(false);
|
||||
setSwitcherOpen(false);
|
||||
onOpenMenu?.();
|
||||
}, [onOpenMenu]);
|
||||
|
||||
// The two header popovers are mutually exclusive.
|
||||
const handleMetadataOpenChange = React.useCallback((value: boolean | ((open: boolean) => boolean)) => {
|
||||
setMetadataOpen((current) => {
|
||||
@@ -101,7 +86,10 @@ export const MobileHeader: React.FC<{
|
||||
<button
|
||||
ref={titleRef}
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center rounded-lg px-2 py-1.5 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
className={cn(
|
||||
'flex min-w-0 items-center rounded-lg px-2 py-1.5 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
compactTitle ? 'shrink' : 'flex-1',
|
||||
)}
|
||||
aria-label={t('sessions.switcher.openAria')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={switcherOpen}
|
||||
@@ -122,6 +110,10 @@ export const MobileHeader: React.FC<{
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Compact title: this takes the leftover width so the trailing
|
||||
controls stay pinned to the right edge. */}
|
||||
{compactTitle ? <div className="min-w-0 flex-1" /> : null}
|
||||
|
||||
<MobileSessionMetadataButton
|
||||
open={metadataOpen}
|
||||
onOpenChange={handleMetadataOpenChange}
|
||||
@@ -130,71 +122,19 @@ export const MobileHeader: React.FC<{
|
||||
isNewSessionDraftOpen={isNewSessionDraftOpen}
|
||||
/>
|
||||
|
||||
{surfaceShortcuts ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex size-10 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
surfaceShortcuts.activePanel === 'files'
|
||||
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selectionForeground)]'
|
||||
: 'text-muted-foreground hover:bg-interactive-hover hover:text-foreground',
|
||||
)}
|
||||
aria-label={t('mobile.menu.files')}
|
||||
aria-pressed={surfaceShortcuts.activePanel === 'files'}
|
||||
onClick={surfaceShortcuts.onToggleFiles}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="file-text" className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'relative flex size-10 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
surfaceShortcuts.activePanel === 'changes'
|
||||
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selectionForeground)]'
|
||||
: 'text-muted-foreground hover:bg-interactive-hover hover:text-foreground',
|
||||
)}
|
||||
aria-label={t('mobile.menu.changes')}
|
||||
aria-pressed={surfaceShortcuts.activePanel === 'changes'}
|
||||
onClick={surfaceShortcuts.onToggleChanges}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="git-branch" className="size-5" />
|
||||
{surfaceShortcuts.changesDirty ? (
|
||||
<span className="absolute right-2 top-2 inline-flex size-2 rounded-full bg-primary" aria-hidden />
|
||||
) : null}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{onOpenMenu ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.header.openMenuAria')}
|
||||
onClick={handleOpenMenu}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="more-2" className="size-5" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{onOpenWorkspace ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.header.openWorkspaceAria')}
|
||||
onClick={() => {
|
||||
setMetadataOpen(false);
|
||||
setSwitcherOpen(false);
|
||||
onOpenWorkspace();
|
||||
}}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="pencil-ruler-2" className="size-5" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.header.openWorkspaceAria')}
|
||||
onClick={() => {
|
||||
setMetadataOpen(false);
|
||||
setSwitcherOpen(false);
|
||||
onOpenWorkspace();
|
||||
}}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="pencil-ruler-2" className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<MobileSessionSwitcher
|
||||
|
||||
@@ -9,6 +9,7 @@ import { cn } from '@/lib/utils';
|
||||
import { connectionDisplayUrl, isActiveRuntimeConnection, useMobileConnection } from './mobileConnections';
|
||||
import { isQrScanSupported, scanConnectionQr } from './mobileQrScan';
|
||||
import { mobileConnectionInputClass, mobileInputKeyboardProps } from './mobileConnectionUi';
|
||||
import { MobileQrConnectionLoading, MobileQrScannerOverlay } from './MobileQrScannerOverlay';
|
||||
|
||||
export const MobileInstancesSurface: React.FC<{
|
||||
onConnect: () => void;
|
||||
@@ -28,6 +29,8 @@ export const MobileInstancesSurface: React.FC<{
|
||||
const [clientToken, setClientToken] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [isScanning, setIsScanning] = React.useState(false);
|
||||
const [isCompletingScan, setIsCompletingScan] = React.useState(false);
|
||||
const scanAbortRef = React.useRef<AbortController | null>(null);
|
||||
const qrScanSupported = React.useMemo(() => isQrScanSupported(), []);
|
||||
// The manual add/edit form is hidden until asked for — the sheet leads with
|
||||
// the list of instances (with live status), not a wall of inputs.
|
||||
@@ -61,11 +64,17 @@ export const MobileInstancesSurface: React.FC<{
|
||||
// Scan a pairing QR into the add/edit form fields (does not change edit mode, so
|
||||
// the form-reset effect doesn't wipe the scanned values). The user reviews + saves.
|
||||
const handleScanInstance = React.useCallback(async () => {
|
||||
if (isScanning) return;
|
||||
if (scanAbortRef.current) return;
|
||||
setError(null);
|
||||
setIsScanning(true);
|
||||
const controller = new AbortController();
|
||||
scanAbortRef.current = controller;
|
||||
try {
|
||||
const result = await scanConnectionQr();
|
||||
const result = await scanConnectionQr({ signal: controller.signal });
|
||||
if (scanAbortRef.current === controller) {
|
||||
scanAbortRef.current = null;
|
||||
setIsScanning(false);
|
||||
}
|
||||
switch (result.status) {
|
||||
case 'ok':
|
||||
// Legacy token QR: prefill the manual form for review before saving.
|
||||
@@ -75,6 +84,7 @@ export const MobileInstancesSurface: React.FC<{
|
||||
setFormOpen(true);
|
||||
break;
|
||||
case 'pairing':
|
||||
setIsCompletingScan(true);
|
||||
await conn.redeemPairingConnection(result.pairing);
|
||||
break;
|
||||
case 'permission-denied':
|
||||
@@ -94,9 +104,15 @@ export const MobileInstancesSurface: React.FC<{
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
setIsCompletingScan(false);
|
||||
if (scanAbortRef.current === controller) {
|
||||
scanAbortRef.current = null;
|
||||
setIsScanning(false);
|
||||
}
|
||||
}
|
||||
}, [conn, isScanning, setError, t]);
|
||||
}, [conn, setError, t]);
|
||||
|
||||
React.useEffect(() => () => scanAbortRef.current?.abort(), []);
|
||||
|
||||
const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -170,6 +186,9 @@ export const MobileInstancesSurface: React.FC<{
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isScanning ? <MobileQrScannerOverlay onCancel={() => scanAbortRef.current?.abort()} /> : null}
|
||||
{isCompletingScan ? <MobileQrConnectionLoading /> : null}
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="space-y-6">
|
||||
@@ -358,5 +377,6 @@ export const MobileInstancesSurface: React.FC<{
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type OverflowItem = {
|
||||
key: 'files' | 'changes' | 'terminal' | 'mcp' | 'notes' | 'instances' | 'update' | 'settings';
|
||||
icon?: IconName;
|
||||
iconNode?: React.ReactNode;
|
||||
label: string;
|
||||
badge?: number;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
export const MobileOverflowMenu: React.FC<{
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
items: OverflowItem[];
|
||||
/** Extra viewport-right inset so the dropdown stays anchored to the
|
||||
three-dots button when the iPad right sidebar shifts the header. */
|
||||
rightOffset?: number;
|
||||
}> = ({ open, onClose, items, rightOffset = 0 }) => {
|
||||
const { t } = useI18n();
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t('mobile.menu.titleAria')}>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 cursor-default"
|
||||
aria-label={t('mobile.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-[calc(var(--oc-safe-area-top,0px)+56px+4px)] w-[min(220px,calc(100vw-1rem))] origin-top-right overflow-hidden rounded-2xl border border-border/70 bg-background shadow-[0_18px_60px_rgb(0_0_0_/_0.35)]"
|
||||
role="menu"
|
||||
style={{
|
||||
right: `${8 + rightOffset}px`,
|
||||
animation: 'mobile-menu-in 160ms cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
onClick={() => {
|
||||
item.onSelect();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{item.iconNode ?? (item.icon ? <Icon name={item.icon} className="size-5 shrink-0 text-muted-foreground" /> : null)}
|
||||
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">{item.label}</span>
|
||||
{item.badge && item.badge > 0 ? (
|
||||
<span className="inline-flex size-2 shrink-0 rounded-full bg-primary" aria-hidden />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<style>{`@keyframes mobile-menu-in { from { opacity: 0; transform: translateY(-6px) scale(0.96); } to { opacity: 1; transform: translateY(0) scale(1); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const MobileQrScannerOverlay: React.FC<{ onCancel: () => void }> = ({ onCancel }) => {
|
||||
const { t } = useI18n();
|
||||
const overlayRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const htmlBackground = {
|
||||
value: document.documentElement.style.getPropertyValue('background-color'),
|
||||
priority: document.documentElement.style.getPropertyPriority('background-color'),
|
||||
};
|
||||
const bodyBackground = {
|
||||
value: document.body.style.getPropertyValue('background-color'),
|
||||
priority: document.body.style.getPropertyPriority('background-color'),
|
||||
};
|
||||
// The app's reduced-transparency theme deliberately uses an !important
|
||||
// background. Use an inline important color while CameraX is behind the
|
||||
// WebView; the CSS minifier collapses `background: transparent` in a way
|
||||
// that does not reset that important background color on Android WebView.
|
||||
document.documentElement.style.setProperty('background-color', 'rgba(0, 0, 0, 0)', 'important');
|
||||
document.body.style.setProperty('background-color', 'rgba(0, 0, 0, 0)', 'important');
|
||||
|
||||
// startScan() places CameraX behind the WebView. OpenChamber has several
|
||||
// independent portal roots, so hiding only #root (or relying on inherited
|
||||
// visibility) can leave a sheet/sidebar painted over the preview. Opacity on
|
||||
// each top-level sibling is composited for its whole subtree and cannot be
|
||||
// overridden by descendants.
|
||||
const hidden = new Map<HTMLElement, { opacity: string; pointerEvents: string }>();
|
||||
const hideBodySibling = (node: Node) => {
|
||||
if (!(node instanceof HTMLElement) || node === overlayRef.current || hidden.has(node)) return;
|
||||
hidden.set(node, { opacity: node.style.opacity, pointerEvents: node.style.pointerEvents });
|
||||
node.style.setProperty('opacity', '0');
|
||||
node.style.setProperty('pointer-events', 'none');
|
||||
};
|
||||
Array.from(document.body.children).forEach(hideBodySibling);
|
||||
const observer = new MutationObserver((records) => {
|
||||
records.forEach((record) => record.addedNodes.forEach(hideBodySibling));
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
hidden.forEach((previous, element) => {
|
||||
element.style.opacity = previous.opacity;
|
||||
element.style.pointerEvents = previous.pointerEvents;
|
||||
});
|
||||
if (htmlBackground.value) {
|
||||
document.documentElement.style.setProperty('background-color', htmlBackground.value, htmlBackground.priority);
|
||||
} else {
|
||||
document.documentElement.style.removeProperty('background-color');
|
||||
}
|
||||
if (bodyBackground.value) {
|
||||
document.body.style.setProperty('background-color', bodyBackground.value, bodyBackground.priority);
|
||||
} else {
|
||||
document.body.style.removeProperty('background-color');
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') onCancel();
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}, [onCancel]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={overlayRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('mobile.connect.scanQr')}
|
||||
className="fixed inset-0 z-[1000] flex flex-col bg-transparent px-6 pb-[calc(var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))+24px)] pt-[calc(var(--safe-area-inset-top,env(safe-area-inset-top,0px))+24px)] text-foreground"
|
||||
>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-6">
|
||||
<div className="aspect-square w-full max-w-72 rounded-[28px] border-2 border-foreground/90 shadow-[0_0_0_9999px_color-mix(in_srgb,var(--surface-background)_18%,transparent)]" aria-hidden />
|
||||
<p className="max-w-sm rounded-[16px] border border-border/60 bg-background px-4 py-3 text-center typography-body text-foreground shadow-sm">
|
||||
{t('mobile.connect.welcome.scanHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="lg" className="mx-auto min-h-12 w-full max-w-sm bg-background" onClick={onCancel}>
|
||||
<Icon name="close" className="size-[18px]" />
|
||||
{t('mobile.instances.cancelEdit')}
|
||||
</Button>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export const MobileQrConnectionLoading: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
return createPortal(
|
||||
<div role="status" className="fixed inset-0 z-[1000] flex flex-col items-center justify-center gap-5 bg-background px-6 text-foreground">
|
||||
<OpenChamberLogo width={96} height={96} isAnimated />
|
||||
<div className="flex items-center gap-2 typography-ui-label text-muted-foreground">
|
||||
<Icon name="loader-4" className="size-[18px] animate-spin" />
|
||||
<span>{t('mobile.connect.connecting')}</span>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
@@ -4,8 +4,8 @@ import { Icon } from '@/components/icon/Icon';
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { preloadProviderLogos } from '@/hooks/useProviderLogo';
|
||||
import { useTabletLayout } from '@/lib/device';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { isIPadApp } from '@/lib/platform';
|
||||
import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
|
||||
import { getDisplayModelName } from '@/lib/quota/model-families';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -16,7 +16,7 @@ import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useSessionMessages } from '@/sync/sync-context';
|
||||
|
||||
const IPAD_METADATA_POPOVER_WIDTH = 380;
|
||||
const TABLET_METADATA_POPOVER_WIDTH = 380;
|
||||
|
||||
const getNumericLimit = (limit: unknown, key: 'context' | 'output'): number | undefined => {
|
||||
if (!limit || typeof limit !== 'object') return undefined;
|
||||
@@ -139,18 +139,18 @@ const SessionMetadataOverlay: React.FC<{
|
||||
const panelRef = React.useRef<HTMLDivElement>(null);
|
||||
const [shouldRender, setShouldRender] = React.useState(open);
|
||||
const [isExiting, setIsExiting] = React.useState(false);
|
||||
// iPad: a phone-width sheet stretched across the whole chat column looks
|
||||
// Tablet: a phone-width sheet stretched across the whole chat column looks
|
||||
// broken — render a popover anchored to the metadata button instead.
|
||||
const isIPad = React.useMemo(() => isIPadApp(), []);
|
||||
const { enabled: isTabletLayout } = useTabletLayout();
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
const [ipadAnchorLeft, setIpadAnchorLeft] = React.useState<number | null>(null);
|
||||
const [anchorLeft, setIpadAnchorLeft] = React.useState<number | null>(null);
|
||||
|
||||
// The shell has transformed ancestors, so the fixed wrapper's containing
|
||||
// block is the chat column, NOT the viewport. Anchor the popover in the
|
||||
// wrapper's own coordinate space — viewport-based lefts would double-count
|
||||
// the sidebar offset.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open || !isIPad || !shouldRender) return;
|
||||
if (!open || !isTabletLayout || !shouldRender) return;
|
||||
const compute = () => {
|
||||
const anchorRect = anchorRef.current?.getBoundingClientRect();
|
||||
const wrapperRect = wrapperRef.current?.getBoundingClientRect();
|
||||
@@ -161,7 +161,7 @@ const SessionMetadataOverlay: React.FC<{
|
||||
const relativeLeft = anchorRect.left - wrapperRect.left;
|
||||
const left = Math.min(
|
||||
Math.max(relativeLeft, 8),
|
||||
Math.max(8, wrapperRect.width - IPAD_METADATA_POPOVER_WIDTH - 8),
|
||||
Math.max(8, wrapperRect.width - TABLET_METADATA_POPOVER_WIDTH - 8),
|
||||
);
|
||||
setIpadAnchorLeft(left);
|
||||
};
|
||||
@@ -173,9 +173,9 @@ const SessionMetadataOverlay: React.FC<{
|
||||
const observer = new ResizeObserver(compute);
|
||||
observer.observe(wrapper);
|
||||
return () => observer.disconnect();
|
||||
}, [anchorRef, isIPad, open, shouldRender]);
|
||||
}, [anchorRef, isTabletLayout, open, shouldRender]);
|
||||
|
||||
const ipadPopover = isIPad && ipadAnchorLeft !== null;
|
||||
const isPopover = isTabletLayout && anchorLeft !== null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
@@ -233,17 +233,17 @@ const SessionMetadataOverlay: React.FC<{
|
||||
aria-label={t('mobile.header.openMetadataAria')}
|
||||
className={cn(
|
||||
'overflow-y-auto overscroll-contain rounded-[20px] border border-border/70 bg-[var(--surface-elevated)] p-2 shadow-[0_12px_32px_rgb(0_0_0_/_0.2)] will-change-transform',
|
||||
ipadPopover ? 'absolute origin-top-left' : 'mx-3 mt-2',
|
||||
isPopover ? 'absolute origin-top-left' : 'mx-3 mt-2',
|
||||
isExiting ? 'pointer-events-none' : 'pointer-events-auto',
|
||||
)}
|
||||
style={{
|
||||
animation: `${isExiting ? 'session-metadata-out' : 'session-metadata-in'} ${isExiting ? 140 : 170}ms cubic-bezier(0.32, 0.72, 0, 1) forwards`,
|
||||
maxHeight: 'min(72dvh, calc(100dvh - var(--oc-safe-area-top, 0px) - var(--oc-header-height, 56px) - 1rem))',
|
||||
...(ipadPopover
|
||||
...(isPopover
|
||||
? {
|
||||
top: 8,
|
||||
left: ipadAnchorLeft ?? 8,
|
||||
width: `min(${IPAD_METADATA_POPOVER_WIDTH}px, calc(100% - 16px))`,
|
||||
left: anchorLeft ?? 8,
|
||||
width: `min(${TABLET_METADATA_POPOVER_WIDTH}px, calc(100% - 16px))`,
|
||||
}
|
||||
: null),
|
||||
}}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils';
|
||||
import { useSwitcherItems } from '@/components/session/sidebar/hooks/useSwitcherItems';
|
||||
import { useTabletLayout } from '@/lib/device';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
@@ -13,6 +14,8 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useGlobalSessionStatus } from '@/sync/sync-context';
|
||||
|
||||
const RECENT_SESSIONS_LIMIT = 10;
|
||||
/** Matches the metadata popover's width so both header dropdowns read as a pair. */
|
||||
const TABLET_POPOVER_WIDTH = 380;
|
||||
|
||||
const getSessionTitle = (session: Session, fallback: string): string =>
|
||||
session.title?.trim() || fallback;
|
||||
@@ -76,6 +79,42 @@ export const MobileSessionSwitcher: React.FC<{
|
||||
const panelRef = React.useRef<HTMLDivElement>(null);
|
||||
const [shouldRender, setShouldRender] = React.useState(open);
|
||||
const [isExiting, setIsExiting] = React.useState(false);
|
||||
// Tablet: a phone-width sheet stretched across the whole chat column looks
|
||||
// broken — anchor a popover under the title instead. Mirror image of the
|
||||
// metadata/usage popover, which anchors to the ring on the right.
|
||||
const { enabled: isTabletLayout } = useTabletLayout();
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
const [anchorLeft, setAnchorLeft] = React.useState<number | null>(null);
|
||||
|
||||
// The shell has transformed ancestors, so the fixed wrapper's containing
|
||||
// block is the chat column, NOT the viewport — anchor in the wrapper's own
|
||||
// coordinate space (see SessionMetadataOverlay for the same reasoning).
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open || !isTabletLayout || !shouldRender) return;
|
||||
const compute = () => {
|
||||
const anchorRect = anchorRef.current?.getBoundingClientRect();
|
||||
const wrapperRect = wrapperRef.current?.getBoundingClientRect();
|
||||
if (!anchorRect || !wrapperRect) {
|
||||
setAnchorLeft(null);
|
||||
return;
|
||||
}
|
||||
const relativeLeft = anchorRect.left - wrapperRect.left;
|
||||
setAnchorLeft(Math.min(
|
||||
Math.max(relativeLeft, 8),
|
||||
Math.max(8, wrapperRect.width - TABLET_POPOVER_WIDTH - 8),
|
||||
));
|
||||
};
|
||||
compute();
|
||||
// Re-anchor if the chat column shifts while the popover is open (sidebar
|
||||
// toggle/resize, orientation change) — the header buttons move with it.
|
||||
const wrapper = wrapperRef.current;
|
||||
if (typeof ResizeObserver === 'undefined' || !wrapper) return;
|
||||
const observer = new ResizeObserver(compute);
|
||||
observer.observe(wrapper);
|
||||
return () => observer.disconnect();
|
||||
}, [anchorRef, isTabletLayout, open, shouldRender]);
|
||||
|
||||
const isPopover = isTabletLayout && anchorLeft !== null;
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
||||
@@ -132,18 +171,26 @@ export const MobileSessionSwitcher: React.FC<{
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 top-[calc(var(--oc-safe-area-top,0px)+var(--oc-header-height,56px))] z-20 pointer-events-none">
|
||||
<div ref={wrapperRef} className="fixed inset-x-0 bottom-0 top-[calc(var(--oc-safe-area-top,0px)+var(--oc-header-height,56px))] z-20 pointer-events-none">
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-label={t('sessions.switcher.openAria')}
|
||||
className={cn(
|
||||
'mx-3 mt-2 flex flex-col overflow-hidden rounded-[20px] border border-border/70 bg-[var(--surface-elevated)] p-2 shadow-[0_12px_32px_rgb(0_0_0_/_0.2)] will-change-transform',
|
||||
'flex flex-col overflow-hidden rounded-[20px] border border-border/70 bg-[var(--surface-elevated)] p-2 shadow-[0_12px_32px_rgb(0_0_0_/_0.2)] will-change-transform',
|
||||
isPopover ? 'absolute origin-top-left' : 'mx-3 mt-2',
|
||||
isExiting ? 'pointer-events-none' : 'pointer-events-auto',
|
||||
)}
|
||||
style={{
|
||||
animation: `${isExiting ? 'session-switcher-out' : 'session-switcher-in'} ${isExiting ? 140 : 170}ms cubic-bezier(0.32, 0.72, 0, 1) forwards`,
|
||||
maxHeight: 'min(72dvh, calc(100dvh - var(--oc-safe-area-top, 0px) - var(--oc-header-height, 56px) - 1rem))',
|
||||
...(isPopover
|
||||
? {
|
||||
top: 8,
|
||||
left: anchorLeft ?? 8,
|
||||
width: `min(${TABLET_POPOVER_WIDTH}px, calc(100% - 16px))`,
|
||||
}
|
||||
: null),
|
||||
}}
|
||||
>
|
||||
<div className="oc-hide-scrollbar min-h-0 flex-1 space-y-0.5 overflow-y-auto overscroll-contain">
|
||||
|
||||
@@ -1437,8 +1437,11 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
</>
|
||||
) : null;
|
||||
|
||||
// flex-1 + min-h-0 rather than h-full: both hosts put a fixed-height header
|
||||
// above this, so a 100% height overflows by exactly that header — and the
|
||||
// clipped overflow swallowed the footer.
|
||||
const surfaceContent = (
|
||||
<div ref={contentRootRef} className="flex h-full flex-col">
|
||||
<div ref={contentRootRef} className="flex min-h-0 flex-1 flex-col">
|
||||
<ScrollShadow className="min-h-0 flex-1 overflow-y-auto pb-4">
|
||||
{/* The search bar scrolls WITH the list (iOS-style): the open-time
|
||||
auto-scroll to the current session naturally tucks it away, and
|
||||
|
||||
@@ -85,10 +85,18 @@ const McpWorkspacePane: React.FC<{ onOpenMcpSettings: () => void }> = ({ onOpenM
|
||||
);
|
||||
};
|
||||
|
||||
/** Full-width right drawer with the phone workspace surfaces as tabs
|
||||
(Changes / Files / Terminal / Notes / MCP). Slides in from the right edge;
|
||||
closes via the header X, Escape (unless the terminal tab owns the keys),
|
||||
or the Android back button (handled by MobileShell). */
|
||||
/** The workspace surfaces as tabs (Changes / Files / Terminal / Notes / MCP).
|
||||
|
||||
Two hosts, same content and same state:
|
||||
- `drawer` (default) covers the app and slides in from the right edge —
|
||||
the phone, and a tablet in portrait where a side panel would leave no
|
||||
usable chat column;
|
||||
- `panel` renders inline so the caller can size it as a real sidebar
|
||||
beside the chat (tablet, landscape). The caller owns the width and the
|
||||
open/close animation there; this component only fills it.
|
||||
|
||||
Closes via the header X, Escape (unless the terminal tab owns the keys), or
|
||||
the Android back button (handled by MobileShell). */
|
||||
export const MobileWorkspaceDrawer: React.FC<{
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -100,7 +108,8 @@ export const MobileWorkspaceDrawer: React.FC<{
|
||||
onOpenPlan: (plan: { path: string; title: string }) => void;
|
||||
/** MCP tab: jump to the MCP settings page pre-seeded with a new server draft. */
|
||||
onOpenMcpSettings: () => void;
|
||||
}> = ({ open, onClose, tab, onTabChange, pendingChangesDiff, onOpenPlan, onOpenMcpSettings }) => {
|
||||
variant?: 'drawer' | 'panel';
|
||||
}> = ({ open, onClose, tab, onTabChange, pendingChangesDiff, onOpenPlan, onOpenMcpSettings, variant = 'drawer' }) => {
|
||||
const { t } = useI18n();
|
||||
const rootRef = React.useRef<HTMLElement | null>(null);
|
||||
const [entered, setEntered] = React.useState(false);
|
||||
@@ -150,20 +159,22 @@ export const MobileWorkspaceDrawer: React.FC<{
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
// Only the full-cover drawer owns the page scroll; the inline panel sits
|
||||
// inside the shell and must leave the chat beside it scrollable.
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
if (variant === 'drawer') document.body.style.overflow = 'hidden';
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// The terminal owns Escape (it goes to the PTY) — don't hijack it.
|
||||
if (event.key === 'Escape' && tabRef.current !== 'terminal') onCloseRef.current();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
if (variant === 'drawer') document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
}, [open, variant]);
|
||||
|
||||
if (!rootRef.current) return null;
|
||||
if (variant === 'drawer' && !rootRef.current) return null;
|
||||
|
||||
const tabItems: SortableTabsStripItem[] = [
|
||||
{ id: 'changes', label: t('mobile.menu.changes'), icon: <Icon name="git-branch" className="h-3.5 w-3.5" /> },
|
||||
@@ -173,23 +184,8 @@ export const MobileWorkspaceDrawer: React.FC<{
|
||||
{ id: 'mcp', label: t('mobile.menu.mcp'), icon: <McpIcon className="h-3.5 w-3.5" /> },
|
||||
];
|
||||
|
||||
return createPortal(
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('mobile.header.openWorkspaceAria')}
|
||||
aria-hidden={!open}
|
||||
className="oc-keyboard-inset-surface fixed inset-0 z-50 flex flex-col bg-background text-foreground"
|
||||
style={{
|
||||
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
||||
// Settled state drops the transform entirely so the drawer isn't kept
|
||||
// on a compositing layer (iOS clips those to the safe-area viewport).
|
||||
transform: entered ? 'none' : 'translateX(100%)',
|
||||
transition: `transform ${ENTER_DURATION_MS}ms ${DRAWER_EASING}`,
|
||||
visibility: visible ? 'visible' : 'hidden',
|
||||
pointerEvents: open ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3">
|
||||
<div className="flex h-9 min-w-0 flex-1 items-center">
|
||||
{/* Mounted only while shown; nonCompositedIndicator keeps the active
|
||||
@@ -268,7 +264,36 @@ export const MobileWorkspaceDrawer: React.FC<{
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (variant === 'panel') {
|
||||
// The caller animates the width; the content itself is plain flow so it
|
||||
// never gets its own compositing layer (iOS clips those to the safe-area
|
||||
// viewport, which is exactly what the drawer's settled `transform: none`
|
||||
// avoids on the other host).
|
||||
return <div className="flex h-full min-h-0 flex-col bg-background text-foreground">{body}</div>;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('mobile.header.openWorkspaceAria')}
|
||||
aria-hidden={!open}
|
||||
className="oc-keyboard-inset-surface fixed inset-0 z-50 flex flex-col bg-background text-foreground"
|
||||
style={{
|
||||
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
||||
// Settled state drops the transform entirely so the drawer isn't kept
|
||||
// on a compositing layer (iOS clips those to the safe-area viewport).
|
||||
transform: entered ? 'none' : 'translateX(100%)',
|
||||
transition: `transform ${ENTER_DURATION_MS}ms ${DRAWER_EASING}`,
|
||||
visibility: visible ? 'visible' : 'hidden',
|
||||
pointerEvents: open ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</section>,
|
||||
rootRef.current,
|
||||
rootRef.current as HTMLElement,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,17 +4,25 @@ export const IPAD_LEFT_SIDEBAR_WIDTH = 320;
|
||||
export const IPAD_RIGHT_SIDEBAR_WIDTH = 380;
|
||||
const IPAD_SIDEBAR_MIN_WIDTH = 280;
|
||||
const IPAD_SIDEBAR_MAX_WIDTH = 560;
|
||||
/** The workspace panel holds diffs, a file editor and a terminal, so it earns
|
||||
far more room than the sessions list ever needs. */
|
||||
export const IPAD_WORKSPACE_SIDEBAR_MAX_WIDTH = 900;
|
||||
|
||||
/** Drag-resize for the iPad sidebars: same live-width mechanics as the desktop
|
||||
Sidebar (imperative styles during the drag, committed to state at the end),
|
||||
but with a finger-sized grab strip instead of a 3px hover handle. */
|
||||
export function useIpadSidebarResize(side: 'left' | 'right', storageKey: string, defaultWidth: number) {
|
||||
export function useIpadSidebarResize(
|
||||
side: 'left' | 'right',
|
||||
storageKey: string,
|
||||
defaultWidth: number,
|
||||
maxWidth: number = IPAD_SIDEBAR_MAX_WIDTH,
|
||||
) {
|
||||
const asideRef = React.useRef<HTMLElement | null>(null);
|
||||
const [width, setWidth] = React.useState(() => {
|
||||
if (typeof window === 'undefined') return defaultWidth;
|
||||
const stored = Number.parseInt(window.localStorage.getItem(storageKey) ?? '', 10);
|
||||
if (!Number.isFinite(stored)) return defaultWidth;
|
||||
return Math.min(IPAD_SIDEBAR_MAX_WIDTH, Math.max(IPAD_SIDEBAR_MIN_WIDTH, stored));
|
||||
return Math.min(maxWidth, Math.max(IPAD_SIDEBAR_MIN_WIDTH, stored));
|
||||
});
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const startXRef = React.useRef(0);
|
||||
@@ -23,8 +31,8 @@ export function useIpadSidebarResize(side: 'left' | 'right', storageKey: string,
|
||||
const pointerIdRef = React.useRef<number | null>(null);
|
||||
|
||||
const clampWidth = React.useCallback((value: number) => (
|
||||
Math.min(IPAD_SIDEBAR_MAX_WIDTH, Math.max(IPAD_SIDEBAR_MIN_WIDTH, Math.round(value)))
|
||||
), []);
|
||||
Math.min(maxWidth, Math.max(IPAD_SIDEBAR_MIN_WIDTH, Math.round(value)))
|
||||
), [maxWidth]);
|
||||
|
||||
const applyLiveWidth = React.useCallback((nextWidth: number) => {
|
||||
const aside = asideRef.current;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { observeNativeKeyboardHeight, resetHardwareKeyboardDetection, startHardwareKeyboardBridge } from '@/lib/hardwareKeyboard';
|
||||
|
||||
/** True when running inside the native Capacitor shell (iOS/Android app). */
|
||||
export const isCapacitorMobileApp = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
@@ -27,6 +29,10 @@ export const useNativeMobileChrome = (): void => {
|
||||
root.classList.add('oc-platform-android');
|
||||
}
|
||||
|
||||
// iOS reports hardware keyboards natively (GCKeyboard); adopting that
|
||||
// answer switches the layout off its keyboard-event inference entirely.
|
||||
cleanup.push(startHardwareKeyboardBridge());
|
||||
|
||||
const setInset = (px: number) => {
|
||||
root.style.setProperty('--oc-keyboard-inset', `${Math.max(0, Math.round(px))}px`);
|
||||
};
|
||||
@@ -88,7 +94,8 @@ export const useNativeMobileChrome = (): void => {
|
||||
// oc-keyboard-open drives CSS (draft starters, composer padding), and
|
||||
// the settled event gives the chat its one deterministic re-pin after
|
||||
// the native resize (the auto-follow idle gate ignores it otherwise).
|
||||
const willShowHandle = await Keyboard.addListener('keyboardWillShow', () => {
|
||||
const willShowHandle = await Keyboard.addListener('keyboardWillShow', (info) => {
|
||||
observeNativeKeyboardHeight(info.keyboardHeight);
|
||||
root.classList.add('oc-keyboard-open');
|
||||
// The composer already expanded on tap — re-pin the chat to it now,
|
||||
// so the native resize that follows is the only remaining movement.
|
||||
@@ -178,6 +185,7 @@ export const useNativeMobileChrome = (): void => {
|
||||
|
||||
const showHandle = await Keyboard.addListener('keyboardWillShow', (info) => {
|
||||
clearSettle();
|
||||
observeNativeKeyboardHeight(info.keyboardHeight);
|
||||
keyboardOpen = true;
|
||||
keyboardHeight = info.keyboardHeight;
|
||||
if (!layoutApplied) {
|
||||
@@ -330,6 +338,7 @@ export const useNativeMobileChrome = (): void => {
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup.forEach((remove) => remove());
|
||||
resetHardwareKeyboardDetection();
|
||||
root.classList.remove('oc-capacitor-app', 'oc-keyboard-open', 'oc-kb-animating', 'oc-kb-hide', 'oc-kb-caret-hold', 'oc-platform-android');
|
||||
root.style.removeProperty('--oc-keyboard-inset');
|
||||
root.style.removeProperty('--oc-kb-shift');
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import { encodePairingConnectionPayload, buildPairingConnectionPayload } from '@/lib/connectionPayload';
|
||||
|
||||
import { parseConnectionPayload } from './mobileQrScan';
|
||||
import { parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
|
||||
|
||||
const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const;
|
||||
|
||||
@@ -40,3 +40,126 @@ describe('parseConnectionPayload', () => {
|
||||
expect(parseConnectionPayload('openchamber://connect?v=1&mode=relay#offer=eyJ2IjoxfQ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanConnectionQr on Android', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||
});
|
||||
|
||||
test('uses the bundled startScan flow and cleans up after a result', async () => {
|
||||
const listeners = new Map<string, (event: { barcodes?: Array<{ rawValue?: string }> }) => void>();
|
||||
let removeCalls = 0;
|
||||
let stopCalls = 0;
|
||||
let scanCalls = 0;
|
||||
let startOptions: unknown;
|
||||
const remove = () => { removeCalls += 1; };
|
||||
const stopScan = async () => { stopCalls += 1; };
|
||||
const scan = async () => { scanCalls += 1; return { barcodes: [] }; };
|
||||
const startScan = async (options?: unknown) => {
|
||||
startOptions = options;
|
||||
listeners.get('barcodesScanned')?.({ barcodes: [{ rawValue: 'https://oc.example' }] });
|
||||
};
|
||||
const plugin = {
|
||||
requestPermissions: mock(async () => ({ camera: 'granted' })),
|
||||
scan,
|
||||
startScan,
|
||||
stopScan,
|
||||
addListener: mock((event: string, callback: (info: { barcodes?: Array<{ rawValue?: string }> }) => void) => {
|
||||
listeners.set(event, callback);
|
||||
return Promise.resolve({ remove });
|
||||
}),
|
||||
};
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } },
|
||||
});
|
||||
|
||||
expect(await scanConnectionQr()).toEqual({ status: 'ok', url: 'https://oc.example' });
|
||||
expect(startOptions).toEqual({ formats: ['QR_CODE'] });
|
||||
expect(scanCalls).toBe(0);
|
||||
expect(stopCalls).toBe(1);
|
||||
expect(removeCalls).toBe(2);
|
||||
});
|
||||
|
||||
test('stops scanning when the caller aborts', async () => {
|
||||
let stopCalls = 0;
|
||||
const stopScan = async () => { stopCalls += 1; };
|
||||
const plugin = {
|
||||
requestPermissions: mock(async () => ({ camera: 'granted' })),
|
||||
startScan: mock(async () => undefined),
|
||||
stopScan,
|
||||
addListener: mock(async () => ({ remove: mock(() => undefined) })),
|
||||
};
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = scanConnectionQr({ signal: controller.signal });
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
|
||||
expect(await result).toEqual({ status: 'cancelled' });
|
||||
expect(stopCalls).toBe(1);
|
||||
});
|
||||
|
||||
test('waits for listener setup to finish before cleaning up an aborted scan', async () => {
|
||||
let finishListenerSetup: (() => void) | undefined;
|
||||
let removeCalls = 0;
|
||||
let startCalls = 0;
|
||||
let stopCalls = 0;
|
||||
const listenerSetup = new Promise<void>((resolve) => { finishListenerSetup = resolve; });
|
||||
const remove = () => { removeCalls += 1; };
|
||||
const startScan = async () => { startCalls += 1; };
|
||||
const plugin = {
|
||||
requestPermissions: mock(async () => ({ camera: 'granted' })),
|
||||
startScan,
|
||||
stopScan: async () => { stopCalls += 1; },
|
||||
addListener: mock(async () => {
|
||||
await listenerSetup;
|
||||
return { remove };
|
||||
}),
|
||||
};
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = scanConnectionQr({ signal: controller.signal });
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
finishListenerSetup?.();
|
||||
|
||||
expect(await result).toEqual({ status: 'cancelled' });
|
||||
expect(startCalls).toBe(0);
|
||||
expect(removeCalls).toBe(2);
|
||||
expect(stopCalls).toBe(1);
|
||||
});
|
||||
|
||||
test('cleans up successful listener registration when the other listener fails', async () => {
|
||||
let removeCalls = 0;
|
||||
let startCalls = 0;
|
||||
let stopCalls = 0;
|
||||
const remove = () => { removeCalls += 1; };
|
||||
const plugin = {
|
||||
requestPermissions: mock(async () => ({ camera: 'granted' })),
|
||||
startScan: async () => { startCalls += 1; },
|
||||
stopScan: async () => { stopCalls += 1; },
|
||||
addListener: mock(async (event: string) => {
|
||||
if (event === 'scanError') throw new Error('listener setup failed');
|
||||
return { remove };
|
||||
}),
|
||||
};
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } },
|
||||
});
|
||||
|
||||
expect(await scanConnectionQr()).toEqual({ status: 'failed' });
|
||||
expect(startCalls).toBe(0);
|
||||
expect(removeCalls).toBe(1);
|
||||
expect(stopCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
// Connection payload parsing + native QR scanning for the dedicated mobile app.
|
||||
//
|
||||
// Pairing v2 links (openchamber://connect?v=2&p=<base64url>) carry a one-time
|
||||
// secret and a list of transport candidates (lan / tunnel / relay); they are
|
||||
// redeemed server-side over whichever candidate connects first. We also accept a
|
||||
// bare http(s) URL so a QR encoding only the server address works.
|
||||
//
|
||||
// QR scanning is delegated to a Capacitor barcode-scanner plugin if the native
|
||||
// shell registered one (`window.Capacitor.Plugins.BarcodeScanner`). We resolve it
|
||||
// at runtime instead of importing the package so the web build stays dependency-free
|
||||
// and the browser-hosted mobile UI degrades to `unsupported` cleanly.
|
||||
// Android uses the plugin's CameraX-backed startScan() flow. Unlike its ready-made
|
||||
// scan() activity, this path bundles the barcode model in the app and does not need
|
||||
// Google Play Services. iOS keeps the native ready-made scanner.
|
||||
|
||||
import { parsePairingConnectionPayload, type PairingConnectionPayload } from '@/lib/connectionPayload';
|
||||
|
||||
@@ -32,74 +26,16 @@ export type QrScanResult =
|
||||
| { status: 'failed' };
|
||||
|
||||
type ScannedBarcode = { rawValue?: string; displayValue?: string };
|
||||
|
||||
type ModuleInstallProgress = { state?: number };
|
||||
type ListenerHandle = { remove: () => void };
|
||||
|
||||
type ListenerHandle = { remove: () => void | Promise<void> };
|
||||
type BarcodeScannerPlugin = {
|
||||
requestPermissions?: () => Promise<{ camera?: string } | undefined>;
|
||||
scan?: (options?: { formats?: string[] }) => Promise<{ barcodes?: ScannedBarcode[] } | undefined>;
|
||||
// Android-only: the Google code scanner used by scan() needs the ML Kit barcode module,
|
||||
// which Play Services must download once before the first scan. Absent on iOS.
|
||||
isGoogleBarcodeScannerModuleAvailable?: () => Promise<{ available?: boolean } | undefined>;
|
||||
installGoogleBarcodeScannerModule?: () => Promise<void>;
|
||||
startScan?: (options?: { formats?: string[] }) => Promise<void>;
|
||||
stopScan?: () => Promise<void>;
|
||||
addListener?: (
|
||||
event: 'googleBarcodeScannerModuleInstallProgress',
|
||||
cb: (info: ModuleInstallProgress) => void,
|
||||
) => Promise<ListenerHandle>;
|
||||
};
|
||||
|
||||
// Google's ModuleInstallProgress states: 4 = COMPLETED, 3 = CANCELED, 5 = FAILED.
|
||||
const MODULE_STATE_COMPLETED = 4;
|
||||
const MODULE_STATE_CANCELED = 3;
|
||||
const MODULE_STATE_FAILED = 5;
|
||||
const MODULE_INSTALL_TIMEOUT_MS = 90_000;
|
||||
|
||||
// Ensure the Android Google barcode module is downloaded before scanning. No-op on platforms
|
||||
// where these methods don't exist (iOS) or when it's already available. Resolves once the module
|
||||
// is usable; rejects if the install is canceled, fails, or times out.
|
||||
const ensureScannerModule = async (plugin: BarcodeScannerPlugin): Promise<void> => {
|
||||
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
|
||||
if (
|
||||
capacitor?.getPlatform?.() !== 'android' ||
|
||||
!plugin.isGoogleBarcodeScannerModuleAvailable ||
|
||||
!plugin.installGoogleBarcodeScannerModule
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const status = await plugin.isGoogleBarcodeScannerModuleAvailable().catch(() => undefined);
|
||||
if (status?.available) return;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let handle: ListenerHandle | undefined;
|
||||
const finish = (fn: () => void) => {
|
||||
window.clearTimeout(timer);
|
||||
handle?.remove();
|
||||
fn();
|
||||
};
|
||||
const timer = window.setTimeout(
|
||||
() => finish(() => reject(new Error('module install timed out'))),
|
||||
MODULE_INSTALL_TIMEOUT_MS,
|
||||
);
|
||||
// addListener may return a handle synchronously OR a Promise<handle> depending on the
|
||||
// Capacitor proxy — normalize with Promise.resolve so a non-thenable handle doesn't throw
|
||||
// and abort the install call below.
|
||||
Promise.resolve(
|
||||
plugin.addListener?.('googleBarcodeScannerModuleInstallProgress', (info) => {
|
||||
if (info?.state === MODULE_STATE_COMPLETED) finish(resolve);
|
||||
else if (info?.state === MODULE_STATE_CANCELED || info?.state === MODULE_STATE_FAILED) {
|
||||
finish(() => reject(new Error('module install failed')));
|
||||
}
|
||||
}),
|
||||
)
|
||||
.then((h) => {
|
||||
handle = h as ListenerHandle | undefined;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
Promise.resolve(plugin.installGoogleBarcodeScannerModule?.()).catch((error) =>
|
||||
finish(() => reject(error instanceof Error ? error : new Error('module install failed'))),
|
||||
);
|
||||
});
|
||||
event: 'barcodesScanned' | 'scanError',
|
||||
cb: (info: { barcodes?: ScannedBarcode[]; message?: string }) => void,
|
||||
) => Promise<ListenerHandle> | ListenerHandle;
|
||||
};
|
||||
|
||||
const getScannerPlugin = (): BarcodeScannerPlugin | null => {
|
||||
@@ -108,7 +44,12 @@ const getScannerPlugin = (): BarcodeScannerPlugin | null => {
|
||||
Capacitor?: { Plugins?: Record<string, unknown> };
|
||||
}).Capacitor;
|
||||
const plugin = capacitor?.Plugins?.BarcodeScanner as BarcodeScannerPlugin | undefined;
|
||||
return plugin && typeof plugin.scan === 'function' ? plugin : null;
|
||||
return plugin && (typeof plugin.scan === 'function' || typeof plugin.startScan === 'function') ? plugin : null;
|
||||
};
|
||||
|
||||
const isAndroid = (): boolean => {
|
||||
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
|
||||
return capacitor?.getPlatform?.() === 'android';
|
||||
};
|
||||
|
||||
export const parseConnectionPayload = (raw: string): MobileConnectionPayload | MobilePairingPayload | null => {
|
||||
@@ -124,54 +65,85 @@ export const parseConnectionPayload = (raw: string): MobileConnectionPayload | M
|
||||
return null;
|
||||
};
|
||||
|
||||
// The Google code scanner can briefly still throw "module not available" in the moments right
|
||||
// after its install completes. Detect that specific error so we can re-ensure + retry rather
|
||||
// than surfacing a failure the user would have to manually tap through.
|
||||
const isModuleUnavailableError = (error: unknown): boolean => {
|
||||
const message =
|
||||
typeof error === 'object' && error && 'message' in error
|
||||
? String((error as { message?: unknown }).message ?? '')
|
||||
: String(error ?? '');
|
||||
return /module/i.test(message) && /not\s*available|unavailable/i.test(message);
|
||||
const resultFromRawValue = (raw: string): QrScanResult => {
|
||||
const payload = parseConnectionPayload(raw);
|
||||
if (!payload) return { status: 'invalid' };
|
||||
if ('pairing' in payload) return { status: 'pairing', ...payload };
|
||||
return { status: 'ok', ...payload };
|
||||
};
|
||||
|
||||
const scanWithBundledAndroidScanner = async (
|
||||
plugin: BarcodeScannerPlugin,
|
||||
signal?: AbortSignal,
|
||||
): Promise<QrScanResult> => {
|
||||
if (!plugin.startScan || !plugin.stopScan || !plugin.addListener) return { status: 'unsupported' };
|
||||
if (signal?.aborted) return { status: 'cancelled' };
|
||||
|
||||
let barcodeListener: ListenerHandle | undefined;
|
||||
let errorListener: ListenerHandle | undefined;
|
||||
let settled = false;
|
||||
let resolveResult: (result: QrScanResult) => void = () => undefined;
|
||||
|
||||
const result = new Promise<QrScanResult>((resolve) => {
|
||||
resolveResult = resolve;
|
||||
});
|
||||
const finish = (scanResult: QrScanResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolveResult(scanResult);
|
||||
};
|
||||
const abort = () => finish({ status: 'cancelled' });
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
|
||||
try {
|
||||
const listenerResults = await Promise.allSettled([
|
||||
Promise.resolve(plugin.addListener('barcodesScanned', ({ barcodes }) => {
|
||||
const barcode = barcodes?.[0];
|
||||
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
|
||||
if (raw) finish(resultFromRawValue(raw));
|
||||
})).then((handle) => { barcodeListener = handle; }),
|
||||
Promise.resolve(plugin.addListener('scanError', () => finish({ status: 'failed' })))
|
||||
.then((handle) => { errorListener = handle; }),
|
||||
]);
|
||||
|
||||
if (listenerResults.some(({ status }) => status === 'rejected')) {
|
||||
finish({ status: 'failed' });
|
||||
} else if (!settled) {
|
||||
void plugin.startScan({ formats: ['QR_CODE'] }).catch(() => finish({ status: 'failed' }));
|
||||
}
|
||||
|
||||
return await result;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
await Promise.allSettled([
|
||||
Promise.resolve(barcodeListener?.remove()),
|
||||
Promise.resolve(errorListener?.remove()),
|
||||
plugin.stopScan(),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
export const isQrScanSupported = (): boolean => getScannerPlugin() !== null;
|
||||
|
||||
export const scanConnectionQr = async (): Promise<QrScanResult> => {
|
||||
export const scanConnectionQr = async (options?: { signal?: AbortSignal }): Promise<QrScanResult> => {
|
||||
const plugin = getScannerPlugin();
|
||||
if (!plugin?.scan) return { status: 'unsupported' };
|
||||
if (!plugin) return { status: 'unsupported' };
|
||||
|
||||
try {
|
||||
if (plugin.requestPermissions) {
|
||||
const permission = await plugin.requestPermissions();
|
||||
const camera = permission?.camera;
|
||||
if (camera && camera !== 'granted' && camera !== 'limited') {
|
||||
return { status: 'permission-denied' };
|
||||
}
|
||||
if (camera && camera !== 'granted' && camera !== 'limited') return { status: 'permission-denied' };
|
||||
}
|
||||
|
||||
// First scan on Android downloads the Google barcode module (the button stays in its
|
||||
// scanning state for the whole wait). The module can still report "not available" for a
|
||||
// moment right after install, so re-ensure + retry within this same call instead of erroring
|
||||
// out — the user shouldn't have to guess to tap again.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
await ensureScannerModule(plugin);
|
||||
const result = await plugin.scan({ formats: ['QR_CODE'] });
|
||||
const barcode = result?.barcodes?.[0];
|
||||
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
|
||||
if (!raw) return { status: 'cancelled' };
|
||||
if (options?.signal?.aborted) return { status: 'cancelled' };
|
||||
if (isAndroid()) return scanWithBundledAndroidScanner(plugin, options?.signal);
|
||||
if (!plugin.scan) return { status: 'unsupported' };
|
||||
|
||||
const payload = parseConnectionPayload(raw);
|
||||
if (!payload) return { status: 'invalid' };
|
||||
if ('pairing' in payload) return { status: 'pairing', ...payload };
|
||||
return { status: 'ok', ...payload };
|
||||
} catch (error) {
|
||||
if (!isModuleUnavailableError(error) || attempt === 2) return { status: 'failed' };
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 600));
|
||||
}
|
||||
}
|
||||
return { status: 'failed' };
|
||||
const result = await plugin.scan({ formats: ['QR_CODE'] });
|
||||
const barcode = result?.barcodes?.[0];
|
||||
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
|
||||
return raw ? resultFromRawValue(raw) : { status: 'cancelled' };
|
||||
} catch {
|
||||
return { status: 'failed' };
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ import React from 'react';
|
||||
*/
|
||||
|
||||
const EDGE_ZONE = 32; // px from a side where the swipe must begin
|
||||
// Android reserves the physical screen edge for system navigation. Accept a
|
||||
// wider start area so both OpenChamber drawers can be invoked beyond the
|
||||
// system Back gesture region without changing the browser/iOS gesture.
|
||||
const ANDROID_EDGE_ZONE = 80;
|
||||
const MIN_DISTANCE = 64; // px of horizontal travel required to commit
|
||||
const MAX_OFF_AXIS_RATIO = 0.7; // |dy| must stay below |dx| * this (keep it horizontal)
|
||||
|
||||
@@ -36,6 +40,8 @@ export const useEdgeSwipe = (
|
||||
React.useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
||||
const edgeZone = platform === 'android' ? ANDROID_EDGE_ZONE : EDGE_ZONE;
|
||||
|
||||
let tracking = false;
|
||||
let fromLeftEdge = false;
|
||||
@@ -49,8 +55,8 @@ export const useEdgeSwipe = (
|
||||
}
|
||||
const touch = event.touches[0];
|
||||
const width = element.clientWidth;
|
||||
const nearLeft = touch.clientX <= EDGE_ZONE;
|
||||
const nearRight = touch.clientX >= width - EDGE_ZONE;
|
||||
const nearLeft = touch.clientX <= edgeZone;
|
||||
const nearRight = touch.clientX >= width - edgeZone;
|
||||
tracking = nearLeft || nearRight;
|
||||
fromLeftEdge = nearLeft;
|
||||
startX = touch.clientX;
|
||||
|
||||
@@ -52,6 +52,8 @@ import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||
import { toast } from '@/components/ui';
|
||||
// useMessageStore removed — messages now come from sync system
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useTabletLayout } from '@/lib/device';
|
||||
import { useHardwareKeyboard } from '@/lib/hardwareKeyboard';
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
import { getCycledPrimaryAgentName, type MobileControlsPanel } from './mobileControlsUtils';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
@@ -349,6 +351,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||
const agents = getVisibleAgents();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const hasHardwareKeyboard = useHardwareKeyboard();
|
||||
const { enabled: isTabletLayout } = useTabletLayout();
|
||||
const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen);
|
||||
const inputBarOffset = useUIStore((state) => state.inputBarOffset);
|
||||
const persistChatDraft = useUIStore((state) => state.persistChatDraft);
|
||||
@@ -369,6 +373,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
);
|
||||
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);
|
||||
@@ -445,9 +450,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
if (!currentDirectory || !runtimeGit) return;
|
||||
return sessionEvents.onGitRefreshHint((hint) => {
|
||||
if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) return;
|
||||
void fetchGitStatus(currentDirectory, runtimeGit);
|
||||
if (hint.paths?.length) {
|
||||
clearGitDiffCache(currentDirectory, hint.paths);
|
||||
}
|
||||
void fetchGitStatus(currentDirectory, runtimeGit, { silent: true });
|
||||
});
|
||||
}, [currentDirectory, runtimeGit, fetchGitStatus]);
|
||||
}, [clearGitDiffCache, currentDirectory, runtimeGit, fetchGitStatus]);
|
||||
|
||||
const handleStartReviewFlow = React.useCallback(async (execution: ReviewFlowExecution) => {
|
||||
if (!currentSessionId) return;
|
||||
@@ -2233,6 +2241,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
editorRef: composerRef,
|
||||
formRef: composerFormRef,
|
||||
setExpandedInput,
|
||||
// The pill exists to buy screen back from the soft keyboard. A tablet
|
||||
// has the room regardless, and with a hardware keyboard there is no
|
||||
// soft keyboard to buy it back from — keep the real composer up.
|
||||
alwaysExpanded: hasHardwareKeyboard || isTabletLayout,
|
||||
holders: {
|
||||
controlsPanelOpen: Boolean(mobileControlsPanel),
|
||||
attachMenuOpen: mobileAttachMenuOpen,
|
||||
|
||||
@@ -138,6 +138,11 @@ describe('classifyMention', () => {
|
||||
expect(classifyMention('', classifier)).toBeNull();
|
||||
});
|
||||
|
||||
test('HTML fragments do not classify as file references', () => {
|
||||
expect(classifyMention('import</style>', classifier)).toBeNull();
|
||||
expect(classifyMention('src/<style.css', classifier)).toBeNull();
|
||||
});
|
||||
|
||||
test('an agent name wins over a file-looking name', () => {
|
||||
const shadowed = {
|
||||
knownAgentNames: new Set(['a.ts']),
|
||||
@@ -152,4 +157,3 @@ describe('classifyMention', () => {
|
||||
expect(looksLikeFilePath('plain', new Set(['plain']))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -116,6 +116,9 @@ export function classifyMention(
|
||||
classifier: MentionClassifier,
|
||||
): MentionKind | null {
|
||||
if (!name) return null;
|
||||
// HTML fragments are prompt text, never references. In particular, do not
|
||||
// interpret CSS syntax such as `@import</style>` as a local file path.
|
||||
if (name.includes('<') || name.includes('>')) return null;
|
||||
if (classifier.knownAgentNames.has(name.toLowerCase())) return 'agent';
|
||||
if (looksLikeFilePath(name, classifier.confirmedMentions)) return 'file';
|
||||
return null;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import React from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
|
||||
import { observeEditorFocus } from '@/lib/hardwareKeyboard';
|
||||
import { isCapacitorApp } from '@/lib/platform';
|
||||
import type { ComposerEditorHandle } from '../editor/ComposerEditor';
|
||||
|
||||
@@ -41,6 +42,13 @@ export interface MobileComposerShellOptions {
|
||||
formRef: React.RefObject<HTMLFormElement | null>;
|
||||
setExpandedInput: (expanded: boolean) => void;
|
||||
holders: MobileComposerHolders;
|
||||
/**
|
||||
* Keep the full composer up permanently and never fall back to the pill.
|
||||
* The pill exists to buy screen back from the soft keyboard; with a
|
||||
* hardware keyboard on a tablet there is no soft keyboard to hide from,
|
||||
* and collapsing between keystrokes would only cost the user a tap.
|
||||
*/
|
||||
alwaysExpanded?: boolean;
|
||||
}
|
||||
|
||||
export interface MobileComposerShell {
|
||||
@@ -65,9 +73,9 @@ export interface MobileComposerShell {
|
||||
export function useMobileComposerShell(
|
||||
options: MobileComposerShellOptions,
|
||||
): MobileComposerShell {
|
||||
const { isMobile, editorRef, formRef, setExpandedInput, holders } = options;
|
||||
const { isMobile, editorRef, formRef, setExpandedInput, holders, alwaysExpanded = false } = options;
|
||||
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const [expanded, setExpanded] = React.useState(alwaysExpanded && isMobile);
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
const [overlayHostBusy, setOverlayHostBusy] = React.useState(false);
|
||||
const [dictationActive, setDictationActive] = React.useState(false);
|
||||
@@ -88,6 +96,16 @@ export function useMobileComposerShell(
|
||||
expandedRef.current = expanded;
|
||||
});
|
||||
|
||||
// A hardware keyboard can be attached (or detached) at any moment, so this
|
||||
// is a live condition rather than a mount-time one. Detaching does NOT
|
||||
// force a collapse — the normal idle/keyboard-hide paths take over again.
|
||||
const alwaysExpandedRef = React.useRef(alwaysExpanded);
|
||||
alwaysExpandedRef.current = alwaysExpanded;
|
||||
React.useEffect(() => {
|
||||
if (!isMobile || !alwaysExpanded) return;
|
||||
setExpanded(true);
|
||||
}, [alwaysExpanded, isMobile]);
|
||||
|
||||
// The draft screen restructures itself around the composer: its starter
|
||||
// chips leave once the full composer is up, and its centered title
|
||||
// re-centers over whatever room remains. Announced as a root class from a
|
||||
@@ -95,12 +113,16 @@ export function useMobileComposerShell(
|
||||
// swap — keyed on the keyboard instead (oc-keyboard-open arrives with the
|
||||
// keyboardWillShow bridge event, ~100ms later), the chips vanished
|
||||
// mid-rise as a second visible jump.
|
||||
//
|
||||
// Not announced while `alwaysExpanded`: there the full composer is the
|
||||
// resting state, not a keyboard takeover, so claiming otherwise would hide
|
||||
// the starters permanently. The keyboard classes still cover that case.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isMobile || typeof document === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('oc-composer-expanded', expanded);
|
||||
root.classList.toggle('oc-composer-expanded', expanded && !alwaysExpanded);
|
||||
return () => root.classList.remove('oc-composer-expanded');
|
||||
}, [expanded, isMobile]);
|
||||
}, [alwaysExpanded, expanded, isMobile]);
|
||||
|
||||
const expand = React.useCallback(() => {
|
||||
expandIntentRef.current = 'focus';
|
||||
@@ -151,7 +173,7 @@ export function useMobileComposerShell(
|
||||
// insert-and-send) collapse straight back to the pill rather than
|
||||
// parking on the normal composer for the usual grace period.
|
||||
window.setTimeout(() => {
|
||||
if (!expandedRef.current) return;
|
||||
if (!expandedRef.current || alwaysExpandedRef.current) return;
|
||||
if (editorRef.current?.isFocused()) return;
|
||||
setExpanded(false);
|
||||
setExpandedInput(false);
|
||||
@@ -288,7 +310,7 @@ export function useMobileComposerShell(
|
||||
|| holders.isDragging;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isMobile || !expanded || busy) return;
|
||||
if (!isMobile || !expanded || busy || alwaysExpanded) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
// Authoritative DOM check: the React focus state can lag a
|
||||
// programmatic refocus (the overlay-close restore above).
|
||||
@@ -299,7 +321,7 @@ export function useMobileComposerShell(
|
||||
setExpandedInput(false);
|
||||
}, 250);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [busy, editorRef, expanded, isMobile, setExpandedInput]);
|
||||
}, [alwaysExpanded, busy, editorRef, expanded, isMobile, setExpandedInput]);
|
||||
|
||||
const busyRef = React.useRef(false);
|
||||
busyRef.current = busy;
|
||||
@@ -344,7 +366,7 @@ export function useMobileComposerShell(
|
||||
const handleIntent = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ open?: boolean }>).detail;
|
||||
if (!detail || detail.open !== false) return;
|
||||
if (!expandedRef.current) return;
|
||||
if (!expandedRef.current || alwaysExpandedRef.current) return;
|
||||
// Something still holds the composer open (dictation, an overlay
|
||||
// that closed the keyboard, a drag) — the fallback path handles it.
|
||||
if (busyRef.current) return;
|
||||
@@ -360,6 +382,9 @@ export function useMobileComposerShell(
|
||||
|
||||
const onEditorFocus = React.useCallback(() => {
|
||||
if (!isMobile) return;
|
||||
// Focus is the only moment a soft keyboard would be presented, so it is
|
||||
// also the only moment its ABSENCE tells us a hardware one is attached.
|
||||
if (isCapacitorApp()) observeEditorFocus();
|
||||
if (blurTimerRef.current !== null) {
|
||||
window.clearTimeout(blurTimerRef.current);
|
||||
blurTimerRef.current = null;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
||||
|
||||
describe('markdown sanitization', () => {
|
||||
test('turns raw assistant HTML into inert visible text', () => {
|
||||
const payload = '<style>@import url("https://example.test/theme.css");</style>';
|
||||
|
||||
expect(escapeRawMarkdownHtml(payload)).toBe(
|
||||
'<style>@import url("https://example.test/theme.css");</style>',
|
||||
);
|
||||
});
|
||||
|
||||
test('forbids script and stylesheet elements as active content', () => {
|
||||
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('script');
|
||||
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('style');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import DOMPurify from 'dompurify';
|
||||
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { highlightCodeInWorker } from './markdown-worker';
|
||||
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
||||
|
||||
const escapeAttr = (value: string): string =>
|
||||
value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
@@ -166,6 +167,12 @@ const parser = marked.use({
|
||||
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
|
||||
// such as stylesheets or positioned overlays into the application shell.
|
||||
html({ text }) {
|
||||
return escapeRawMarkdownHtml(text);
|
||||
},
|
||||
link({ href, title, text }) {
|
||||
const target = href ?? '';
|
||||
const agentName = parseAgentHref(target);
|
||||
@@ -283,8 +290,10 @@ const SANITIZE_CONFIG = {
|
||||
USE_PROFILES: { html: true, mathMl: true, svg: true },
|
||||
ADD_TAGS: ['svg', 'path', 'g', 'rect', 'line', 'polygon', 'polyline', 'circle', 'ellipse', 'text', 'tspan', 'defs', 'marker'],
|
||||
ADD_ATTR: ['d', 'viewBox', 'preserveAspectRatio', 'xmlns', 'target', 'fill', 'stroke', 'stroke-width', 'transform', 'points', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'style'],
|
||||
FORBID_TAGS: ['script'],
|
||||
FORBID_CONTENTS: ['script'],
|
||||
// Defense in depth for generated/highlighter HTML after raw markdown HTML
|
||||
// has been escaped by the marked renderer above.
|
||||
FORBID_TAGS: [...MARKDOWN_FORBIDDEN_TAGS],
|
||||
FORBID_CONTENTS: [...MARKDOWN_FORBIDDEN_TAGS],
|
||||
};
|
||||
|
||||
let sanitizeHookInstalled = false;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Raw HTML in assistant markdown is untrusted and must stay inert text. */
|
||||
export const escapeRawMarkdownHtml = (value: string): string =>
|
||||
value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
/** Active elements forbidden again at the final DOMPurify boundary. */
|
||||
export const MARKDOWN_FORBIDDEN_TAGS = ['script', 'style'] as const;
|
||||
@@ -34,7 +34,6 @@ import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { useChatSurfaceMode } from '@/components/chat/useChatSurfaceMode';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { toPng } from 'html-to-image';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { formatTimestampForDisplay } from './timeFormat';
|
||||
@@ -1532,6 +1531,9 @@ const AssistantMessageBody = React.memo(({
|
||||
|
||||
let wrapper: HTMLDivElement | null = null;
|
||||
try {
|
||||
// Load the exporter before attaching its temporary clone so a slow
|
||||
// chunk request cannot leave export-only content in the page layout.
|
||||
const { toPng } = await import('html-to-image');
|
||||
const originalElement = sourceElement;
|
||||
const computedStyle = window.getComputedStyle(originalElement);
|
||||
const rootStyle = window.getComputedStyle(document.documentElement);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import type { EditorAPI } from '@/lib/api/types';
|
||||
|
||||
import { ApplyPatchFileButtons } from './ApplyPatchFileButtons';
|
||||
import { openApplyPatchFileInEditor } from './applyPatchEditorAction';
|
||||
|
||||
const makePatch = (path: string, line: number, before: string, after: string) => [
|
||||
`--- a/${path}`,
|
||||
`+++ b/${path}`,
|
||||
`@@ -${line} +${line} @@`,
|
||||
`-${before}`,
|
||||
`+${after}`,
|
||||
].join('\n');
|
||||
|
||||
const files = [
|
||||
{
|
||||
filePath: '/workspace/project/src/first.ts',
|
||||
relativePath: 'src/first.ts',
|
||||
patch: makePatch('src/first.ts', 4, 'first old', 'first new'),
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
type: 'update',
|
||||
},
|
||||
{
|
||||
filePath: '/workspace/project/src/second.ts',
|
||||
relativePath: 'src/second.ts',
|
||||
patch: makePatch('src/second.ts', 12, 'second old', 'second new'),
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
type: 'update',
|
||||
},
|
||||
];
|
||||
|
||||
describe('ApplyPatchFileButtons', () => {
|
||||
test('renders one labeled button per non-deleted file', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<ApplyPatchFileButtons
|
||||
metadata={{ files }}
|
||||
openDiffLabel="Open file diff"
|
||||
onFileClick={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup.match(/<button/g)).toHaveLength(2);
|
||||
expect(markup).toContain('aria-label="Open file diff: src/first.ts"');
|
||||
expect(markup).toContain('aria-label="Open file diff: src/second.ts"');
|
||||
});
|
||||
|
||||
test('opens each clicked file with its own authoritative path, patch, and line', () => {
|
||||
const openDiffCalls: Parameters<EditorAPI['openDiff']>[] = [];
|
||||
const editor: EditorAPI = {
|
||||
openDiff: async (...args) => { openDiffCalls.push(args); },
|
||||
openFile: async () => undefined,
|
||||
};
|
||||
let propagationStops = 0;
|
||||
const stopPropagation = () => { propagationStops += 1; };
|
||||
const tree = ApplyPatchFileButtons({
|
||||
metadata: { files },
|
||||
openDiffLabel: 'Open file diff',
|
||||
onFileClick: (file, event) => {
|
||||
event.stopPropagation();
|
||||
const targetPath = typeof file.relativePath === 'string' ? file.relativePath : '';
|
||||
openApplyPatchFileInEditor({
|
||||
currentDirectory: '/workspace/project',
|
||||
diffLabel: `${targetPath} (changes)`,
|
||||
editor,
|
||||
file,
|
||||
isVSCode: true,
|
||||
});
|
||||
},
|
||||
}) as React.ReactElement<{ children: React.ReactNode }>;
|
||||
const buttons = React.Children.toArray(tree.props.children) as React.ReactElement<{
|
||||
onClick: (event: { stopPropagation: () => void }) => void;
|
||||
}>[];
|
||||
|
||||
buttons[0]?.props.onClick({ stopPropagation });
|
||||
buttons[1]?.props.onClick({ stopPropagation });
|
||||
|
||||
expect(propagationStops).toBe(2);
|
||||
expect(openDiffCalls).toEqual([
|
||||
['', '/workspace/project/src/first.ts', 'src/first.ts (changes)', {
|
||||
line: 4,
|
||||
patch: files[0]?.patch,
|
||||
}],
|
||||
['', '/workspace/project/src/second.ts', 'src/second.ts (changes)', {
|
||||
line: 12,
|
||||
patch: files[1]?.patch,
|
||||
}],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { getApplyPatchFilePath } from './toolDiffUtils';
|
||||
|
||||
type ApplyPatchFileEntry = {
|
||||
file: Record<string, unknown>;
|
||||
path: string;
|
||||
name: string;
|
||||
added: number | null;
|
||||
removed: number | null;
|
||||
};
|
||||
|
||||
const parseCount = (value: unknown): number | null => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.max(0, Math.trunc(value));
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? Math.max(0, parsed) : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const combineCounts = (base: number | null, incoming: number | null): number | null => {
|
||||
if (base === null) return incoming;
|
||||
if (incoming === null) return base;
|
||||
return base + incoming;
|
||||
};
|
||||
|
||||
const getApplyPatchFileEntries = (metadata: Record<string, unknown> | undefined): ApplyPatchFileEntry[] => {
|
||||
const files = Array.isArray(metadata?.files) ? metadata.files : [];
|
||||
const entriesByPath = new Map<string, ApplyPatchFileEntry>();
|
||||
|
||||
for (const file of files) {
|
||||
if (!file || typeof file !== 'object') continue;
|
||||
const fileRecord = file as Record<string, unknown>;
|
||||
const displayPath = typeof fileRecord.relativePath === 'string'
|
||||
? fileRecord.relativePath
|
||||
: typeof fileRecord.filePath === 'string'
|
||||
? fileRecord.filePath
|
||||
: '';
|
||||
if (!displayPath) continue;
|
||||
|
||||
const added = parseCount(fileRecord.additions);
|
||||
const removed = parseCount(fileRecord.deletions);
|
||||
const existing = entriesByPath.get(displayPath);
|
||||
if (existing) {
|
||||
existing.added = combineCounts(existing.added, added);
|
||||
existing.removed = combineCounts(existing.removed, removed);
|
||||
continue;
|
||||
}
|
||||
|
||||
entriesByPath.set(displayPath, {
|
||||
file: fileRecord,
|
||||
path: displayPath,
|
||||
name: displayPath.split('/').pop() || displayPath,
|
||||
added,
|
||||
removed,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(entriesByPath.values());
|
||||
};
|
||||
|
||||
export const ApplyPatchFileButtons = ({
|
||||
animate = true,
|
||||
metadata,
|
||||
onFileClick,
|
||||
openDiffLabel,
|
||||
showFileIcons = true,
|
||||
textClassName,
|
||||
}: {
|
||||
animate?: boolean;
|
||||
metadata: Record<string, unknown> | undefined;
|
||||
onFileClick?: (file: Record<string, unknown>, event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
openDiffLabel: string;
|
||||
showFileIcons?: boolean;
|
||||
textClassName?: string;
|
||||
}): React.ReactNode => {
|
||||
const entries = getApplyPatchFileEntries(metadata);
|
||||
if (entries.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{entries.map((entry) => {
|
||||
const hasPerFileDiff = entry.added !== null || entry.removed !== null;
|
||||
const content = (
|
||||
<>
|
||||
{showFileIcons ? <FileTypeIcon filePath={entry.path} className="h-3.5 w-3.5" /> : null}
|
||||
<Text
|
||||
variant={animate ? 'generate-effect' : 'static'}
|
||||
className={cn('min-w-0 max-w-full truncate', textClassName)}
|
||||
style={{ color: 'var(--tools-description)' }}
|
||||
title={entry.path}
|
||||
>
|
||||
{entry.name}
|
||||
</Text>
|
||||
{hasPerFileDiff ? (
|
||||
<span className="flex-shrink-0 inline-flex items-center gap-0 typography-meta" style={{ fontSize: '0.8rem', lineHeight: '1' }}>
|
||||
<span style={{ color: 'var(--status-success)' }}>+{entry.added ?? 0}</span>
|
||||
<span style={{ color: 'var(--tools-description)' }}>/</span>
|
||||
<span style={{ color: 'var(--status-error)' }}>-{entry.removed ?? 0}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const canOpen = onFileClick && entry.file.type !== 'delete' && getApplyPatchFilePath(entry.file);
|
||||
const actionLabel = `${openDiffLabel}: ${entry.path}`;
|
||||
return canOpen ? (
|
||||
<Button
|
||||
key={entry.path}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className={cn('min-w-0 max-w-full gap-1 normal-case font-normal tracking-normal', textClassName)}
|
||||
aria-label={actionLabel}
|
||||
title={actionLabel}
|
||||
onClick={(event) => onFileClick(entry.file, event)}
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
) : (
|
||||
<span key={entry.path} className={cn('inline-flex min-w-0 max-w-full items-center gap-1', textClassName)} style={{ color: 'var(--tools-description)' }}>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -51,6 +51,10 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
||||
|
||||
## Current important behavior
|
||||
|
||||
- Assistant markdown treats raw HTML as inert visible text. The final generated
|
||||
HTML is sanitized as defense in depth, with script and style elements
|
||||
forbidden, so message content cannot inject active DOM or application-wide
|
||||
CSS into any runtime surface.
|
||||
- `read` and `skill` are **static navigation tools** and render via `StaticToolRow`.
|
||||
- Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`.
|
||||
- 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.
|
||||
|
||||
@@ -54,11 +54,23 @@ import {
|
||||
} from './taskToolModel';
|
||||
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getDiffPatchEntries, getPatchText, type DiffPatchEntry } from './toolDiffUtils';
|
||||
import {
|
||||
extractFirstChangedLineFromDiff,
|
||||
getDiffPatchEntries,
|
||||
getFirstChangedLineFromMetadata,
|
||||
getMutatedToolPaths,
|
||||
getPatchText,
|
||||
getPrimaryDiffFromMetadata,
|
||||
getPrimaryToolPath,
|
||||
type DiffPatchEntry,
|
||||
} from './toolDiffUtils';
|
||||
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
|
||||
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
||||
import { getStreamingOutputAppend, getToolOutput } from './toolOutput';
|
||||
import { toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
import { getToolDescriptionFallback } from './toolRenderUtils';
|
||||
import { ApplyPatchFileButtons } from './ApplyPatchFileButtons';
|
||||
import { openApplyPatchFileInEditor } from './applyPatchEditorAction';
|
||||
|
||||
const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal';
|
||||
const TOOL_ROW_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS);
|
||||
@@ -77,84 +89,6 @@ interface ToolPartProps {
|
||||
animateTailText?: boolean;
|
||||
}
|
||||
|
||||
const getMultiFileDescription = (
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
animate = true,
|
||||
showFileIcons = true,
|
||||
): React.ReactNode => {
|
||||
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
|
||||
if (files.length <= 1) return null;
|
||||
|
||||
const parseCount = (value: unknown): number | null => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.max(0, Math.trunc(value));
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return Math.max(0, parsed);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const combineCounts = (base: number | null, incoming: number | null): number | null => {
|
||||
if (base === null) return incoming;
|
||||
if (incoming === null) return base;
|
||||
return base + incoming;
|
||||
};
|
||||
|
||||
const entriesByPath = new Map<string, { path: string; name: string; added: number | null; removed: number | null }>();
|
||||
|
||||
for (const file of files) {
|
||||
const fileObj = file as { relativePath?: string; filePath?: string; additions?: unknown; deletions?: unknown };
|
||||
const filePath = fileObj.relativePath || fileObj.filePath || '';
|
||||
if (!filePath) continue;
|
||||
const fileName = filePath.split('/').pop() || filePath;
|
||||
const added = parseCount(fileObj.additions);
|
||||
const removed = parseCount(fileObj.deletions);
|
||||
|
||||
const existing = entriesByPath.get(filePath);
|
||||
if (existing) {
|
||||
existing.added = combineCounts(existing.added, added);
|
||||
existing.removed = combineCounts(existing.removed, removed);
|
||||
continue;
|
||||
}
|
||||
|
||||
entriesByPath.set(filePath, { path: filePath, name: fileName, added, removed });
|
||||
}
|
||||
|
||||
const entries = Array.from(entriesByPath.values());
|
||||
|
||||
return (
|
||||
<>
|
||||
{entries.map((entry) => {
|
||||
const hasPerFileDiff = entry.added !== null || entry.removed !== null;
|
||||
return (
|
||||
<span key={entry.path} className={cn('inline-flex min-w-0 max-w-full items-center gap-1', TOOL_ROW_DESCRIPTION_CLASS)} style={{ color: 'var(--tools-description)' }}>
|
||||
{showFileIcons ? <FileTypeIcon filePath={entry.path} className="h-3.5 w-3.5" /> : null}
|
||||
<Text
|
||||
variant={animate ? 'generate-effect' : 'static'}
|
||||
className={cn('min-w-0 max-w-full truncate', TOOL_ROW_DESCRIPTION_CLASS)}
|
||||
style={{ color: 'var(--tools-description)' }}
|
||||
title={entry.path}
|
||||
>
|
||||
{entry.name}
|
||||
</Text>
|
||||
{hasPerFileDiff ? (
|
||||
<span className="flex-shrink-0 inline-flex items-center gap-0 typography-meta" style={{ fontSize: '0.8rem', lineHeight: '1' }}>
|
||||
<span style={{ color: 'var(--status-success)' }}>+{entry.added ?? 0}</span>
|
||||
<span style={{ color: 'var(--tools-description)' }}>/</span>
|
||||
<span style={{ color: 'var(--status-error)' }}>-{entry.removed ?? 0}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeToolName = (toolName: string | undefined | null): string => {
|
||||
if (typeof toolName !== 'string') {
|
||||
return '';
|
||||
@@ -180,7 +114,6 @@ const GIT_REFRESH_MUTATING_TOOLS = new Set([
|
||||
'write',
|
||||
'apply_patch',
|
||||
'patch',
|
||||
'task',
|
||||
]);
|
||||
|
||||
const formatDuration = (start: number, end?: number, now: number = Date.now()) => {
|
||||
@@ -306,54 +239,6 @@ const parseWriteLineCount = (input?: Record<string, unknown>): number | null =>
|
||||
return lines;
|
||||
};
|
||||
|
||||
const extractFirstChangedLineFromDiff = (diffText: string): number | undefined => {
|
||||
if (!diffText || typeof diffText !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lines = diffText.split('\n');
|
||||
let currentNewLine: number | undefined;
|
||||
let firstHunkStart: number | undefined;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.replace(/\r$/, '');
|
||||
const hunkMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/);
|
||||
if (hunkMatch) {
|
||||
const parsed = Number.parseInt(hunkMatch[1] ?? '', 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
currentNewLine = Math.max(1, parsed);
|
||||
if (!Number.isFinite(firstHunkStart)) {
|
||||
firstHunkStart = currentNewLine;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentNewLine === undefined || !Number.isFinite(currentNewLine)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('diff ')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
return currentNewLine;
|
||||
}
|
||||
|
||||
if (line.startsWith(' ')) {
|
||||
currentNewLine += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('-') || line.startsWith('\\')) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return firstHunkStart;
|
||||
};
|
||||
|
||||
const buildWritePreviewPatch = (filePath: string | undefined, content: string): string | undefined => {
|
||||
const normalizedContent = content.replace(/\r\n/g, '\n');
|
||||
if (!normalizedContent.trim()) {
|
||||
@@ -380,73 +265,6 @@ const buildWritePreviewPatch = (filePath: string | undefined, content: string):
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const getFirstChangedLineFromMetadata = (tool: string, metadata?: Record<string, unknown>): number | undefined => {
|
||||
if (!metadata || (tool !== 'edit' && tool !== 'multiedit' && tool !== 'apply_patch')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const topLevelPatch = getPatchText((metadata as { patch?: unknown }).patch) ?? getPatchText(metadata.diff);
|
||||
if (topLevelPatch) {
|
||||
const line = extractFirstChangedLineFromDiff(topLevelPatch);
|
||||
if (Number.isFinite(line)) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
const files = Array.isArray(metadata.files) ? metadata.files : [];
|
||||
const firstFile = files[0] as { patch?: unknown; diff?: unknown } | undefined;
|
||||
const filePatch = getPatchText(firstFile?.patch) ?? getPatchText(firstFile?.diff);
|
||||
if (filePatch) {
|
||||
const line = extractFirstChangedLineFromDiff(filePatch);
|
||||
if (Number.isFinite(line)) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getPrimaryDiffFromMetadata = (
|
||||
tool: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
preferredPath?: string,
|
||||
): string | undefined => {
|
||||
if (!metadata || (tool !== 'edit' && tool !== 'multiedit' && tool !== 'apply_patch')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const files = Array.isArray(metadata.files) ? metadata.files : [];
|
||||
if (files.length > 0) {
|
||||
const preferred = typeof preferredPath === 'string' && preferredPath.length > 0
|
||||
? preferredPath
|
||||
: undefined;
|
||||
const matched = preferred
|
||||
? files.find((file) => {
|
||||
if (!file || typeof file !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const candidate = file as { relativePath?: unknown; filePath?: unknown };
|
||||
return candidate.relativePath === preferred || candidate.filePath === preferred;
|
||||
})
|
||||
: files[0];
|
||||
|
||||
if (matched && typeof matched === 'object') {
|
||||
const patch = getPatchText((matched as { patch?: unknown; diff?: unknown }).patch)
|
||||
?? getPatchText((matched as { patch?: unknown; diff?: unknown }).diff);
|
||||
if (patch) {
|
||||
return patch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const topLevelPatch = getPatchText((metadata as { patch?: unknown }).patch) ?? getPatchText(metadata.diff);
|
||||
if (topLevelPatch) {
|
||||
return topLevelPatch;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeDisplayPath = (value: string): string => {
|
||||
const trimmed = value.trim().replace(/\\/g, '/').replace(/\/{2,}/g, '/');
|
||||
if (!trimmed || trimmed === '/') {
|
||||
@@ -526,58 +344,6 @@ const normalizeToolDiagnostic = (value: unknown): ToolDiagnostic | null => {
|
||||
};
|
||||
};
|
||||
|
||||
const getPrimaryToolPath = (
|
||||
toolName: string,
|
||||
input: Record<string, unknown> | undefined,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): string | null => {
|
||||
if (toolName === 'apply_patch') {
|
||||
const files = Array.isArray(metadata?.files) ? metadata.files : [];
|
||||
const first = files.find((entry) => {
|
||||
if (!isRecord(entry)) {
|
||||
return false;
|
||||
}
|
||||
return entry.type !== 'delete';
|
||||
});
|
||||
if (!isRecord(first)) {
|
||||
return null;
|
||||
}
|
||||
return typeof first.movePath === 'string'
|
||||
? first.movePath
|
||||
: typeof first.filePath === 'string'
|
||||
? first.filePath
|
||||
: typeof first.relativePath === 'string'
|
||||
? first.relativePath
|
||||
: null;
|
||||
}
|
||||
|
||||
if (toolName === 'edit' || toolName === 'multiedit') {
|
||||
const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined;
|
||||
if (isRecord(fileDiff) && typeof fileDiff.file === 'string') {
|
||||
return fileDiff.file;
|
||||
}
|
||||
return typeof input?.filePath === 'string'
|
||||
? input.filePath
|
||||
: typeof input?.file_path === 'string'
|
||||
? input.file_path
|
||||
: typeof input?.path === 'string'
|
||||
? input.path
|
||||
: null;
|
||||
}
|
||||
|
||||
if (toolName === 'write') {
|
||||
return typeof input?.filePath === 'string'
|
||||
? input.filePath
|
||||
: typeof input?.file_path === 'string'
|
||||
? input.file_path
|
||||
: typeof input?.path === 'string'
|
||||
? input.path
|
||||
: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getToolDiagnosticSection = (
|
||||
toolName: string,
|
||||
input: Record<string, unknown> | undefined,
|
||||
@@ -1686,9 +1452,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
);
|
||||
|
||||
const renderResultContent = () => {
|
||||
const getEntryAbsolutePath = (entry: DiffPatchEntry) => (
|
||||
entry.title.startsWith('/') ? entry.title : `${currentDirectory}/${entry.title}`.replace(/\/+/g, '/')
|
||||
);
|
||||
const getEntryAbsolutePath = (entry: DiffPatchEntry) => toAbsoluteFilePath(currentDirectory, entry.filePath ?? entry.title);
|
||||
const openEntryFile = (entry: DiffPatchEntry, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
const line = extractFirstChangedLineFromDiff(entry.patch);
|
||||
@@ -2054,7 +1818,11 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
onShowPopup,
|
||||
animateTailText = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const state = part.state;
|
||||
const stateWithData = state as ToolStateWithMetadata;
|
||||
const metadata = stateWithData.metadata;
|
||||
const input = stateWithData.input;
|
||||
const showToolFileIcons = useUIStore((s) => s.showToolFileIcons);
|
||||
const currentDirectory = useEffectiveDirectory() ?? '';
|
||||
|
||||
@@ -2063,18 +1831,19 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
|
||||
const status = state?.status as string | undefined;
|
||||
const isFinalized = status === 'completed' || status === 'error' || status === 'aborted' || status === 'failed' || status === 'timeout' || status === 'cancelled';
|
||||
const isSuccessfullyFinalized = status === 'completed';
|
||||
const isError = status === 'error' || status === 'failed';
|
||||
|
||||
const [activeLatched, setActiveLatched] = React.useState<boolean>(!isFinalized);
|
||||
const previousPartIdRef = React.useRef<string | undefined>(part.id);
|
||||
const lastGitRefreshSignatureRef = React.useRef<string>('');
|
||||
const observedActiveGitToolRef = React.useRef(!isFinalized);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (previousPartIdRef.current === part.id) {
|
||||
return;
|
||||
}
|
||||
previousPartIdRef.current = part.id;
|
||||
lastGitRefreshSignatureRef.current = '';
|
||||
observedActiveGitToolRef.current = !isFinalized;
|
||||
// Reset latch only when tool identity changes.
|
||||
setActiveLatched(!isFinalized);
|
||||
}, [isFinalized, part.id]);
|
||||
@@ -2086,20 +1855,34 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
}, [isFinalized]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isFinalized || isError || !currentDirectory) {
|
||||
return;
|
||||
}
|
||||
if (!GIT_REFRESH_MUTATING_TOOLS.has(normalizedPartTool)) {
|
||||
if (!isFinalized) {
|
||||
observedActiveGitToolRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const signature = `${part.id}:${status ?? 'unknown'}`;
|
||||
if (lastGitRefreshSignatureRef.current === signature) {
|
||||
// Historical completed tools can remount when the timeline changes.
|
||||
// Refresh only for a tool whose active state this instance observed.
|
||||
const finalizedAfterObservedActive = observedActiveGitToolRef.current;
|
||||
if (!finalizedAfterObservedActive) {
|
||||
return;
|
||||
}
|
||||
lastGitRefreshSignatureRef.current = signature;
|
||||
sessionEvents.requestGitRefresh({ directory: currentDirectory });
|
||||
}, [currentDirectory, isError, isFinalized, normalizedPartTool, part.id, status]);
|
||||
|
||||
if (!isSuccessfullyFinalized || !GIT_REFRESH_MUTATING_TOOLS.has(normalizedPartTool)) {
|
||||
observedActiveGitToolRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!currentDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
observedActiveGitToolRef.current = false;
|
||||
const paths = getMutatedToolPaths(normalizedPartTool, input, metadata)
|
||||
.map((path) => getRelativePath(path, currentDirectory));
|
||||
sessionEvents.requestGitRefresh({
|
||||
directory: currentDirectory,
|
||||
...(paths.length > 0 ? { paths } : {}),
|
||||
});
|
||||
}, [currentDirectory, input, isFinalized, isSuccessfullyFinalized, metadata, normalizedPartTool]);
|
||||
|
||||
const shouldNotifyStructuralChange = isFinalized || isTaskTool;
|
||||
|
||||
@@ -2125,10 +1908,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
}
|
||||
}, [isExpanded, isTaskTool, shouldNotifyStructuralChange]);
|
||||
|
||||
const stateWithData = state as ToolStateWithMetadata;
|
||||
const metadata = stateWithData.metadata;
|
||||
const partMetadata = (part as unknown as { metadata?: unknown }).metadata;
|
||||
const input = stateWithData.input;
|
||||
const time = stateWithData.time;
|
||||
|
||||
const [pinnedTime, setPinnedTime] = React.useState<{ start?: number; end?: number }>(() => ({
|
||||
@@ -2347,6 +2127,26 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
}, [descriptionPath, normalizedPartTool, stateWithData, input]);
|
||||
const runtime = React.useContext(RuntimeAPIContext);
|
||||
|
||||
const openApplyPatchFile = (file: Record<string, unknown>, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!runtime?.editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
const displayPath = typeof file.relativePath === 'string'
|
||||
? file.relativePath
|
||||
: typeof file.filePath === 'string'
|
||||
? getRelativePath(file.filePath, currentDirectory)
|
||||
: '';
|
||||
openApplyPatchFileInEditor({
|
||||
currentDirectory,
|
||||
diffLabel: `${displayPath} (changes)`,
|
||||
editor: runtime.editor,
|
||||
file,
|
||||
isVSCode: runtime.runtime.isVSCode,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMainClick = (e: { stopPropagation: () => void }) => {
|
||||
if (isTaskTool || !runtime?.editor) {
|
||||
onToggle(part.id);
|
||||
@@ -2356,23 +2156,21 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
let filePath: unknown;
|
||||
let targetLine: number | undefined;
|
||||
let toolDiff: string | undefined;
|
||||
if (part.tool === 'edit' || part.tool === 'multiedit') {
|
||||
if (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit') {
|
||||
filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
|
||||
targetLine = getFirstChangedLineFromMetadata(part.tool, metadata);
|
||||
if (typeof filePath === 'string') {
|
||||
toolDiff = getPrimaryDiffFromMetadata(part.tool, metadata, filePath);
|
||||
toolDiff = getPrimaryDiffFromMetadata(normalizedPartTool, metadata, filePath);
|
||||
targetLine = getFirstChangedLineFromMetadata(normalizedPartTool, metadata, filePath);
|
||||
}
|
||||
} else if (part.tool === 'apply_patch') {
|
||||
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
|
||||
const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined;
|
||||
filePath = firstFile?.relativePath || firstFile?.filePath;
|
||||
targetLine = getFirstChangedLineFromMetadata(part.tool, metadata);
|
||||
} else if (normalizedPartTool === 'apply_patch') {
|
||||
filePath = getPrimaryToolPath(normalizedPartTool, input, metadata);
|
||||
if (typeof filePath === 'string') {
|
||||
toolDiff = getPrimaryDiffFromMetadata(part.tool, metadata, filePath);
|
||||
toolDiff = getPrimaryDiffFromMetadata(normalizedPartTool, metadata, filePath);
|
||||
targetLine = getFirstChangedLineFromMetadata(normalizedPartTool, metadata, filePath);
|
||||
}
|
||||
} else if (['write', 'create', 'file_write'].includes(part.tool)) {
|
||||
} else if (['write', 'create', 'file_write'].includes(normalizedPartTool)) {
|
||||
filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
|
||||
} else if (part.tool === 'lsp') {
|
||||
} else if (normalizedPartTool === 'lsp') {
|
||||
filePath = input?.filePath || input?.file_path || input?.path;
|
||||
const line = input?.line;
|
||||
targetLine = typeof line === 'number' && Number.isFinite(line) ? Math.trunc(line) : undefined;
|
||||
@@ -2380,11 +2178,8 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
|
||||
if (typeof filePath === 'string') {
|
||||
e.stopPropagation();
|
||||
let absolutePath = filePath;
|
||||
if (!filePath.startsWith('/')) {
|
||||
absolutePath = currentDirectory.endsWith('/') ? currentDirectory + filePath : currentDirectory + '/' + filePath;
|
||||
}
|
||||
if (runtime.runtime.isVSCode && toolDiff && (part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch')) {
|
||||
const absolutePath = toAbsoluteFilePath(currentDirectory, filePath);
|
||||
if (runtime.runtime.isVSCode && toolDiff && (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch')) {
|
||||
const label = `${getRelativePath(absolutePath, currentDirectory)} (changes)`;
|
||||
void runtime.editor.openDiff('', absolutePath, label, { line: targetLine, patch: toolDiff });
|
||||
return;
|
||||
@@ -2417,60 +2212,85 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
{}
|
||||
<div
|
||||
className={cn(
|
||||
'group/tool flex gap-1.5 pr-2 pl-px py-1.5 rounded-xl cursor-pointer',
|
||||
isMultiFileApplyPatch ? 'flex-wrap items-start' : 'items-center'
|
||||
)}
|
||||
onClick={handleMainClick}
|
||||
onKeyDown={handleMainKeyDown}
|
||||
'group/tool flex gap-1.5 pr-2 pl-px py-1.5 rounded-xl',
|
||||
isMultiFileApplyPatch ? 'flex-wrap items-start cursor-pointer' : 'items-center cursor-pointer',
|
||||
)}
|
||||
onClick={isMultiFileApplyPatch ? () => onToggle(part.id) : handleMainClick}
|
||||
onKeyDown={isMultiFileApplyPatch ? (event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
onToggle(part.id);
|
||||
} : handleMainKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className={cn('flex gap-1.5', isMultiFileApplyPatch ? 'w-full min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5' : 'items-center flex-shrink-0')}>
|
||||
{}
|
||||
<div
|
||||
// h-5 matches StaticToolRow's icon column, so expandable
|
||||
// and static rows come out the same height (the 14px
|
||||
// icon alone left these rows ~2px shorter).
|
||||
className="relative h-5 w-3.5 flex-shrink-0 cursor-pointer"
|
||||
onClick={(event) => { event.stopPropagation(); onToggle(part.id); }}
|
||||
>
|
||||
{}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center transition-opacity',
|
||||
isExpanded && 'opacity-0',
|
||||
!isExpanded && 'group-hover/tool:opacity-0'
|
||||
)}
|
||||
style={iconStyle}
|
||||
>
|
||||
{getToolIcon(normalizedPartTool || part.tool)}
|
||||
</div>
|
||||
{}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-opacity flex items-center justify-center',
|
||||
isExpanded && 'opacity-100',
|
||||
!isExpanded && 'opacity-0 group-hover/tool:opacity-100'
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
{isMultiFileApplyPatch ? (
|
||||
<>
|
||||
<MinDurationShineText
|
||||
active={Boolean(isActive && !isError)}
|
||||
minDurationMs={300}
|
||||
className={cn(TOOL_ROW_TITLE_CLASS, 'flex-shrink-0')}
|
||||
style={titleStyle}
|
||||
title={displayName}
|
||||
>
|
||||
{displayName}
|
||||
</MinDurationShineText>
|
||||
{getMultiFileDescription(metadata, animateTailText, showToolFileIcons)}
|
||||
<div className="flex h-5 flex-shrink-0 items-center gap-1.5">
|
||||
<span className="relative h-3.5 w-3.5 flex-shrink-0">
|
||||
<span className={cn(
|
||||
'absolute inset-0 flex items-center justify-center transition-opacity',
|
||||
isExpanded ? 'opacity-0' : 'group-hover/tool:opacity-0',
|
||||
)} style={iconStyle}>
|
||||
{getToolIcon(normalizedPartTool || part.tool)}
|
||||
</span>
|
||||
<Icon
|
||||
name={isExpanded ? 'arrow-down-s' : 'arrow-right-s'}
|
||||
className={cn(
|
||||
'absolute inset-0 h-3.5 w-3.5 transition-opacity',
|
||||
isExpanded ? 'opacity-100' : 'opacity-0 group-hover/tool:opacity-100',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<MinDurationShineText
|
||||
active={Boolean(isActive && !isError)}
|
||||
minDurationMs={300}
|
||||
className={cn(TOOL_ROW_TITLE_CLASS, 'flex-shrink-0')}
|
||||
style={titleStyle}
|
||||
>
|
||||
{displayName}
|
||||
</MinDurationShineText>
|
||||
</div>
|
||||
<ApplyPatchFileButtons
|
||||
metadata={metadata}
|
||||
animate={animateTailText}
|
||||
showFileIcons={showToolFileIcons}
|
||||
textClassName={TOOL_ROW_DESCRIPTION_CLASS}
|
||||
openDiffLabel={t('chat.toolPart.openFileDiff')}
|
||||
onFileClick={runtime?.editor ? openApplyPatchFile : undefined}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
// h-5 matches StaticToolRow's icon column, so expandable
|
||||
// and static rows come out the same height (the 14px
|
||||
// icon alone left these rows ~2px shorter).
|
||||
className="relative h-5 w-3.5 flex-shrink-0 cursor-pointer"
|
||||
onClick={(event) => { event.stopPropagation(); onToggle(part.id); }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center transition-opacity',
|
||||
isExpanded && 'opacity-0',
|
||||
!isExpanded && 'group-hover/tool:opacity-0'
|
||||
)}
|
||||
style={iconStyle}
|
||||
>
|
||||
{getToolIcon(normalizedPartTool || part.tool)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-opacity flex items-center justify-center',
|
||||
isExpanded && 'opacity-100',
|
||||
!isExpanded && 'opacity-0 group-hover/tool:opacity-100'
|
||||
)}
|
||||
>
|
||||
{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">
|
||||
<MinDurationShineText
|
||||
active={Boolean(isActive && !isError)}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { EditorAPI } from '@/lib/api/types';
|
||||
import { toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
|
||||
import { extractFirstChangedLineFromDiff, getApplyPatchFilePath, getPatchText } from './toolDiffUtils';
|
||||
|
||||
export const openApplyPatchFileInEditor = ({
|
||||
currentDirectory,
|
||||
diffLabel,
|
||||
editor,
|
||||
file,
|
||||
isVSCode,
|
||||
}: {
|
||||
currentDirectory: string;
|
||||
diffLabel: string;
|
||||
editor: EditorAPI;
|
||||
file: Record<string, unknown>;
|
||||
isVSCode: boolean;
|
||||
}): boolean => {
|
||||
const filePath = getApplyPatchFilePath(file);
|
||||
if (!filePath || file.type === 'delete') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const patch = getPatchText(file.patch) ?? getPatchText(file.diff);
|
||||
const line = patch ? extractFirstChangedLineFromDiff(patch) : undefined;
|
||||
const absolutePath = toAbsoluteFilePath(currentDirectory, filePath);
|
||||
if (isVSCode && patch) {
|
||||
void editor.openDiff('', absolutePath, diffLabel, { line, patch });
|
||||
} else {
|
||||
void editor.openFile(absolutePath, line);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -1,10 +1,110 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getDiffPatchEntries, getRenderablePatchInfo } from './toolDiffUtils';
|
||||
import {
|
||||
getApplyPatchFilePath,
|
||||
getDiffPatchEntries,
|
||||
getFirstChangedLineFromMetadata,
|
||||
getMutatedToolPaths,
|
||||
getPrimaryDiffFromMetadata,
|
||||
getPrimaryToolPath,
|
||||
getRenderablePatchInfo,
|
||||
} from './toolDiffUtils';
|
||||
|
||||
const identity = (path: string) => path;
|
||||
|
||||
describe('toolDiffUtils', () => {
|
||||
test('prefers the absolute apply_patch path over its worktree-relative label', () => {
|
||||
expect(getPrimaryToolPath('apply_patch', undefined, {
|
||||
files: [{
|
||||
filePath: '/workspace/project/src/file.ts',
|
||||
relativePath: 'workspace/project/src/file.ts',
|
||||
type: 'update',
|
||||
}],
|
||||
})).toBe('/workspace/project/src/file.ts');
|
||||
});
|
||||
|
||||
test('opens the move destination and skips deleted apply_patch files', () => {
|
||||
expect(getPrimaryToolPath('apply_patch', undefined, {
|
||||
files: [
|
||||
{ filePath: '/workspace/deleted.ts', relativePath: 'deleted.ts', type: 'delete' },
|
||||
{
|
||||
filePath: '/workspace/old.ts',
|
||||
relativePath: 'new.ts',
|
||||
movePath: '/workspace/new.ts',
|
||||
type: 'move',
|
||||
},
|
||||
],
|
||||
})).toBe('/workspace/new.ts');
|
||||
});
|
||||
|
||||
test('falls back to the relative apply_patch path for legacy metadata', () => {
|
||||
expect(getPrimaryToolPath('apply_patch', undefined, {
|
||||
files: [{ relativePath: 'src/file.ts', type: 'update' }],
|
||||
})).toBe('src/file.ts');
|
||||
});
|
||||
|
||||
test('resolves each apply_patch file independently', () => {
|
||||
expect(getApplyPatchFilePath({
|
||||
filePath: '/workspace/project/src/first.ts',
|
||||
relativePath: 'workspace/project/src/first.ts',
|
||||
})).toBe('/workspace/project/src/first.ts');
|
||||
expect(getApplyPatchFilePath({
|
||||
filePath: '/workspace/project/src/old.ts',
|
||||
movePath: '/workspace/project/src/second.ts',
|
||||
relativePath: 'src/second.ts',
|
||||
})).toBe('/workspace/project/src/second.ts');
|
||||
});
|
||||
|
||||
test('lists every apply_patch mutation path, including both sides of a move', () => {
|
||||
expect(getMutatedToolPaths('apply_patch', undefined, {
|
||||
files: [
|
||||
{ filePath: '/workspace/project/src/deleted.ts', type: 'delete' },
|
||||
{
|
||||
filePath: '/workspace/project/src/old.ts',
|
||||
movePath: '/workspace/project/src/new.ts',
|
||||
type: 'move',
|
||||
},
|
||||
],
|
||||
})).toEqual([
|
||||
'/workspace/project/src/deleted.ts',
|
||||
'/workspace/project/src/new.ts',
|
||||
'/workspace/project/src/old.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not invent paths for bash or task tools', () => {
|
||||
expect(getMutatedToolPaths('bash', { command: 'date' }, undefined)).toEqual([]);
|
||||
expect(getMutatedToolPaths('task', { description: 'inspect' }, undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
test('selects the move patch and line from the same non-deleted file', () => {
|
||||
const deletedPatch = '@@ -3 +3 @@\n-old\n+deleted';
|
||||
const movedPatch = '@@ -42 +42 @@\n-before\n+after';
|
||||
const metadata = {
|
||||
patch: deletedPatch,
|
||||
files: [
|
||||
{
|
||||
filePath: '/workspace/project/src/deleted.ts',
|
||||
relativePath: 'src/deleted.ts',
|
||||
patch: deletedPatch,
|
||||
type: 'delete',
|
||||
},
|
||||
{
|
||||
filePath: '/workspace/project/src/old.ts',
|
||||
movePath: '/workspace/project/src/moved.ts',
|
||||
relativePath: 'src/moved.ts',
|
||||
patch: movedPatch,
|
||||
type: 'move',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getPrimaryDiffFromMetadata('apply_patch', metadata, '/workspace/project/src/moved.ts'))
|
||||
.toBe(movedPatch);
|
||||
expect(getFirstChangedLineFromMetadata('apply_patch', metadata, '/workspace/project/src/moved.ts'))
|
||||
.toBe(42);
|
||||
});
|
||||
|
||||
test('treats raw apply_patch envelopes as text, not visual diffs', () => {
|
||||
const entries = getDiffPatchEntries(undefined, [
|
||||
'*** Begin Patch',
|
||||
@@ -57,6 +157,27 @@ describe('toolDiffUtils', () => {
|
||||
expect(entries[0]?.title).toBe('src/file.ts');
|
||||
});
|
||||
|
||||
test('keeps the authoritative path for every metadata file entry', () => {
|
||||
const patch = [
|
||||
'--- a/src/file.ts',
|
||||
'+++ b/src/file.ts',
|
||||
'@@ -1 +1 @@',
|
||||
'-old',
|
||||
'+new',
|
||||
].join('\n');
|
||||
const entries = getDiffPatchEntries({
|
||||
files: [
|
||||
{ filePath: '/workspace/project/src/first.ts', relativePath: 'src/first.ts', patch },
|
||||
{ filePath: '/workspace/project/src/second.ts', relativePath: 'src/second.ts', patch },
|
||||
],
|
||||
}, undefined, identity);
|
||||
|
||||
expect(entries.map((entry) => entry.filePath)).toEqual([
|
||||
'/workspace/project/src/first.ts',
|
||||
'/workspace/project/src/second.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
test('synthesizes headers for valid headerless hunks', () => {
|
||||
const entries = getDiffPatchEntries(undefined, [
|
||||
'@@ -1 +1 @@',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { parsePatchFiles } from '@pierre/diffs';
|
||||
export type DiffPatchEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
filePath?: string;
|
||||
patch: string;
|
||||
renderMode: 'diff' | 'text';
|
||||
};
|
||||
@@ -140,6 +141,195 @@ export const getPatchText = (value: unknown): string | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getApplyPatchFilePath = (file: unknown): string | null => {
|
||||
if (!isRecord(file)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return typeof file.movePath === 'string'
|
||||
? file.movePath
|
||||
: typeof file.filePath === 'string'
|
||||
? file.filePath
|
||||
: typeof file.relativePath === 'string'
|
||||
? file.relativePath
|
||||
: null;
|
||||
};
|
||||
|
||||
export const getPrimaryToolPath = (
|
||||
toolName: string,
|
||||
input: Record<string, unknown> | undefined,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): string | null => {
|
||||
if (toolName === 'apply_patch') {
|
||||
const files = Array.isArray(metadata?.files) ? metadata.files : [];
|
||||
for (const file of files) {
|
||||
if (isRecord(file) && file.type !== 'delete') {
|
||||
const filePath = getApplyPatchFilePath(file);
|
||||
if (filePath) {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (toolName === 'edit' || toolName === 'multiedit') {
|
||||
const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined;
|
||||
if (fileDiff && typeof fileDiff.file === 'string') {
|
||||
return fileDiff.file;
|
||||
}
|
||||
return typeof input?.filePath === 'string'
|
||||
? input.filePath
|
||||
: typeof input?.file_path === 'string'
|
||||
? input.file_path
|
||||
: typeof input?.path === 'string'
|
||||
? input.path
|
||||
: null;
|
||||
}
|
||||
|
||||
if (toolName === 'write') {
|
||||
return typeof input?.filePath === 'string'
|
||||
? input.filePath
|
||||
: typeof input?.file_path === 'string'
|
||||
? input.file_path
|
||||
: typeof input?.path === 'string'
|
||||
? input.path
|
||||
: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getMutatedToolPaths = (
|
||||
toolName: string,
|
||||
input: Record<string, unknown> | undefined,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): string[] => {
|
||||
if (toolName === 'apply_patch') {
|
||||
const files = Array.isArray(metadata?.files) ? metadata.files : [];
|
||||
const paths = new Set<string>();
|
||||
for (const file of files) {
|
||||
if (!isRecord(file)) continue;
|
||||
const filePath = getApplyPatchFilePath(file);
|
||||
if (filePath) paths.add(filePath);
|
||||
if (file.type === 'move' && typeof file.filePath === 'string') {
|
||||
paths.add(file.filePath);
|
||||
}
|
||||
}
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
const primaryPath = getPrimaryToolPath(toolName, input, metadata);
|
||||
return primaryPath ? [primaryPath] : [];
|
||||
};
|
||||
|
||||
const supportsDiffMetadata = (toolName: string): boolean => (
|
||||
toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch'
|
||||
);
|
||||
|
||||
const getMetadataFileForPath = (
|
||||
metadata: Record<string, unknown>,
|
||||
preferredPath?: string,
|
||||
): Record<string, unknown> | undefined => {
|
||||
const files = Array.isArray(metadata.files) ? metadata.files : [];
|
||||
if (!preferredPath) {
|
||||
const first = files[0];
|
||||
return isRecord(first) ? first : undefined;
|
||||
}
|
||||
|
||||
return files.find((file): file is Record<string, unknown> => (
|
||||
isRecord(file)
|
||||
&& (file.relativePath === preferredPath || file.filePath === preferredPath || file.movePath === preferredPath)
|
||||
));
|
||||
};
|
||||
|
||||
export const getPrimaryDiffFromMetadata = (
|
||||
toolName: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
preferredPath?: string,
|
||||
): string | undefined => {
|
||||
if (!metadata || !supportsDiffMetadata(toolName)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchedFile = getMetadataFileForPath(metadata, preferredPath);
|
||||
const filePatch = getPatchText(matchedFile?.patch) ?? getPatchText(matchedFile?.diff);
|
||||
if (filePatch) {
|
||||
return filePatch;
|
||||
}
|
||||
|
||||
return getPatchText(metadata.patch) ?? getPatchText(metadata.diff);
|
||||
};
|
||||
|
||||
export const extractFirstChangedLineFromDiff = (diffText: string): number | undefined => {
|
||||
if (!diffText) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let currentNewLine: number | undefined;
|
||||
let firstHunkStart: number | undefined;
|
||||
for (const rawLine of diffText.split('\n')) {
|
||||
const line = rawLine.replace(/\r$/, '');
|
||||
const hunkMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/);
|
||||
if (hunkMatch) {
|
||||
const parsed = Number.parseInt(hunkMatch[1] ?? '', 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
currentNewLine = Math.max(1, parsed);
|
||||
firstHunkStart ??= currentNewLine;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentNewLine === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('diff ')) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('+')) {
|
||||
return currentNewLine;
|
||||
}
|
||||
if (line.startsWith(' ')) {
|
||||
currentNewLine += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return firstHunkStart;
|
||||
};
|
||||
|
||||
export const getFirstChangedLineFromMetadata = (
|
||||
toolName: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
preferredPath?: string,
|
||||
): number | undefined => {
|
||||
if (!metadata || !supportsDiffMetadata(toolName)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (preferredPath) {
|
||||
const matchedFile = getMetadataFileForPath(metadata, preferredPath);
|
||||
const matchedPatch = getPatchText(matchedFile?.patch) ?? getPatchText(matchedFile?.diff);
|
||||
if (matchedPatch) {
|
||||
const matchedLine = extractFirstChangedLineFromDiff(matchedPatch);
|
||||
if (matchedLine !== undefined) {
|
||||
return matchedLine;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const topLevelPatch = getPatchText(metadata.patch) ?? getPatchText(metadata.diff);
|
||||
if (topLevelPatch) {
|
||||
const topLevelLine = extractFirstChangedLineFromDiff(topLevelPatch);
|
||||
if (topLevelLine !== undefined) {
|
||||
return topLevelLine;
|
||||
}
|
||||
}
|
||||
|
||||
const firstFile = getMetadataFileForPath(metadata);
|
||||
const firstPatch = getPatchText(firstFile?.patch) ?? getPatchText(firstFile?.diff);
|
||||
return firstPatch ? extractFirstChangedLineFromDiff(firstPatch) : undefined;
|
||||
};
|
||||
|
||||
const normalizeParsedPath = (path: string | undefined): string => {
|
||||
const trimmed = (path ?? '').trim().replace(/\t.*$/, '');
|
||||
if (!trimmed || trimmed === '/dev/null') {
|
||||
@@ -287,7 +477,7 @@ const getPatchEntriesFromText = (
|
||||
}];
|
||||
};
|
||||
|
||||
const getFilePatch = (file: unknown): { patch: string; title: string } | null => {
|
||||
const getFilePatch = (file: unknown): { filePath?: string; patch: string; title: string } | null => {
|
||||
if (!isRecord(file)) {
|
||||
return null;
|
||||
}
|
||||
@@ -304,6 +494,7 @@ const getFilePatch = (file: unknown): { patch: string; title: string } | null =>
|
||||
: '';
|
||||
|
||||
return {
|
||||
filePath: getApplyPatchFilePath(file) ?? undefined,
|
||||
patch,
|
||||
title: rawPath,
|
||||
};
|
||||
@@ -325,7 +516,7 @@ export const getDiffPatchEntries = (
|
||||
filePatch.title || `File ${index + 1}`,
|
||||
`file-${index}`,
|
||||
resolveTitle,
|
||||
);
|
||||
).map((entry) => ({ ...entry, filePath: filePatch.filePath }));
|
||||
});
|
||||
|
||||
if (fileEntries.length > 0) {
|
||||
|
||||
@@ -30,6 +30,7 @@ export const iconSpriteData = {
|
||||
"book": `<path d="M3 18.5V5C3 3.34315 4.34315 2 6 2H20C20.5523 2 21 2.44772 21 3V21C21 21.5523 20.5523 22 20 22H6.5C4.567 22 3 20.433 3 18.5ZM19 20V17H6.5C5.67157 17 5 17.6716 5 18.5C5 19.3284 5.67157 20 6.5 20H19ZM5 15.3368C5.45463 15.1208 5.9632 15 6.5 15H19V4H6C5.44772 4 5 4.44772 5 5V15.3368Z" fill="currentColor"/>`,
|
||||
"book-open": `<path d="M13 21V23H11V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H9C10.1947 3 11.2671 3.52375 12 4.35418C12.7329 3.52375 13.8053 3 15 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H13ZM20 19V5H15C13.8954 5 13 5.89543 13 7V19H20ZM11 19V7C11 5.89543 10.1046 5 9 5H4V19H11Z" fill="currentColor"/>`,
|
||||
"booklet": `<path d="M20.0049 2C21.1068 2 22 2.89821 22 3.9908V20.0092C22 21.1087 21.1074 22 20.0049 22H4V18H2V16H4V13H2V11H4V8H2V6H4V2H20.0049ZM8 4H6V20H8V4ZM20 4H10V20H20V4Z" fill="currentColor"/>`,
|
||||
"braces": `<path d="M4 18V14.3C4 13.4716 3.32843 12.8 2.5 12.8H2V11.2H2.5C3.32843 11.2 4 10.5284 4 9.7V6C4 4.34315 5.34315 3 7 3H8V5H7C6.44772 5 6 5.44772 6 6V10.1C6 10.9858 5.42408 11.7372 4.62623 12C5.42408 12.2628 6 13.0142 6 13.9V18C6 18.5523 6.44772 19 7 19H8V21H7C5.34315 21 4 19.6569 4 18ZM20 14.3V18C20 19.6569 18.6569 21 17 21H16V19H17C17.5523 19 18 18.5523 18 18V13.9C18 13.0142 18.5759 12.2628 19.3738 12C18.5759 11.7372 18 10.9858 18 10.1V6C18 5.44772 17.5523 5 17 5H16V3H17C18.6569 3 20 4.34315 20 6V9.7C20 10.5284 20.6716 11.2 21.5 11.2H22V12.8H21.5C20.6716 12.8 20 13.4716 20 14.3Z" fill="currentColor"/>`,
|
||||
"brain": `<path d="M9 4C10.1046 4 11 4.89543 11 6V12.8271C10.1058 12.1373 8.96602 11.7305 7.6644 11.5136L7.3356 13.4864C8.71622 13.7165 9.59743 14.1528 10.1402 14.7408C10.67 15.3147 11 16.167 11 17.5C11 18.8807 9.88071 20 8.5 20C7.11929 20 6 18.8807 6 17.5V17.1493C6.43007 17.2926 6.87634 17.4099 7.3356 17.4864L7.6644 15.5136C6.92149 15.3898 6.1752 15.1144 5.42909 14.7599C4.58157 14.3573 4 13.499 4 12.5C4 11.6653 4.20761 11.0085 4.55874 10.5257C4.90441 10.0504 5.4419 9.6703 6.24254 9.47014L7 9.28078V6C7 4.89543 7.89543 4 9 4ZM12 3.35418C11.2671 2.52376 10.1947 2 9 2C6.79086 2 5 3.79086 5 6V7.77422C4.14895 8.11644 3.45143 8.64785 2.94126 9.34933C2.29239 10.2415 2 11.3347 2 12.5C2 14.0652 2.79565 15.4367 4 16.2422V17.5C4 19.9853 6.01472 22 8.5 22C9.91363 22 11.175 21.3482 12 20.3287C12.825 21.3482 14.0864 22 15.5 22C17.9853 22 20 19.9853 20 17.5V16.2422C21.2044 15.4367 22 14.0652 22 12.5C22 11.3347 21.7076 10.2415 21.0587 9.34933C20.5486 8.64785 19.8511 8.11644 19 7.77422V6C19 3.79086 17.2091 2 15 2C13.8053 2 12.7329 2.52376 12 3.35418ZM18 17.1493V17.5C18 18.8807 16.8807 20 15.5 20C14.1193 20 13 18.8807 13 17.5C13 16.167 13.33 15.3147 13.8598 14.7408C14.4026 14.1528 15.2838 13.7165 16.6644 13.4864L16.3356 11.5136C15.034 11.7305 13.8942 12.1373 13 12.8271V6C13 4.89543 13.8954 4 15 4C16.1046 4 17 4.89543 17 6V9.28078L17.7575 9.47014C18.5581 9.6703 19.0956 10.0504 19.4413 10.5257C19.7924 11.0085 20 11.6653 20 12.5C20 13.499 19.4184 14.3573 18.5709 14.7599C17.8248 15.1144 17.0785 15.3898 16.3356 15.5136L16.6644 17.4864C17.1237 17.4099 17.5699 17.2926 18 17.1493Z" fill="currentColor"/>`,
|
||||
"brain-ai-3": `<path d="M19.5 4.7832V7.6709L22 9.11426V14.8867L19.499 16.3311L19.5 19.2178L14.5 22.1045L12 20.6611L9.5 22.1045L4.5 19.2178V16.3311L2 14.8877L2.00098 9.11328L4.5 7.66992V4.78418L9.5 1.89746L11.999 3.34082L14.501 1.89648L19.5 4.7832ZM13 5.07227V7H11V5.07324L9.5 4.20703L6.49902 5.93848V8.8252L4 10.2676V13.7334L6.5 15.1768V18.0635L9.5 19.7959L11 18.9287V17H13V18.9297L14.5 19.7959L17.5 18.0625V15.1768L20 13.7324V10.2695L17.499 8.8252L17.5 5.9375L14.501 4.20605L13 5.07227ZM14.2646 13.1602C14.3529 12.9473 14.6472 12.9473 14.7354 13.1602L14.8623 13.4648C15.0783 13.986 15.4807 14.4027 15.9873 14.6279L16.3457 14.7871C16.5511 14.8784 16.5511 15.1773 16.3457 15.2686L15.9658 15.4375C15.4721 15.6571 15.0761 16.0586 14.8564 16.5625L14.7334 16.8447C14.6432 17.0517 14.3569 17.0517 14.2666 16.8447L14.1436 16.5625C13.9239 16.0586 13.5279 15.6571 13.0342 15.4375L12.6543 15.2686C12.4489 15.1773 12.4489 14.8784 12.6543 14.7871L13.0127 14.6279C13.5193 14.4027 13.9217 13.986 14.1377 13.4648L14.2646 13.1602ZM9.58789 7.7793C9.74239 7.40671 10.2577 7.4067 10.4121 7.7793L10.6338 8.31445C11.0118 9.22695 11.7161 9.95624 12.6025 10.3506L13.2305 10.6289C13.5899 10.7887 13.5897 11.3117 13.2305 11.4717L12.5654 11.7676C11.7013 12.152 11.0086 12.8548 10.624 13.7373L10.4082 14.2324C10.2504 14.5948 9.74973 14.5948 9.5918 14.2324L9.37598 13.7373C8.99143 12.8548 8.29875 12.152 7.43457 11.7676L6.76953 11.4717C6.41033 11.3117 6.41022 10.7887 6.76953 10.6289L7.39746 10.3506C8.2839 9.95624 8.98832 9.22697 9.36621 8.31445L9.58789 7.7793Z" fill="currentColor"/>`,
|
||||
"briefcase": `<path d="M7 5V2C7 1.44772 7.44772 1 8 1H16C16.5523 1 17 1.44772 17 2V5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V6C2 5.44772 2.44772 5 3 5H7ZM4 16V19H20V16H4ZM4 14H20V7H4V14ZM9 3V5H15V3H9ZM11 11H13V13H11V11Z" fill="currentColor"/>`,
|
||||
@@ -192,6 +193,7 @@ export const iconSpriteData = {
|
||||
"robot": `<path d="M13 4.05493C17.5 4.55237 21 8.36745 21 13V22H3V13C3 8.36745 6.50005 4.55237 11 4.05493V1H13V4.05493ZM19 20V13C19 9.13401 15.866 6 12 6C8.13401 6 5 9.13401 5 13V20H19ZM12 18C9.23858 18 7 15.7614 7 13C7 10.2386 9.23858 8 12 8C14.7614 8 17 10.2386 17 13C17 15.7614 14.7614 18 12 18ZM12 16C13.6569 16 15 14.6569 15 13C15 11.3431 13.6569 10 12 10C10.3431 10 9 11.3431 9 13C9 14.6569 10.3431 16 12 16ZM12 14C11.4477 14 11 13.5523 11 13C11 12.4477 11.4477 12 12 12C12.5523 12 13 12.4477 13 13C13 13.5523 12.5523 14 12 14Z" fill="currentColor"/>`,
|
||||
"robot-2": `<path d="M13.5 2C13.5 2.44425 13.3069 2.84339 13 3.11805V5H18C19.6569 5 21 6.34315 21 8V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V8C3 6.34315 4.34315 5 6 5H11V3.11805C10.6931 2.84339 10.5 2.44425 10.5 2C10.5 1.17157 11.1716 0.5 12 0.5C12.8284 0.5 13.5 1.17157 13.5 2ZM6 7C5.44772 7 5 7.44772 5 8V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V8C19 7.44772 18.5523 7 18 7H13H11H6ZM2 10H0V16H2V10ZM22 10H24V16H22V10ZM9 14.5C9.82843 14.5 10.5 13.8284 10.5 13C10.5 12.1716 9.82843 11.5 9 11.5C8.17157 11.5 7.5 12.1716 7.5 13C7.5 13.8284 8.17157 14.5 9 14.5ZM15 14.5C15.8284 14.5 16.5 13.8284 16.5 13C16.5 12.1716 15.8284 11.5 15 11.5C14.1716 11.5 13.5 12.1716 13.5 13C13.5 13.8284 14.1716 14.5 15 14.5Z" fill="currentColor"/>`,
|
||||
"rocket": `<path d="M4.99958 12.9999C4.99958 7.91198 7.90222 3.5636 11.9996 1.81799C16.0969 3.5636 18.9996 7.91198 18.9996 12.9999C18.9996 13.8229 18.9236 14.6264 18.779 15.4027L20.7194 17.2353C20.8845 17.3913 20.9238 17.6389 20.815 17.8383L18.3196 22.4133C18.1873 22.6557 17.8836 22.7451 17.6412 22.6128C17.5993 22.59 17.5608 22.5612 17.5271 22.5274L15.2925 20.2928C15.1049 20.1053 14.8506 19.9999 14.5854 19.9999H9.41379C9.14857 19.9999 8.89422 20.1053 8.70668 20.2928L6.47209 22.5274C6.27683 22.7227 5.96025 22.7227 5.76498 22.5274C5.73122 22.4937 5.70246 22.4552 5.67959 22.4133L3.18412 17.8383C3.07537 17.6389 3.11464 17.3913 3.27975 17.2353L5.22014 15.4027C5.07551 14.6264 4.99958 13.8229 4.99958 12.9999ZM6.47542 19.6957L7.29247 18.8786C7.85508 18.316 8.61814 17.9999 9.41379 17.9999H14.5854C15.381 17.9999 16.1441 18.316 16.7067 18.8786L17.5237 19.6957L18.5056 17.8955L17.4058 16.8568C16.9117 16.3901 16.6884 15.7045 16.8128 15.0364C16.9366 14.3722 16.9996 13.6911 16.9996 12.9999C16.9996 9.13037 15.0045 5.69965 11.9996 4.04033C8.99462 5.69965 6.99958 9.13037 6.99958 12.9999C6.99958 13.6911 7.06255 14.3722 7.18631 15.0364C7.31078 15.7045 7.08746 16.3901 6.59338 16.8568L5.49353 17.8955L6.47542 19.6957ZM11.9996 12.9999C10.895 12.9999 9.99958 12.1045 9.99958 10.9999C9.99958 9.89537 10.895 8.99994 11.9996 8.99994C13.1041 8.99994 13.9996 9.89537 13.9996 10.9999C13.9996 12.1045 13.1041 12.9999 11.9996 12.9999Z" fill="currentColor"/>`,
|
||||
"route": `<path d="M4 15V8.5C4 6.01472 6.01472 4 8.5 4C10.9853 4 13 6.01472 13 8.5V15.5C13 16.8807 14.1193 18 15.5 18C16.8807 18 18 16.8807 18 15.5V8.82929C16.8348 8.41746 16 7.30622 16 6C16 4.34315 17.3431 3 19 3C20.6569 3 22 4.34315 22 6C22 7.30622 21.1652 8.41746 20 8.82929V15.5C20 17.9853 17.9853 20 15.5 20C13.0147 20 11 17.9853 11 15.5V8.5C11 7.11929 9.88071 6 8.5 6C7.11929 6 6 7.11929 6 8.5V15H9L5 20L1 15H4ZM19 7C19.5523 7 20 6.55228 20 6C20 5.44772 19.5523 5 19 5C18.4477 5 18 5.44772 18 6C18 6.55228 18.4477 7 19 7Z" fill="currentColor"/>`,
|
||||
"save-3": `<path d="M18 19H19V6.82843L17.1716 5H16V9H7V5H5V19H6V12H18V19ZM4 3H18L20.7071 5.70711C20.8946 5.89464 21 6.149 21 6.41421V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V4C3 3.44772 3.44772 3 4 3ZM8 14V19H16V14H8Z" fill="currentColor"/>`,
|
||||
"scales-3": `<path d="M12.9985 2L12.9979 3.278L17.9985 4.94591L21.631 3.73509L22.2634 5.63246L19.2319 6.643L22.3272 15.1549C21.2353 16.2921 19.6996 17 17.9985 17C16.2975 17 14.7618 16.2921 13.6699 15.1549L16.7639 6.643L12.9979 5.387V19H16.9985V21H6.99854V19H10.9979V5.387L7.23192 6.643L10.3272 15.1549C9.23528 16.2921 7.69957 17 5.99854 17C4.2975 17 2.76179 16.2921 1.66992 15.1549L4.76392 6.643L1.73363 5.63246L2.36608 3.73509L5.99854 4.94591L10.9979 3.278L10.9985 2H12.9985ZM17.9985 9.10267L16.04 14.4892C16.628 14.8201 17.2979 15 17.9985 15C18.6992 15 19.3691 14.8201 19.957 14.4892L17.9985 9.10267ZM5.99854 9.10267L4.04004 14.4892C4.62795 14.8201 5.29792 15 5.99854 15C6.69916 15 7.36912 14.8201 7.95703 14.4892L5.99854 9.10267Z" fill="currentColor"/>`,
|
||||
"scan-2": `<path d="M5.67127 4.25705L13.4142 12L12 13.4142L8.55382 9.96803C8.20193 10.5635 8 11.2582 8 12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12C16 9.87494 14.3429 8.13693 12.2503 8.00771L10.4459 6.20323C10.9416 6.07067 11.4625 6 12 6C15.3137 6 18 8.68629 18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12C6 10.7042 6.41079 9.50428 7.10925 8.52347L5.68014 7.09436C4.62708 8.44904 4 10.1513 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C10.8915 4 9.83557 4.22547 8.8757 4.63306L7.37443 3.13179C8.75768 2.40883 10.3311 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 8.87842 3.43029 6.09091 5.67127 4.25705Z" fill="currentColor"/>`,
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
|
||||
interface DiffIconProps extends Omit<SVGProps<SVGSVGElement>, 'children'> {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function DiffViewIcon({ className = 'h-5 w-5' }: { className?: string }) {
|
||||
return (
|
||||
<span aria-hidden="true" className={`relative inline-block overflow-hidden rounded-[2px] ${className}`}>
|
||||
<span className="absolute left-[20%] top-[20%] h-[60%] w-[25%] bg-[var(--status-error)]/25" />
|
||||
<span className="absolute right-[20%] top-[20%] h-[60%] w-[25%] bg-[var(--status-success)]/25" />
|
||||
<Icon name="layout-column" className="absolute inset-0 h-full w-full" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Git merge/branch icon for the Diff tab.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { DiffViewIcon } from '@/components/icons/DiffIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import { DiffView } from '@/components/views/DiffView';
|
||||
@@ -8,6 +9,7 @@ import { FilesView } from '@/components/views/FilesView';
|
||||
import { GitView } from '@/components/views/GitView';
|
||||
import { PullRequestView } from '@/components/views/PullRequestView';
|
||||
import { TerminalView } from '@/components/views/TerminalView';
|
||||
import { WalkthroughView } from '@/components/views/walkthrough/WalkthroughView';
|
||||
import { PlanView } from '@/components/views/PlanView';
|
||||
import { ProjectContextPanel } from './RightSidebarTabs';
|
||||
import { SidebarFilesTree } from './SidebarFilesTree';
|
||||
@@ -158,6 +160,7 @@ const getModeLabel = (
|
||||
if (mode === 'chat') return t('contextPanel.mode.chat');
|
||||
if (mode === 'file') return t('contextPanel.mode.files');
|
||||
if (mode === 'diff') return t('contextPanel.mode.diff');
|
||||
if (mode === 'walkthrough') return t('contextPanel.mode.walkthrough');
|
||||
if (mode === 'plan') return t('contextPanel.mode.plan');
|
||||
if (mode === 'preview') return t('contextPanel.mode.preview');
|
||||
if (mode === 'browser') return t('contextPanel.mode.browser');
|
||||
@@ -244,7 +247,11 @@ const getTabIcon = (tab: { mode: ContextPanelMode; targetPath: string | null }):
|
||||
}
|
||||
|
||||
if (tab.mode === 'diff') {
|
||||
return <Icon name="arrow-left-right" className="h-3.5 w-3.5" />;
|
||||
return <DiffViewIcon className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
if (tab.mode === 'walkthrough') {
|
||||
return <Icon name="route" className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
if (tab.mode === 'git') {
|
||||
@@ -252,7 +259,7 @@ const getTabIcon = (tab: { mode: ContextPanelMode; targetPath: string | null }):
|
||||
}
|
||||
|
||||
if (tab.mode === 'pr') {
|
||||
return <Icon name="git-pull-request" className="h-3.5 w-3.5" />;
|
||||
return <Icon name="github" className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
if (tab.mode === 'notes') {
|
||||
@@ -2717,6 +2724,12 @@ export const ContextPanel: React.FC = () => {
|
||||
() => tabs.some((tab) => tab.mode === 'terminal'),
|
||||
[tabs],
|
||||
);
|
||||
// Keep-alive: the walkthrough holds reading progress and scroll position that
|
||||
// a remount would silently throw away.
|
||||
const hasWalkthroughTab = React.useMemo(
|
||||
() => tabs.some((tab) => tab.mode === 'walkthrough'),
|
||||
[tabs],
|
||||
);
|
||||
const BrowserPane = isElectronBrowserRuntime() ? DesktopBrowserPane : IframeBrowserPane;
|
||||
const hasFileTabs = React.useMemo(
|
||||
() => tabs.some((tab) => tab.mode === 'file'),
|
||||
@@ -2979,7 +2992,12 @@ export const ContextPanel: React.FC = () => {
|
||||
<TerminalView visible={isOpen && activeTab?.mode === 'terminal'} />
|
||||
</div>
|
||||
) : null}
|
||||
{activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' && activeTab?.mode !== 'terminal' ? activeNonChatContent : null}
|
||||
{hasWalkthroughTab ? (
|
||||
<div className={cn('absolute inset-0', activeTab?.mode === 'walkthrough' ? 'block' : 'hidden')}>
|
||||
<WalkthroughView directory={effectiveDirectory} />
|
||||
</div>
|
||||
) : null}
|
||||
{activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' && activeTab?.mode !== 'terminal' && activeTab?.mode !== 'walkthrough' ? activeNonChatContent : null}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -17,8 +17,11 @@ import {
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { DiffViewIcon } from '@/components/icons/DiffIcon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
sortContextSurfaces,
|
||||
@@ -30,6 +33,9 @@ import { useGitStatus } from '@/stores/useGitStore';
|
||||
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
const RAIL_TOOLTIP_DELAY_MS = 150;
|
||||
// Tablet width and up: below this the walkthrough cannot show a stop and its
|
||||
// code side by side, which is the whole point of the surface.
|
||||
const WALKTHROUGH_MIN_WIDTH = 768;
|
||||
const EMPTY_TABS: never[] = [];
|
||||
|
||||
type RailItemProps = {
|
||||
@@ -75,7 +81,11 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon name={surface.icon} className="h-[18px] w-[18px]" />
|
||||
{surface.id === 'diff' ? (
|
||||
<DiffViewIcon />
|
||||
) : (
|
||||
<Icon name={surface.icon} className="h-[18px] w-[18px]" />
|
||||
)}
|
||||
{showActivityDot ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
@@ -105,6 +115,7 @@ export const ContextPanelRail: React.FC = () => {
|
||||
const setContextRailOrder = useUIStore((state) => state.setContextRailOrder);
|
||||
const openContextSurface = useUIStore((state) => state.openContextSurface);
|
||||
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
||||
const { screenWidth } = useDeviceInfo();
|
||||
const gitStatus = useGitStatus(directoryKey || null);
|
||||
|
||||
const sensors = useSensors(
|
||||
@@ -124,12 +135,17 @@ export const ContextPanelRail: React.FC = () => {
|
||||
if (surface.id === 'plan' && !planModeEnabled) {
|
||||
return false;
|
||||
}
|
||||
// The walkthrough needs room for a stop list beside real code, and its
|
||||
// diffs come from OpenChamber's Git routes, which VS Code does not serve.
|
||||
if (surface.id === 'walkthrough' && (isVSCodeRuntime() || screenWidth < WALKTHROUGH_MIN_WIDTH)) {
|
||||
return false;
|
||||
}
|
||||
if (surface.availability === 'has-content') {
|
||||
return tabs.some((tab) => tab.mode === surface.mode);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [contextRailOrder, planModeEnabled, tabs]);
|
||||
}, [contextRailOrder, planModeEnabled, screenWidth, tabs]);
|
||||
|
||||
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
@@ -339,6 +339,11 @@ interface ModelPickerListProps {
|
||||
selectedModel?: { providerID: string; modelID: string } | null;
|
||||
hiddenModels?: HiddenModel[];
|
||||
allowedProviderIds?: string[];
|
||||
/**
|
||||
* Per-model gate, for callers whose feature needs a capability rather than a
|
||||
* provider (e.g. structured output). Applied on top of `allowedProviderIds`.
|
||||
*/
|
||||
isModelAllowed?: (providerID: string, modelID: string) => boolean;
|
||||
includeNotSelected?: boolean;
|
||||
onSelectNone?: () => void;
|
||||
selectionCount?: (entry: ModelPickerEntry) => number;
|
||||
@@ -379,6 +384,7 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
selectedModel,
|
||||
hiddenModels = [],
|
||||
allowedProviderIds,
|
||||
isModelAllowed,
|
||||
includeNotSelected = false,
|
||||
onSelectNone,
|
||||
selectionCount,
|
||||
@@ -448,17 +454,19 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
|
||||
const filteredFavorites = React.useMemo(() => favoriteModels.filter(({ model, providerID, modelID }) => {
|
||||
if (allowedProviderSet && !allowedProviderSet.has(providerID)) return false;
|
||||
if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false;
|
||||
if (isHidden(providerID, modelID)) return false;
|
||||
const providerName = providerById.get(providerID)?.name || providerID;
|
||||
return matchesQuery(getModelDisplayName(model), providerName);
|
||||
}), [allowedProviderSet, favoriteModels, isHidden, matchesQuery, providerById]);
|
||||
}), [allowedProviderSet, favoriteModels, isHidden, isModelAllowed, matchesQuery, providerById]);
|
||||
|
||||
const filteredRecents = React.useMemo(() => recentModels.filter(({ model, providerID, modelID }) => {
|
||||
if (allowedProviderSet && !allowedProviderSet.has(providerID)) return false;
|
||||
if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false;
|
||||
if (isHidden(providerID, modelID)) return false;
|
||||
const providerName = providerById.get(providerID)?.name || providerID;
|
||||
return matchesQuery(getModelDisplayName(model), providerName);
|
||||
}), [allowedProviderSet, isHidden, matchesQuery, providerById, recentModels]);
|
||||
}), [allowedProviderSet, isHidden, isModelAllowed, matchesQuery, providerById, recentModels]);
|
||||
|
||||
const orderedProviders = React.useMemo(() => {
|
||||
if (!providerOrder || providerOrder.length === 0) return providers;
|
||||
@@ -477,11 +485,12 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
const filteredModels = models.filter((model) => {
|
||||
const modelID = typeof model.id === 'string' ? model.id : '';
|
||||
if (!modelID || isHidden(provider.id, modelID)) return false;
|
||||
if (isModelAllowed && !isModelAllowed(provider.id, modelID)) return false;
|
||||
return matchesQuery(getModelDisplayName(model), provider.name || provider.id);
|
||||
});
|
||||
return { ...provider, models: filteredModels };
|
||||
})
|
||||
.filter((provider) => provider.models.length > 0), [allowedProviderSet, isHidden, matchesQuery, orderedProviders]);
|
||||
.filter((provider) => provider.models.length > 0), [allowedProviderSet, isHidden, isModelAllowed, matchesQuery, orderedProviders]);
|
||||
|
||||
const flatModelList = React.useMemo(() => {
|
||||
const items: ModelPickerEntry[] = [];
|
||||
|
||||
@@ -23,9 +23,17 @@ interface ModelSelectorProps {
|
||||
onChange: (providerId: string, modelId: string) => void;
|
||||
className?: string;
|
||||
allowedProviderIds?: string[];
|
||||
isModelAllowed?: (providerId: string, modelId: string) => boolean;
|
||||
placeholder?: string;
|
||||
tooltipsEnabled?: boolean;
|
||||
dropdownPortalToBody?: boolean;
|
||||
/**
|
||||
* Drop the model name and the chevron, leaving the provider logo. For
|
||||
* headers that run out of room before they run out of controls — the logo
|
||||
* still says which provider is answering, which is the part a glance is
|
||||
* usually after.
|
||||
*/
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
@@ -34,9 +42,11 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
onChange,
|
||||
className,
|
||||
allowedProviderIds,
|
||||
isModelAllowed,
|
||||
placeholder,
|
||||
tooltipsEnabled = true,
|
||||
dropdownPortalToBody = false,
|
||||
compact = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { isReady, isUnavailable } = useOpenCodeReadiness();
|
||||
@@ -115,6 +125,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
selectedModel={selectedModel}
|
||||
hiddenModels={hiddenModels}
|
||||
allowedProviderIds={allowedProviderIds}
|
||||
isModelAllowed={isModelAllowed}
|
||||
includeNotSelected
|
||||
onSelectNone={handleSelectNone}
|
||||
onEscape={closePicker}
|
||||
@@ -166,26 +177,35 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
return (
|
||||
<DropdownMenu open={isReady && isDropdownOpen} onOpenChange={isReady ? setIsDropdownOpen : undefined}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className={cn(
|
||||
dropdownTriggerVariants({ size: 'sm' }),
|
||||
'min-w-0 w-fit',
|
||||
!isReady && 'opacity-60 cursor-not-allowed',
|
||||
className,
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
dropdownTriggerVariants({ size: 'sm' }),
|
||||
'min-w-0 w-fit',
|
||||
!isReady && 'opacity-60 cursor-not-allowed',
|
||||
className,
|
||||
)}
|
||||
// The name is gone from the trigger, so it has to stay
|
||||
// reachable somewhere.
|
||||
title={compact && isReady ? triggerLabel : undefined}
|
||||
>
|
||||
{!isReady ? (
|
||||
<>
|
||||
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin text-muted-foreground flex-shrink-0" />
|
||||
<span className="typography-ui-label font-normal whitespace-nowrap text-muted-foreground">
|
||||
{isUnavailable ? t('common.unavailable') : t('common.loading')}
|
||||
</span>
|
||||
{!compact && (
|
||||
<span className="typography-ui-label font-normal whitespace-nowrap text-muted-foreground">
|
||||
{isUnavailable ? t('common.unavailable') : t('common.loading')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{providerId ? <ProviderLogo providerId={providerId} className="h-3.5 w-3.5 flex-shrink-0" /> : <Icon name="pencil-ai" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />}
|
||||
<span className="typography-ui-label min-w-0 flex-1 truncate text-left font-normal text-foreground">{triggerLabel}</span>
|
||||
{!compact && (
|
||||
<span className="typography-ui-label min-w-0 flex-1 truncate text-left font-normal text-foreground">{triggerLabel}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Icon name="arrow-down-s" className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
|
||||
{!compact && <Icon name="arrow-down-s" className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col" align="start" portalToBody={dropdownPortalToBody}>
|
||||
|
||||
@@ -45,6 +45,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
||||
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const modelsMetadata = useConfigStore((state) => state.modelsMetadata);
|
||||
|
||||
const [defaultModel, setDefaultModel] = React.useState<string | undefined>();
|
||||
const [defaultVariant, setDefaultVariant] = React.useState<string | undefined>();
|
||||
@@ -52,6 +53,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
const [smallModelUseDefault, setSmallModelUseDefault] = React.useState(true);
|
||||
const [smallModelOverride, setSmallModelOverride] = React.useState<string | undefined>();
|
||||
const [smallModelProviders, setSmallModelProviders] = React.useState<string[] | undefined>();
|
||||
const [walkthroughModelOverride, setWalkthroughModelOverride] = React.useState<string | undefined>();
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
|
||||
const parsedModel = React.useMemo(() => getDisplayModel(defaultModel), [defaultModel]);
|
||||
@@ -65,6 +67,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
defaultAgent?: string;
|
||||
smallModelUseDefault?: boolean;
|
||||
smallModelOverride?: string;
|
||||
walkthroughModelOverride?: string;
|
||||
} | null = null;
|
||||
|
||||
if (!data) {
|
||||
@@ -84,6 +87,8 @@ export const DefaultsSettings: React.FC = () => {
|
||||
defaultAgent: typeof settings.defaultAgent === 'string' ? settings.defaultAgent : undefined,
|
||||
smallModelUseDefault: typeof raw.smallModelUseDefault === 'boolean' ? raw.smallModelUseDefault : undefined,
|
||||
smallModelOverride: typeof raw.smallModelOverride === 'string' ? raw.smallModelOverride : undefined,
|
||||
walkthroughModelOverride:
|
||||
typeof raw.walkthroughModelOverride === 'string' ? raw.walkthroughModelOverride : undefined,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -123,6 +128,9 @@ export const DefaultsSettings: React.FC = () => {
|
||||
if (typeof data.smallModelOverride === 'string' && data.smallModelOverride.trim()) {
|
||||
setSmallModelOverride(data.smallModelOverride.trim());
|
||||
}
|
||||
if (typeof data.walkthroughModelOverride === 'string' && data.walkthroughModelOverride.trim()) {
|
||||
setWalkthroughModelOverride(data.walkthroughModelOverride.trim());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load defaults settings:', error);
|
||||
@@ -236,10 +244,43 @@ export const DefaultsSettings: React.FC = () => {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleWalkthroughModelOverrideChange = React.useCallback(
|
||||
async (providerId: string, modelId: string) => {
|
||||
const newValue = providerId && modelId ? `${providerId}/${modelId}` : undefined;
|
||||
setWalkthroughModelOverride(newValue);
|
||||
try {
|
||||
// Clearing the picker is how the user goes back to the small model, so
|
||||
// an empty value is a real choice rather than a no-op.
|
||||
await updateDesktopSettings({ walkthroughModelOverride: newValue ?? '' });
|
||||
} catch (error) {
|
||||
console.warn('Failed to save walkthrough model override:', error);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// The walkthrough cannot work at all without schema-shaped output, so models
|
||||
// the catalog says cannot do it are hidden rather than offered and then
|
||||
// refused. A missing capability is not a "no": roughly half the catalog omits
|
||||
// the field, and those models usually work.
|
||||
const isStructuredOutputCapable = React.useCallback(
|
||||
(providerId: string, modelId: string) =>
|
||||
modelsMetadata.get(`${providerId}/${modelId}`)?.structured_output !== false,
|
||||
[modelsMetadata]
|
||||
);
|
||||
|
||||
const parsedSmallModel = React.useMemo(() => getDisplayModel(smallModelOverride), [smallModelOverride]);
|
||||
const parsedWalkthroughModel = React.useMemo(
|
||||
() => getDisplayModel(walkthroughModelOverride),
|
||||
[walkthroughModelOverride]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (smallModelUseDefault || smallModelProviders !== undefined) return;
|
||||
// Both pickers filter by the same authenticated-provider list, so either
|
||||
// one being open is reason enough to fetch it.
|
||||
// Both pickers filter by the same authenticated-provider list, and the
|
||||
// walkthrough picker is always visible, so this is always worth fetching.
|
||||
if (smallModelProviders !== undefined) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
@@ -256,7 +297,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [smallModelUseDefault, smallModelProviders]);
|
||||
}, [smallModelProviders]);
|
||||
|
||||
const availableVariants = React.useMemo(() => {
|
||||
if (!parsedModel.providerId || !parsedModel.modelId) return [];
|
||||
@@ -396,6 +437,32 @@ export const DefaultsSettings: React.FC = () => {
|
||||
/>
|
||||
</SettingsFieldRow>
|
||||
) : null}
|
||||
|
||||
<SettingsInset className={SETTINGS_OPTION_STACK_CLASS}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<SettingsGroupTitle>
|
||||
{t('settings.openchamber.defaults.walkthroughModel.title')}
|
||||
</SettingsGroupTitle>
|
||||
<SettingsInfoHint>
|
||||
{t('settings.openchamber.defaults.walkthroughModel.description')}
|
||||
</SettingsInfoHint>
|
||||
</div>
|
||||
|
||||
<SettingsFieldRow
|
||||
settingsItem="sessions.walkthrough-model"
|
||||
label={t('settings.openchamber.defaults.walkthroughModel.overrideModel')}
|
||||
>
|
||||
<ModelSelector
|
||||
providerId={parsedWalkthroughModel.providerId}
|
||||
modelId={parsedWalkthroughModel.modelId}
|
||||
onChange={handleWalkthroughModelOverrideChange}
|
||||
allowedProviderIds={smallModelProviders}
|
||||
isModelAllowed={isStructuredOutputCapable}
|
||||
placeholder={t('settings.openchamber.defaults.walkthroughModel.usesSmallModel')}
|
||||
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
|
||||
/>
|
||||
</SettingsFieldRow>
|
||||
</SettingsInset>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -711,7 +711,9 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title={t('directoryExplorerDialog.title')}
|
||||
className="h-[88dvh] max-h-[720px] max-w-full"
|
||||
// Height only — the width stays on MobileOverlayPanel's shared max-w-lg
|
||||
// so this sheet matches every other mobile overlay on wide screens.
|
||||
className="h-[88dvh] max-h-[720px]"
|
||||
contentMaxHeightClassName="flex-1"
|
||||
footer={<div className="flex flex-col gap-2">{renderFooter()}</div>}
|
||||
>
|
||||
|
||||
@@ -413,13 +413,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
const target = deleteDialog.sessions[0];
|
||||
const success = isWorktreeDelete
|
||||
? await archiveSession(target.id)
|
||||
: await deleteSession(target.id, {
|
||||
// In "worktree" mode, remove the selected worktree explicitly below.
|
||||
// Don't try to derive worktree removal from per-session metadata (may be missing).
|
||||
archiveWorktree: false,
|
||||
deleteRemoteBranch: removeRemoteBranch,
|
||||
deleteLocalBranch,
|
||||
});
|
||||
: await deleteSession(target.id);
|
||||
if (!success) {
|
||||
toast.error(isWorktreeDelete
|
||||
? t('sessions.sidebar.session.archive.error')
|
||||
|
||||
@@ -1204,7 +1204,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
handleSessionDoubleClick(session.id, sessionTitle);
|
||||
}}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none transition-[padding]',
|
||||
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none transition-[padding]',
|
||||
isTouchPressed && 'bg-interactive-hover/70',
|
||||
alwaysShowActions
|
||||
? (isVSCode ? revealPaddingClass : alwaysActionPaddingClass)
|
||||
|
||||
@@ -119,6 +119,15 @@ export const MobileOverlayPanel: React.FC<MobileOverlayPanelProps> = ({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={onClose}
|
||||
// The panel centers over the CHAT column, not the whole app: on a tablet
|
||||
// the shell keeps a persistent sessions sidebar, and a sheet centered on
|
||||
// the window reads as belonging to nothing. The shell publishes the
|
||||
// column's insets; on phones they are 0 and this is a no-op. The scrim
|
||||
// deliberately still covers everything.
|
||||
style={{
|
||||
paddingLeft: 'var(--oc-chat-inset-left, 0px)',
|
||||
paddingRight: 'var(--oc-chat-inset-right, 0px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -34,11 +34,14 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
|
||||
import { toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { findDiffScrollAnchor, getRestoredDiffScrollTop, type DiffScrollAnchor } from './diffScrollAnchor';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { I18nKey } from '@/lib/i18n/store';
|
||||
import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { startReviewFlow } from '@/lib/reviewFlow';
|
||||
import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction';
|
||||
import { useWalkthroughStore } from '@/stores/useWalkthroughStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionMessages } from '@/sync/sync-context';
|
||||
import { getFirstChangedModifiedLineFromPatch } from './diffPatchUtils';
|
||||
@@ -961,6 +964,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const { t } = useI18n();
|
||||
const { git, files } = useRuntimeAPIs();
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const openContextSurface = useUIStore((state) => state.openContextSurface);
|
||||
const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource);
|
||||
const { screenWidth, isMobile } = useDeviceInfo();
|
||||
|
||||
const isGitRepo = useIsGitRepo(effectiveDirectory ?? null);
|
||||
@@ -969,6 +974,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
|
||||
const ensureStatus = useGitStore((state) => state.ensureStatus);
|
||||
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
||||
const clearDiffCache = useGitStore((state) => state.clearDiffCache);
|
||||
const setDiff = useGitStore((state) => state.setDiff);
|
||||
const [displayFile, setDisplayFile] = React.useState<string | null>(null);
|
||||
const [displayFileStaged, setDisplayFileStaged] = React.useState(false);
|
||||
@@ -977,6 +983,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const [mountedStackedFiles, setMountedStackedFiles] = React.useState<Set<string>>(() => new Set());
|
||||
const [loadFullFiles, setLoadFullFiles] = React.useState(false);
|
||||
const [scrollRequestNonce, setScrollRequestNonce] = React.useState(0);
|
||||
const [fileDiffRefreshNonce, setFileDiffRefreshNonce] = React.useState<Map<string, number>>(() => new Map());
|
||||
const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false);
|
||||
const [reviewFlowSubmitting, setReviewFlowSubmitting] = React.useState(false);
|
||||
const [activeDiffScope, setActiveDiffScope] = React.useState(diffScope);
|
||||
@@ -1003,6 +1010,9 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
|
||||
const isMobileLayout = isMobile || screenWidth <= 768;
|
||||
const showReviewAction = Boolean(currentSessionId) && activeDiffScope !== 'turn' && !isMobileLayout && !isVSCodeRuntime();
|
||||
// Same runtime and width rules as the rail surface: no point offering an
|
||||
// entry point to a surface that cannot open here.
|
||||
const showWalkthroughAction = activeDiffScope !== 'turn' && !isMobileLayout && !isVSCodeRuntime();
|
||||
const showFileSidebar = !hideStackedFileSidebar && !isMobileLayout && screenWidth >= 1024;
|
||||
const diffScrollRef = React.useRef<HTMLElement | null>(null);
|
||||
const fileSectionRefs = React.useRef(new Map<string, HTMLDivElement | null>());
|
||||
@@ -1011,6 +1021,20 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const shouldPinAfterAlignRef = React.useRef(false);
|
||||
const visibleSyncFrameRef = React.useRef<number | null>(null);
|
||||
const stackedStateScopeRef = React.useRef<string | null>(null);
|
||||
const lastScrollAnchorRef = React.useRef<DiffScrollAnchor | null>(null);
|
||||
const pendingScrollAnchorRestoreRef = React.useRef<DiffScrollAnchor | null>(null);
|
||||
|
||||
const captureScrollAnchor = React.useCallback((): DiffScrollAnchor | null => {
|
||||
const scrollRoot = diffScrollRef.current;
|
||||
if (!scrollRoot) return null;
|
||||
|
||||
const rootTop = scrollRoot.getBoundingClientRect().top;
|
||||
const sections: Array<{ path: string; top: number }> = [];
|
||||
for (const [path, node] of fileSectionRefs.current) {
|
||||
if (node) sections.push({ path, top: node.getBoundingClientRect().top });
|
||||
}
|
||||
return findDiffScrollAnchor(rootTop, sections);
|
||||
}, []);
|
||||
|
||||
const cancelPendingScrollAlignment = React.useCallback(() => {
|
||||
pendingScrollTargetRef.current = null;
|
||||
@@ -1164,13 +1188,17 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const top = rootRect.top - STACKED_DIFF_MOUNT_MARGIN;
|
||||
const bottom = rootRect.bottom + STACKED_DIFF_MOUNT_MARGIN;
|
||||
const next: Record<string, boolean> = {};
|
||||
const sectionPositions: Array<{ path: string; top: number }> = [];
|
||||
|
||||
for (const [path, node] of fileSectionRefs.current) {
|
||||
if (!node || !expandedFiles.has(path)) continue;
|
||||
if (!node) continue;
|
||||
const rect = node.getBoundingClientRect();
|
||||
sectionPositions.push({ path, top: rect.top });
|
||||
if (!expandedFiles.has(path)) continue;
|
||||
if (rect.bottom < top || rect.top > bottom) continue;
|
||||
next[path] = true;
|
||||
}
|
||||
lastScrollAnchorRef.current = findDiffScrollAnchor(rootRect.top, sectionPositions);
|
||||
|
||||
setMountedStackedFiles((previous) => {
|
||||
let changed = false;
|
||||
@@ -1252,9 +1280,40 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
if (normalizePath(hint.directory) !== normalizePath(effectiveDirectory)) {
|
||||
return;
|
||||
}
|
||||
void fetchStatus(effectiveDirectory, git);
|
||||
if (hint.paths?.length) {
|
||||
pendingScrollAnchorRestoreRef.current = captureScrollAnchor() ?? lastScrollAnchorRef.current;
|
||||
clearDiffCache(effectiveDirectory, hint.paths);
|
||||
setFileDiffRefreshNonce((previous) => {
|
||||
const next = new Map(previous);
|
||||
for (const path of hint.paths ?? []) {
|
||||
next.set(path, (next.get(path) ?? 0) + 1);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
void fetchStatus(effectiveDirectory, git, { silent: true });
|
||||
});
|
||||
}, [effectiveDirectory, fetchStatus, git]);
|
||||
}, [captureScrollAnchor, clearDiffCache, effectiveDirectory, fetchStatus, git]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const anchor = pendingScrollAnchorRestoreRef.current;
|
||||
if (!anchor) return;
|
||||
pendingScrollAnchorRestoreRef.current = null;
|
||||
|
||||
const scrollRoot = diffScrollRef.current;
|
||||
const node = fileSectionRefs.current.get(anchor.path);
|
||||
if (!scrollRoot || !node) return;
|
||||
|
||||
const rootTop = scrollRoot.getBoundingClientRect().top;
|
||||
const currentTopOffset = node.getBoundingClientRect().top - rootTop;
|
||||
scrollRoot.scrollTop = getRestoredDiffScrollTop(
|
||||
scrollRoot.scrollTop,
|
||||
anchor.topOffset,
|
||||
currentTopOffset,
|
||||
scrollRoot.scrollHeight - scrollRoot.clientHeight,
|
||||
);
|
||||
lastScrollAnchorRef.current = anchor;
|
||||
}, [fileDiffRefreshNonce]);
|
||||
|
||||
// Handle pending diff file from external navigation
|
||||
React.useEffect(() => {
|
||||
@@ -1392,11 +1451,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
const rootRect = scrollRoot.getBoundingClientRect();
|
||||
const nodeRect = node.getBoundingClientRect();
|
||||
const delta = nodeRect.top - rootRect.top;
|
||||
const maxTop = Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight);
|
||||
scrollRoot.scrollTop = Math.min(maxTop, Math.max(0, scrollRoot.scrollTop + delta));
|
||||
const scrollOffset = node.getBoundingClientRect().top - scrollRoot.getBoundingClientRect().top;
|
||||
scrollRoot.scrollTo({ top: scrollRoot.scrollTop + scrollOffset, behavior: 'auto' });
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
@@ -1408,14 +1464,16 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const maxAttempts = 20;
|
||||
let cancelled = false;
|
||||
|
||||
const cancelPending = () => {
|
||||
const cancelPending = (clearPinnedTarget = true) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
cancelled = true;
|
||||
pendingScrollTargetRef.current = null;
|
||||
shouldPinAfterAlignRef.current = false;
|
||||
setPinnedStackedTarget(null);
|
||||
if (clearPinnedTarget) {
|
||||
setPinnedStackedTarget(null);
|
||||
}
|
||||
if (pendingScrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(pendingScrollFrameRef.current);
|
||||
pendingScrollFrameRef.current = null;
|
||||
@@ -1448,6 +1506,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
|
||||
if (pinSelectedFileHeaderToTopOnNavigate && shouldPinAfterAlignRef.current) {
|
||||
setPinnedStackedTarget(currentTarget);
|
||||
cancelPending(false);
|
||||
return;
|
||||
}
|
||||
cancelPending();
|
||||
};
|
||||
@@ -1592,7 +1652,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
<div className="flex flex-col [overflow-anchor:none]" data-diff-virtual-content>
|
||||
{changedFiles.map((file) => (
|
||||
<MultiFileDiffEntry
|
||||
key={file.path}
|
||||
key={`${file.path}:${fileDiffRefreshNonce.get(file.path) ?? 0}`}
|
||||
directory={effectiveDirectory}
|
||||
file={file}
|
||||
layout={getLayoutForFile(file)}
|
||||
@@ -1724,6 +1784,32 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{changedFiles.length > 0 && showWalkthroughAction && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Carry the scope across: opening the walkthrough
|
||||
// while looking at staged changes should review
|
||||
// staged changes, not whatever the panel showed last.
|
||||
const directory = effectiveDirectory ?? '';
|
||||
requestWalkthroughSource(directory, {
|
||||
kind: 'working-tree',
|
||||
scope: activeDiffScope === 'staged' || activeDiffScope === 'working'
|
||||
? activeDiffScope
|
||||
: 'all',
|
||||
});
|
||||
openContextSurface(directory, 'walkthrough');
|
||||
}}
|
||||
className={cn('diff-toolbar__walkthrough-button h-7 flex-shrink-0 gap-1.5 px-2', WALKTHROUGH_ACTION_CLASS)}
|
||||
aria-label={t('walkthrough.action.open')}
|
||||
>
|
||||
<Icon name="route" className="size-4" />
|
||||
<span className="diff-toolbar__walkthrough-label typography-ui-label">
|
||||
{t('walkthrough.action.open')}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{changedFiles.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -74,6 +74,7 @@ import { openDesktopFileInApp, openDesktopPath } from '@/lib/desktop';
|
||||
import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore';
|
||||
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
|
||||
type FileNode = {
|
||||
name: string;
|
||||
@@ -1655,6 +1656,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
return false;
|
||||
}
|
||||
setFileContent(draftContent);
|
||||
if (root && isPathWithinRoot(selectedFile.path, root)) {
|
||||
const relativePath = getDisplayPath(root, selectedFile.path);
|
||||
if (relativePath) {
|
||||
sessionEvents.requestGitRefresh({ directory: root, paths: [relativePath] });
|
||||
}
|
||||
}
|
||||
if (selectedFile?.path && isDrawioFile(selectedFile.path)) {
|
||||
diagramXmlRef.current = draftContent;
|
||||
diagramSavedXmlRef.current = draftContent;
|
||||
@@ -1674,7 +1681,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [contentDetectedBinary, draftContent, fileContent, fileLoading, files, isDirty, loadedFileLineEnding, loadedFilePath, readFileStat, selectedFile, t]);
|
||||
}, [contentDetectedBinary, draftContent, fileContent, fileLoading, files, isDirty, loadedFileLineEnding, loadedFilePath, readFileStat, root, selectedFile, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDirty) {
|
||||
|
||||
@@ -280,6 +280,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
||||
setLogMaxCount,
|
||||
fetchIdentity,
|
||||
prefetchDiffs,
|
||||
clearDiffCache,
|
||||
moveStatusPathsOptimistically,
|
||||
restoreStatus,
|
||||
bumpIndexRevision,
|
||||
@@ -293,6 +294,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
||||
setLogMaxCount: state.setLogMaxCount,
|
||||
fetchIdentity: state.fetchIdentity,
|
||||
prefetchDiffs: state.prefetchDiffs,
|
||||
clearDiffCache: state.clearDiffCache,
|
||||
moveStatusPathsOptimistically: state.moveStatusPathsOptimistically,
|
||||
restoreStatus: state.restoreStatus,
|
||||
bumpIndexRevision: state.bumpIndexRevision,
|
||||
@@ -861,9 +863,12 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
||||
if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) {
|
||||
return;
|
||||
}
|
||||
void fetchStatus(currentDirectory, git);
|
||||
if (hint.paths?.length) {
|
||||
clearDiffCache(currentDirectory, hint.paths);
|
||||
}
|
||||
void fetchStatus(currentDirectory, git, { silent: true });
|
||||
});
|
||||
}, [isActive, currentDirectory, fetchStatus, git]);
|
||||
}, [isActive, clearDiffCache, currentDirectory, fetchStatus, git]);
|
||||
|
||||
const refreshStatusAndBranches = React.useCallback(
|
||||
async (showErrors = true) => {
|
||||
@@ -2019,14 +2024,14 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
||||
}
|
||||
}, [currentDirectory, git, conflictOperation, refreshStatusAndBranches, refreshLog, clearConflictState, t]);
|
||||
|
||||
// Check if there are unresolved conflicts (files with 'U' status)
|
||||
const hasUnresolvedConflicts = React.useMemo(() => {
|
||||
if (!status?.files) return false;
|
||||
return status.files.some((f) =>
|
||||
// Count unresolved conflicts (files with 'U' status)
|
||||
const conflictCount = React.useMemo(() => {
|
||||
if (!status?.files) return 0;
|
||||
return status.files.filter((f) =>
|
||||
(f.index === 'U' || f.working_dir === 'U') ||
|
||||
(f.index === 'A' && f.working_dir === 'A') ||
|
||||
(f.index === 'D' && f.working_dir === 'D')
|
||||
);
|
||||
).length;
|
||||
}, [status?.files]);
|
||||
|
||||
const handleContinueOperation = React.useCallback(async () => {
|
||||
@@ -2350,7 +2355,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
||||
onContinue={handleContinueOperation}
|
||||
onAbort={handleAbortOperation}
|
||||
onResolveWithAI={handleResolveWithAIFromBanner}
|
||||
hasUnresolvedConflicts={hasUnresolvedConflicts}
|
||||
conflictCount={conflictCount}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { findDiffScrollAnchor, getRestoredDiffScrollTop } from './diffScrollAnchor';
|
||||
|
||||
describe('diff scroll anchoring', () => {
|
||||
test('uses the last file section that reached the top of the viewport', () => {
|
||||
expect(findDiffScrollAnchor(100, [
|
||||
{ path: 'third.ts', top: 240 },
|
||||
{ path: 'second.ts', top: 80 },
|
||||
{ path: 'first.ts', top: -200 },
|
||||
])).toEqual({ path: 'second.ts', topOffset: -20 });
|
||||
});
|
||||
|
||||
test('uses the first section when no header reached the viewport top yet', () => {
|
||||
expect(findDiffScrollAnchor(100, [
|
||||
{ path: 'first.ts', top: 140 },
|
||||
{ path: 'second.ts', top: 300 },
|
||||
])).toEqual({ path: 'first.ts', topOffset: 40 });
|
||||
});
|
||||
|
||||
test('restores the prior section offset and clamps at the scroll boundary', () => {
|
||||
expect(getRestoredDiffScrollTop(500, -20, 80, 1000)).toBe(600);
|
||||
expect(getRestoredDiffScrollTop(950, -20, 80, 1000)).toBe(1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
export type DiffScrollAnchor = {
|
||||
path: string;
|
||||
topOffset: number;
|
||||
};
|
||||
|
||||
export const findDiffScrollAnchor = (
|
||||
rootTop: number,
|
||||
sections: Array<{ path: string; top: number }>,
|
||||
): DiffScrollAnchor | null => {
|
||||
if (sections.length === 0) return null;
|
||||
|
||||
let beforeTop: { path: string; top: number } | null = null;
|
||||
let afterTop: { path: string; top: number } | null = null;
|
||||
for (const section of sections) {
|
||||
if (section.top <= rootTop) {
|
||||
if (!beforeTop || section.top > beforeTop.top) beforeTop = section;
|
||||
} else if (!afterTop || section.top < afterTop.top) {
|
||||
afterTop = section;
|
||||
}
|
||||
}
|
||||
|
||||
const anchor = beforeTop ?? afterTop;
|
||||
if (!anchor) return null;
|
||||
return { path: anchor.path, topOffset: anchor.top - rootTop };
|
||||
};
|
||||
|
||||
export const getRestoredDiffScrollTop = (
|
||||
scrollTop: number,
|
||||
previousTopOffset: number,
|
||||
currentTopOffset: number,
|
||||
maxScrollTop: number,
|
||||
): number => Math.min(
|
||||
Math.max(0, maxScrollTop),
|
||||
Math.max(0, scrollTop + currentTopOffset - previousTopOffset),
|
||||
);
|
||||
@@ -10,7 +10,7 @@ interface InProgressOperationBannerProps {
|
||||
onContinue: () => Promise<void>;
|
||||
onAbort: () => Promise<void>;
|
||||
onResolveWithAI?: () => void;
|
||||
hasUnresolvedConflicts?: boolean;
|
||||
conflictCount?: number;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export const InProgressOperationBanner: React.FC<InProgressOperationBannerProps>
|
||||
onContinue,
|
||||
onAbort,
|
||||
onResolveWithAI,
|
||||
hasUnresolvedConflicts = false,
|
||||
conflictCount = 0,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -30,7 +30,7 @@ export const InProgressOperationBanner: React.FC<InProgressOperationBannerProps>
|
||||
const hasMergeInProgress = mergeInProgress && mergeInProgress.head;
|
||||
const hasRebaseInProgress = rebaseInProgress && (rebaseInProgress.headName || rebaseInProgress.onto);
|
||||
const operation = hasMergeInProgress ? 'merge' : hasRebaseInProgress ? 'rebase' : null;
|
||||
|
||||
|
||||
if (!operation) {
|
||||
return null;
|
||||
}
|
||||
@@ -54,54 +54,52 @@ export const InProgressOperationBanner: React.FC<InProgressOperationBannerProps>
|
||||
};
|
||||
|
||||
const isProcessing = processingAction !== null;
|
||||
const hasUnresolvedConflicts = conflictCount > 0;
|
||||
|
||||
const operationLabel = operation === 'merge' ? t('gitView.operation.merge') : t('gitView.operation.rebase');
|
||||
const operationIconName = operation === 'merge' ? 'git-merge' : 'git-branch';
|
||||
|
||||
// Build description
|
||||
let description = '';
|
||||
if (mergeInProgress) {
|
||||
description = mergeInProgress.message
|
||||
description = mergeInProgress.message
|
||||
? t('gitView.operation.mergingMessage', { message: mergeInProgress.message })
|
||||
: t('gitView.operation.mergeInProgressWithHead', { head: mergeInProgress.head });
|
||||
} else if (rebaseInProgress) {
|
||||
description = rebaseInProgress.headName
|
||||
description = rebaseInProgress.headName
|
||||
? t('gitView.operation.rebasingOnto', { headName: rebaseInProgress.headName, onto: rebaseInProgress.onto || '' })
|
||||
: t('gitView.operation.rebaseInProgress');
|
||||
}
|
||||
|
||||
const title = !hasUnresolvedConflicts
|
||||
? t('gitView.operation.inProgressTitle', { operation: operationLabel })
|
||||
: conflictCount === 1
|
||||
? t('gitView.operation.inProgressTitleOneConflict', { operation: operationLabel, count: conflictCount })
|
||||
: t('gitView.operation.inProgressTitleManyConflicts', { operation: operationLabel, count: conflictCount });
|
||||
|
||||
const hint = hasUnresolvedConflicts
|
||||
? t('gitView.operation.resolveConflictsHint')
|
||||
: t('gitView.operation.readyToContinueHint');
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--status-warning-bg)] border border-[var(--status-warning)] rounded-lg p-3 mx-3 mt-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<Icon name={operationIconName} className="size-4 text-[var(--status-warning)] shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="typography-label text-[var(--status-warning)]">
|
||||
{t('gitView.operation.inProgressTitle', { operation: operationLabel })}
|
||||
<div className="mx-4 mt-3 overflow-hidden rounded-lg border border-[var(--status-warning-border)]">
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="min-w-0">
|
||||
<p className="typography-label text-[var(--status-warning)]">
|
||||
{title}
|
||||
</p>
|
||||
{description && (
|
||||
<p className="typography-micro break-words text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
{description && (
|
||||
<p className="typography-micro text-muted-foreground truncate">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{hasUnresolvedConflicts && onResolveWithAI && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onResolveWithAI}
|
||||
disabled={isProcessing || isLoading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Icon name="sparkling" className="size-4" />
|
||||
{t('gitView.operation.resolveWithAi')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="typography-micro min-w-0 flex-1 text-muted-foreground">
|
||||
{hint}
|
||||
</p>
|
||||
|
||||
{processingAction !== 'continue' && (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -116,31 +114,36 @@ export const InProgressOperationBanner: React.FC<InProgressOperationBannerProps>
|
||||
)}
|
||||
{t('gitView.operation.abort')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!hasUnresolvedConflicts && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleContinue}
|
||||
disabled={isProcessing || isLoading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{processingAction === 'continue' ? (
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icon name="check" className="size-4" />
|
||||
{hasUnresolvedConflicts
|
||||
? onResolveWithAI && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onResolveWithAI}
|
||||
disabled={isProcessing || isLoading}
|
||||
>
|
||||
{t('gitView.operation.resolveWithAi')}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleContinue}
|
||||
disabled={isProcessing || isLoading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{processingAction === 'continue' ? (
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icon name="check" className="size-4" />
|
||||
)}
|
||||
{t('gitView.operation.continue')}
|
||||
</Button>
|
||||
)}
|
||||
{t('gitView.operation.continue')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasUnresolvedConflicts && (
|
||||
<p className="typography-micro text-[var(--status-warning)]">
|
||||
{t('gitView.operation.resolveConflictsHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,9 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useWalkthroughStore } from '@/stores/useWalkthroughStore';
|
||||
import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { formatDateTimeForPreference } from '@/lib/timeFormat';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useInlineCommentDraftStore, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
|
||||
@@ -327,7 +330,12 @@ export const PullRequestSection: React.FC<{
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const { isMobile, hasTouchInput } = useDeviceInfo();
|
||||
const { isMobile, hasTouchInput, screenWidth } = useDeviceInfo();
|
||||
const openContextSurface = useUIStore((state) => state.openContextSurface);
|
||||
const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource);
|
||||
// Mirrors the rail's gating: the surface is not available on mobile widths or
|
||||
// in VS Code, so neither is its entry point.
|
||||
const showWalkthroughAction = !isMobile && screenWidth >= 768 && !isVSCodeRuntime();
|
||||
|
||||
const openGitHubSettings = React.useCallback(() => {
|
||||
setSettingsPage('github');
|
||||
@@ -1487,7 +1495,7 @@ export const PullRequestSection: React.FC<{
|
||||
</div>
|
||||
|
||||
{pr ? (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="@container/pr-actions flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 typography-micro text-muted-foreground">
|
||||
<span style={{ color: prColorVar }}>{prStatusText}</span>
|
||||
{checks ? (
|
||||
@@ -1503,6 +1511,23 @@ export const PullRequestSection: React.FC<{
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{showWalkthroughAction ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('pr-actions__walkthrough-button h-7 shrink-0 gap-1.5 px-2', WALKTHROUGH_ACTION_CLASS)}
|
||||
onClick={() => {
|
||||
requestWalkthroughSource(directory, { kind: 'pr', number: pr.number });
|
||||
openContextSurface(directory, 'walkthrough');
|
||||
}}
|
||||
aria-label={t('walkthrough.action.open')}
|
||||
>
|
||||
<Icon name="route" className="size-4" />
|
||||
<span className="pr-actions__walkthrough-label typography-ui-label">
|
||||
{t('walkthrough.action.open')}
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{canMerge && pr.draft && pr.state === 'open' ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import type { WalkthroughBlockedReason, WalkthroughModel } from '@/lib/walkthrough/types';
|
||||
|
||||
interface WalkthroughBlockerProps {
|
||||
reason: WalkthroughBlockedReason;
|
||||
model?: WalkthroughModel;
|
||||
requiredChars?: number;
|
||||
availableChars?: number;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
const modelLabel = (model?: WalkthroughModel) =>
|
||||
model ? `${model.providerID}/${model.modelID}` : '';
|
||||
|
||||
/**
|
||||
* A refusal the user can act on. Both blocking reasons come down to "this small
|
||||
* model cannot do this job", so the remedy — pick a different one — is offered
|
||||
* in place rather than sending the user to Settings to guess.
|
||||
*/
|
||||
export const WalkthroughBlocker = ({
|
||||
reason,
|
||||
model,
|
||||
requiredChars,
|
||||
availableChars,
|
||||
onRetry,
|
||||
}: WalkthroughBlockerProps) => {
|
||||
const { t } = useI18n();
|
||||
const modelsMetadata = useConfigStore((state) => state.modelsMetadata);
|
||||
const [providers, setProviders] = useState<string[] | undefined>(undefined);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Every one of these means "this small model cannot do this job", so the
|
||||
// remedy is the same: choose a different one, here, without a detour through
|
||||
// Settings.
|
||||
const canChooseModel = reason === 'context-too-small'
|
||||
|| reason === 'structured-output-unsupported'
|
||||
|| reason === 'output-exhausted';
|
||||
|
||||
useEffect(() => {
|
||||
if (!canChooseModel || providers !== undefined) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/small-model', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { authenticatedProviders?: unknown }
|
||||
| null;
|
||||
if (!cancelled && Array.isArray(payload?.authenticatedProviders)) {
|
||||
setProviders(payload.authenticatedProviders.filter((id): id is string => typeof id === 'string'));
|
||||
}
|
||||
} catch {
|
||||
// Leave undefined: the picker then offers every provider, which is a
|
||||
// worse experience but not a broken one.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canChooseModel, providers]);
|
||||
|
||||
const handleModelChange = useCallback(
|
||||
async (providerId: string, modelId: string) => {
|
||||
if (!providerId || !modelId || saving) return;
|
||||
const value = `${providerId}/${modelId}`;
|
||||
setSaving(true);
|
||||
try {
|
||||
// Scoped to this feature: fixing the walkthrough must not quietly
|
||||
// change the model used for commit messages and recaps.
|
||||
await updateDesktopSettings({ walkthroughModelOverride: value });
|
||||
onRetry();
|
||||
} catch (error) {
|
||||
console.warn('Failed to save small model override:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[onRetry, saving]
|
||||
);
|
||||
|
||||
// Offering a model the catalog already says cannot do this would just move
|
||||
// the same refusal one click later.
|
||||
const isStructuredOutputCapable = useCallback(
|
||||
(providerId: string, modelId: string) =>
|
||||
modelsMetadata.get(`${providerId}/${modelId}`)?.structured_output !== false,
|
||||
[modelsMetadata]
|
||||
);
|
||||
|
||||
const label = modelLabel(model);
|
||||
|
||||
const description = () => {
|
||||
if (reason === 'no-model') return t('walkthrough.blocked.noModel.description');
|
||||
if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.description');
|
||||
if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.description');
|
||||
if (reason === 'output-exhausted') {
|
||||
return label
|
||||
? t('walkthrough.blocked.outputExhausted.description', { model: label })
|
||||
: t('walkthrough.blocked.outputExhausted.descriptionUnknownModel');
|
||||
}
|
||||
if (reason === 'structured-output-unsupported') {
|
||||
// Naming the model that was actually tried is the whole point of this
|
||||
// screen; the unnamed variant is a defensive fallback, not the norm.
|
||||
return label
|
||||
? t('walkthrough.blocked.structuredOutput.description', { model: label })
|
||||
: t('walkthrough.blocked.structuredOutput.descriptionUnknownModel');
|
||||
}
|
||||
const required = Math.ceil((requiredChars ?? 0) / 1000);
|
||||
const available = Math.ceil((availableChars ?? 0) / 1000);
|
||||
return label
|
||||
? t('walkthrough.blocked.contextTooSmall.description', { model: label, required, available })
|
||||
: t('walkthrough.blocked.contextTooSmall.descriptionUnknownModel', { required, available });
|
||||
};
|
||||
|
||||
const title = () => {
|
||||
if (reason === 'no-model') return t('walkthrough.blocked.noModel.title');
|
||||
if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.title');
|
||||
if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.title');
|
||||
if (reason === 'output-exhausted') return t('walkthrough.blocked.outputExhausted.title');
|
||||
if (reason === 'structured-output-unsupported') return t('walkthrough.blocked.structuredOutput.title');
|
||||
return t('walkthrough.blocked.contextTooSmall.title');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<Icon
|
||||
name={reason === 'empty-diff' || reason === 'only-generated' ? 'information' : 'error-warning'}
|
||||
className="size-6 text-muted-foreground"
|
||||
/>
|
||||
<h3 className="typography-ui-label font-semibold text-foreground">{title()}</h3>
|
||||
<p className="typography-meta max-w-md text-muted-foreground">{description()}</p>
|
||||
|
||||
{canChooseModel && (
|
||||
<div className="flex flex-col items-center gap-2 pt-2">
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{t('walkthrough.blocked.chooseModel')}
|
||||
</span>
|
||||
<ModelSelector
|
||||
providerId={model?.providerID ?? ''}
|
||||
modelId={model?.modelID ?? ''}
|
||||
onChange={(providerId, modelId) => {
|
||||
void handleModelChange(providerId, modelId);
|
||||
}}
|
||||
allowedProviderIds={providers}
|
||||
isModelAllowed={isStructuredOutputCapable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(reason === 'empty-diff' || reason === 'only-generated') && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
||||
{t('walkthrough.action.refresh')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
|
||||
import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff';
|
||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||
import { mergeRunPatch } from '@/lib/walkthrough/model';
|
||||
import type { WalkthroughHunk } from '@/lib/walkthrough/types';
|
||||
|
||||
interface WalkthroughHunkRunProps {
|
||||
path: string;
|
||||
hunks: WalkthroughHunk[];
|
||||
renderSideBySide: boolean;
|
||||
wrapLines: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One file's contribution to a stop. Consecutive hunks are merged back into a
|
||||
* single patch so the reader sees continuous code rather than a stack of
|
||||
* one-hunk cards.
|
||||
*
|
||||
* Inline comments are on: a review you cannot annotate is a reader, not a tool.
|
||||
* They work here because the merged patch keeps the original `@@` headers, so
|
||||
* the line numbers a comment captures are the file's real ones and not offsets
|
||||
* into an excerpt.
|
||||
*/
|
||||
export const WalkthroughHunkRun = memo(function WalkthroughHunkRun({
|
||||
path,
|
||||
hunks,
|
||||
renderSideBySide,
|
||||
wrapLines,
|
||||
}: WalkthroughHunkRunProps) {
|
||||
const fileDiff = useMemo(() => {
|
||||
const patch = mergeRunPatch(hunks);
|
||||
return patch ? fileDiffFromPatch(path, patch) : undefined;
|
||||
}, [hunks, path]);
|
||||
|
||||
if (!fileDiff) return null;
|
||||
|
||||
return (
|
||||
<PierreDiffViewer
|
||||
original=""
|
||||
modified=""
|
||||
fileDiff={fileDiff}
|
||||
language={getLanguageFromExtension(path) || ''}
|
||||
fileName={path}
|
||||
renderSideBySide={renderSideBySide}
|
||||
wrapLines={wrapLines}
|
||||
layout="inline"
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { I18nKey } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WalkthroughStageProgress } from './useWalkthroughStageProgress';
|
||||
|
||||
interface WalkthroughStagesProps {
|
||||
progress: WalkthroughStageProgress;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wait is long and uneven — collecting a pull request diff is seconds of
|
||||
* network, the model call is minutes — and a lone spinner makes those look
|
||||
* identical. Naming the phase says which one you are waiting on, and that the
|
||||
* cost has been committed once it reads "waiting on the model".
|
||||
*
|
||||
* No durations: a stopwatch on a step nobody can hurry adds pressure, not
|
||||
* information. And no mention of the schema fallback — from out here it is the
|
||||
* same wait, and naming our plumbing only invites the question of what it is.
|
||||
*/
|
||||
const STAGES: Array<{ labelKey: I18nKey }> = [
|
||||
{ labelKey: 'walkthrough.stage.collecting' },
|
||||
{ labelKey: 'walkthrough.stage.asking' },
|
||||
{ labelKey: 'walkthrough.stage.assembling' },
|
||||
];
|
||||
|
||||
export const WalkthroughStages = ({ progress }: WalkthroughStagesProps) => {
|
||||
const { t } = useI18n();
|
||||
const { completedCount, activeIndex } = progress;
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-2 text-left">
|
||||
{STAGES.map((entry, index) => {
|
||||
const isDone = index < completedCount;
|
||||
const isActive = activeIndex === index;
|
||||
|
||||
return (
|
||||
<li key={entry.labelKey} className="flex items-center gap-2">
|
||||
<span className="flex size-4 shrink-0 items-center justify-center">
|
||||
{isDone ? (
|
||||
<Icon name="check" className="size-3.5 text-status-success" />
|
||||
) : isActive ? (
|
||||
<Icon name="loader-4" className="size-3.5 animate-spin text-[var(--status-info)]" />
|
||||
) : (
|
||||
<span className="size-1.5 rounded-full bg-surface-muted" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'typography-meta',
|
||||
isActive ? 'text-foreground' : isDone ? 'text-muted-foreground' : 'text-muted-foreground/60'
|
||||
)}
|
||||
>
|
||||
{t(entry.labelKey)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,269 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { groupHunksByFile } from '@/lib/walkthrough/model';
|
||||
import type { WalkthroughStopView, WalkthroughView } from '@/lib/walkthrough/model';
|
||||
import type { WalkthroughHunk, WalkthroughStopImportance } from '@/lib/walkthrough/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { WalkthroughHunkRun } from './WalkthroughHunkRun';
|
||||
import { stopElementId } from './stopElementId';
|
||||
|
||||
interface WalkthroughStreamProps {
|
||||
view: WalkthroughView;
|
||||
activeStopId: string | null;
|
||||
scrollToStopId: string | null;
|
||||
onActiveStopChange: (stopId: string) => void;
|
||||
onScrollHandled: () => void;
|
||||
renderSideBySide: boolean;
|
||||
wrapLines: boolean;
|
||||
}
|
||||
|
||||
const IMPORTANCE_CLASS: Record<WalkthroughStopImportance, string> = {
|
||||
critical: 'bg-status-error/10 text-status-error',
|
||||
normal: 'bg-surface-muted text-muted-foreground',
|
||||
context: 'bg-surface-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
const StopHeader = ({ stopView }: { stopView: WalkthroughStopView }) => {
|
||||
const { t } = useI18n();
|
||||
const { stop } = stopView;
|
||||
|
||||
return (
|
||||
<header className="flex flex-col gap-2 px-4 pt-5 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-micro flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-surface-muted px-1.5 tabular-nums text-muted-foreground">
|
||||
{stopView.position}
|
||||
</span>
|
||||
<h3 className="typography-ui-label font-semibold text-foreground">{stop.title}</h3>
|
||||
{/* Same height as the step badge, so a row with an importance pill is
|
||||
exactly as tall as one without: vertical padding on a smaller type
|
||||
size was pushing past the tallest element in the row. */}
|
||||
{stop.importance !== 'normal' && (
|
||||
<span className={cn('typography-micro flex h-5 items-center rounded px-1.5 leading-none', IMPORTANCE_CLASS[stop.importance])}>
|
||||
{stop.importance === 'critical'
|
||||
? t('walkthrough.importance.critical')
|
||||
: t('walkthrough.importance.context')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="typography-body text-muted-foreground">{stop.prose}</p>
|
||||
{stopView.isStale && (
|
||||
<p className="typography-meta flex items-center gap-1.5 text-status-warning">
|
||||
<Icon name="error-warning" className="size-3.5 shrink-0" />
|
||||
{stopView.hunks.length === 0
|
||||
? t('walkthrough.stop.staleAll')
|
||||
: t('walkthrough.stop.stalePartial', { count: stopView.missingHunkIds.length })}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sticky so the file you are reading stays named while you scroll through its
|
||||
* hunks — the path is the main orientation cue in a long stream, and as a plain
|
||||
* caption it was easy to scroll straight past.
|
||||
*/
|
||||
const FileHeader = ({ path }: { path: string }) => (
|
||||
<div className="sticky top-0 z-10 flex items-center gap-1.5 border-b border-[var(--interactive-border)]/35 bg-[var(--surface-elevated)]/90 px-4 py-1.5 backdrop-blur-md supports-[backdrop-filter]:bg-[var(--surface-elevated)]/80">
|
||||
<FileTypeIcon filePath={path} className="size-3.5 shrink-0" />
|
||||
<span className="typography-meta truncate font-mono text-foreground">{path}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const HunkRuns = ({
|
||||
hunks,
|
||||
renderSideBySide,
|
||||
wrapLines,
|
||||
}: {
|
||||
hunks: WalkthroughHunk[];
|
||||
renderSideBySide: boolean;
|
||||
wrapLines: boolean;
|
||||
}) => {
|
||||
const runs = useMemo(() => groupHunksByFile(hunks), [hunks]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{runs.map((run, index) => (
|
||||
<div key={`${run.path}-${index}`}>
|
||||
<FileHeader path={run.path} />
|
||||
<WalkthroughHunkRun
|
||||
path={run.path}
|
||||
hunks={run.hunks}
|
||||
renderSideBySide={renderSideBySide}
|
||||
wrapLines={wrapLines}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything the walkthrough covers, plus everything it does not, in one
|
||||
* continuous scroll: a stop's explanation sits directly above the code it
|
||||
* explains.
|
||||
*/
|
||||
export const WalkthroughStream = memo(function WalkthroughStream({
|
||||
view,
|
||||
activeStopId,
|
||||
scrollToStopId,
|
||||
onActiveStopChange,
|
||||
onScrollHandled,
|
||||
renderSideBySide,
|
||||
wrapLines,
|
||||
}: WalkthroughStreamProps) {
|
||||
const { t } = useI18n();
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [uncoveredOpen, setUncoveredOpen] = useState(false);
|
||||
|
||||
// Set while a click-driven jump is in flight. Without it the observer reports
|
||||
// every stop the viewport passes over on the way to the target and the
|
||||
// highlight ends up on whichever one happened to be reported last — the
|
||||
// sidebar showing step 5 while the stream shows step 6.
|
||||
const navigatingRef = useRef<string | null>(null);
|
||||
const navigationTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (navigationTimerRef.current !== null) window.clearTimeout(navigationTimerRef.current);
|
||||
}, []);
|
||||
|
||||
// Scrolling is driven by the DOM rather than a virtualizer: only the visible
|
||||
// stops mount their diff viewers, and each stop is its own element, so there
|
||||
// is nothing to translate between index space and pixel space.
|
||||
useEffect(() => {
|
||||
if (!scrollToStopId) return;
|
||||
const element = document.getElementById(stopElementId(scrollToStopId));
|
||||
if (element) {
|
||||
navigatingRef.current = scrollToStopId;
|
||||
// Instant, not smooth: picking a step is a jump to a known destination,
|
||||
// and a long animation only creates a window for the highlight to drift
|
||||
// through everything in between.
|
||||
element.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||
|
||||
// The observer fires asynchronously after the jump, and an element that
|
||||
// was already in view may not fire at all — so the mute is released on a
|
||||
// timer as well as on arrival.
|
||||
if (navigationTimerRef.current !== null) window.clearTimeout(navigationTimerRef.current);
|
||||
navigationTimerRef.current = window.setTimeout(() => {
|
||||
navigatingRef.current = null;
|
||||
navigationTimerRef.current = null;
|
||||
}, 250);
|
||||
}
|
||||
onScrollHandled();
|
||||
}, [scrollToStopId, onScrollHandled]);
|
||||
|
||||
const handleIntersection = useCallback(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
|
||||
if (!visible) return;
|
||||
const stopId = visible.target.getAttribute('data-stop-id');
|
||||
if (!stopId) return;
|
||||
|
||||
const navigatingTo = navigatingRef.current;
|
||||
if (navigatingTo) {
|
||||
// Arrived: hand control back to free scrolling.
|
||||
if (stopId === navigatingTo) navigatingRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
onActiveStopChange(stopId);
|
||||
},
|
||||
[onActiveStopChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current;
|
||||
if (!root) return;
|
||||
|
||||
const observer = new IntersectionObserver(handleIntersection, {
|
||||
root,
|
||||
// Only count a stop as active once its header reaches the upper band of
|
||||
// the viewport, so scrolling through a long diff does not flicker the
|
||||
// active step back and forth.
|
||||
rootMargin: '0px 0px -70% 0px',
|
||||
threshold: 0,
|
||||
});
|
||||
|
||||
for (const stopView of view.stops) {
|
||||
const element = document.getElementById(stopElementId(stopView.stop.id));
|
||||
if (element) observer.observe(element);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [handleIntersection, view.stops]);
|
||||
|
||||
const uncoveredRuns = useMemo(() => groupHunksByFile(view.uncoveredHunks), [view.uncoveredHunks]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
data-diff-virtual-root
|
||||
data-diff-virtual-content
|
||||
>
|
||||
{view.stops.map((stopView) => (
|
||||
<section
|
||||
key={stopView.stop.id}
|
||||
id={stopElementId(stopView.stop.id)}
|
||||
data-stop-id={stopView.stop.id}
|
||||
className={cn(
|
||||
'border-b border-border/60',
|
||||
activeStopId === stopView.stop.id && 'bg-interactive-selection/5'
|
||||
)}
|
||||
>
|
||||
<StopHeader stopView={stopView} />
|
||||
{stopView.hunks.length > 0 ? (
|
||||
<HunkRuns
|
||||
hunks={stopView.hunks}
|
||||
renderSideBySide={renderSideBySide}
|
||||
wrapLines={wrapLines}
|
||||
/>
|
||||
) : (
|
||||
<p className="typography-meta px-4 pb-4 text-muted-foreground">
|
||||
{t('walkthrough.stop.noCode')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{view.uncoveredHunks.length > 0 && (
|
||||
<section className="border-b border-border/60">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto w-full justify-start gap-2 px-4 py-3"
|
||||
onClick={() => setUncoveredOpen((open) => !open)}
|
||||
aria-expanded={uncoveredOpen}
|
||||
>
|
||||
<Icon name={uncoveredOpen ? 'arrow-down-s' : 'arrow-right-s'} className="size-4 shrink-0" />
|
||||
<span className="typography-ui-label text-muted-foreground">
|
||||
{t('walkthrough.uncovered.title', { count: view.uncoveredHunks.length })}
|
||||
</span>
|
||||
</Button>
|
||||
{!uncoveredOpen && (
|
||||
<p className="typography-meta px-4 pb-3 pl-10 text-muted-foreground">
|
||||
{t('walkthrough.uncovered.description')}
|
||||
</p>
|
||||
)}
|
||||
{uncoveredOpen
|
||||
&& uncoveredRuns.map((run, index) => (
|
||||
<div key={`${run.path}-${index}`}>
|
||||
<FileHeader path={run.path} />
|
||||
<WalkthroughHunkRun
|
||||
path={run.path}
|
||||
hunks={run.hunks}
|
||||
renderSideBySide={renderSideBySide}
|
||||
wrapLines={wrapLines}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { summarizeHunkFiles } from '@/lib/walkthrough/model';
|
||||
import type { WalkthroughStopView, WalkthroughView } from '@/lib/walkthrough/model';
|
||||
import type { WalkthroughChapterIcon } from '@/lib/walkthrough/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface WalkthroughTocProps {
|
||||
view: WalkthroughView;
|
||||
activeStopId: string | null;
|
||||
visitedStopIds: ReadonlySet<string>;
|
||||
onSelectStop: (stopId: string) => void;
|
||||
width: number;
|
||||
}
|
||||
|
||||
const CHAPTER_ICONS: Record<WalkthroughChapterIcon, IconName> = {
|
||||
bug: 'bug',
|
||||
wrench: 'tools',
|
||||
path: 'compass-3',
|
||||
flask: 'flask',
|
||||
doc: 'file-text',
|
||||
gear: 'settings-3',
|
||||
};
|
||||
|
||||
const TocStop = ({
|
||||
stopView,
|
||||
isActive,
|
||||
isVisited,
|
||||
onSelect,
|
||||
activeRef,
|
||||
}: {
|
||||
stopView: WalkthroughStopView;
|
||||
isActive: boolean;
|
||||
isVisited: boolean;
|
||||
onSelect: () => void;
|
||||
activeRef: React.Ref<HTMLButtonElement>;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const files = summarizeHunkFiles(stopView.hunks);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
ref={isActive ? activeRef : undefined}
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-current={isActive ? 'step' : undefined}
|
||||
className={cn(
|
||||
'flex w-full flex-col gap-1 rounded px-2 py-1.5 text-left transition-colors',
|
||||
'hover:bg-interactive-hover',
|
||||
isActive && 'bg-interactive-selection text-interactive-selection-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
// A pill rather than a fixed circle: two-digit steps were cramped
|
||||
// and visibly off-centre in a square.
|
||||
'typography-micro flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full px-1 tabular-nums',
|
||||
isActive
|
||||
? 'bg-interactive-selection-foreground/20'
|
||||
: isVisited
|
||||
? 'bg-status-success/15 text-status-success'
|
||||
: 'bg-surface-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isVisited && !isActive ? <Icon name="check" className="size-2.5" /> : stopView.position}
|
||||
</span>
|
||||
<span className="typography-meta truncate font-medium">{stopView.stop.title}</span>
|
||||
{stopView.isStale && (
|
||||
<Icon
|
||||
name="error-warning"
|
||||
className="size-3 shrink-0 text-status-warning"
|
||||
aria-label={t('walkthrough.stop.staleShort')}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{files.length > 0 && (
|
||||
<span className="typography-micro flex flex-col gap-0.5 pl-6 text-muted-foreground">
|
||||
{files.slice(0, 3).map((file) => (
|
||||
<span key={file.path} className="truncate font-mono">
|
||||
{file.path}
|
||||
</span>
|
||||
))}
|
||||
{files.length > 3 && (
|
||||
<span>{t('walkthrough.toc.moreFiles', { count: files.length - 3 })}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export const WalkthroughToc = memo(function WalkthroughToc({
|
||||
view,
|
||||
activeStopId,
|
||||
visitedStopIds,
|
||||
onSelectStop,
|
||||
width,
|
||||
}: WalkthroughTocProps) {
|
||||
const { t } = useI18n();
|
||||
const activeRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
// Scrolling the stream moves the active step, and past a certain point the
|
||||
// highlighted row leaves the contents column entirely — the reader loses
|
||||
// their place in the very thing meant to hold it. `nearest` keeps the move
|
||||
// minimal, so clicking a row that is already visible does not jolt the list.
|
||||
useEffect(() => {
|
||||
activeRef.current?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeStopId]);
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="flex shrink-0 flex-col overflow-y-auto border-r border-border/60 p-2"
|
||||
style={{ width }}
|
||||
>
|
||||
{view.walkthrough.focus && (
|
||||
<p className="typography-meta px-2 pb-3 pt-1 text-muted-foreground">{view.walkthrough.focus}</p>
|
||||
)}
|
||||
{view.chapters.map(({ chapter, stops }) => (
|
||||
<section key={chapter.id} className="pb-3">
|
||||
<h4 className="typography-micro flex items-center gap-1.5 px-2 py-1 font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<Icon name={CHAPTER_ICONS[chapter.icon] ?? 'file-text'} className="size-3 shrink-0" />
|
||||
<span className="truncate">{chapter.title}</span>
|
||||
</h4>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{stops.map((stopView) => (
|
||||
<TocStop
|
||||
key={stopView.stop.id}
|
||||
stopView={stopView}
|
||||
isActive={activeStopId === stopView.stop.id}
|
||||
isVisited={visitedStopIds.has(stopView.stop.id)}
|
||||
onSelect={() => onSelectStop(stopView.stop.id)}
|
||||
activeRef={activeRef}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
{view.uncoveredHunks.length > 0 && (
|
||||
<p className="typography-micro mt-auto px-2 pt-3 text-muted-foreground">
|
||||
{t('walkthrough.toc.uncovered', { count: view.uncoveredHunks.length })}
|
||||
</p>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,761 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useI18n, type Locale } from '@/lib/i18n';
|
||||
import { buildWalkthroughView } from '@/lib/walkthrough/model';
|
||||
import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types';
|
||||
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
|
||||
import { deriveBaseBranch } from '@/components/views/git/baseBranch';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useGitBranches, useGitStatus } from '@/stores/useGitStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import {
|
||||
getFreshestPrStatusForBranch,
|
||||
getGitHubPrStatusKey,
|
||||
useGitHubPrStatusStore,
|
||||
} from '@/stores/useGitHubPrStatusStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useWalkthroughStore } from '@/stores/useWalkthroughStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { WalkthroughBlocker } from './WalkthroughBlocker';
|
||||
import { WALKTHROUGH_ACTION_CLASS } from './walkthroughAction';
|
||||
import { WalkthroughStages } from './WalkthroughStages';
|
||||
import { useWalkthroughStageProgress } from './useWalkthroughStageProgress';
|
||||
import { WalkthroughStream } from './WalkthroughStream';
|
||||
import { WalkthroughToc } from './WalkthroughToc';
|
||||
|
||||
interface WalkthroughViewProps {
|
||||
directory: string;
|
||||
}
|
||||
|
||||
const SCOPES: WalkthroughWorkingTreeScope[] = ['all', 'staged', 'working'];
|
||||
|
||||
// DropdownMenuLabel defaults to the same size and weight as its items, which
|
||||
// makes a heading read as another choice. This matches SelectLabel, the
|
||||
// treatment used by the worktree picker.
|
||||
const SCOPE_GROUP_LABEL_CLASS = 'typography-meta font-normal text-muted-foreground';
|
||||
|
||||
// Below this the table of contents would squeeze the diff into uselessness, so
|
||||
// the stream takes the whole panel and the header arrows carry navigation.
|
||||
const TOC_MIN_PANEL_WIDTH = 720;
|
||||
const TOC_MIN_WIDTH = 180;
|
||||
// The diff is the point of the surface; the contents column may never take more
|
||||
// than half the panel no matter how far the user drags.
|
||||
const TOC_MAX_FRACTION = 0.5;
|
||||
|
||||
// Below this the header controls wrap onto a second row and the labels squeeze
|
||||
// to two letters and an ellipsis, which reads as broken rather than dense. The
|
||||
// controls drop their text instead: every one of them carries an icon that
|
||||
// already identifies it.
|
||||
//
|
||||
// Every control in this row is 32px tall — `Button` size `sm` and the dropdown
|
||||
// trigger's `default` size are both h-8, so this is the design system's form
|
||||
// scale rather than a number picked here. Three heights in one row (28px
|
||||
// pickers, 32px action, 36px arrows) read as misalignment, not hierarchy.
|
||||
const HEADER_COMPACT_WIDTH = 680;
|
||||
|
||||
export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
|
||||
const { t, locale, locales, label } = useI18n();
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const [panelWidth, setPanelWidth] = useState(0);
|
||||
|
||||
// Panel width, not viewport width: this surface is resizable independently of
|
||||
// the window.
|
||||
useEffect(() => {
|
||||
const element = rootRef.current;
|
||||
if (!element || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
setPanelWidth(entries[0]?.contentRect.width ?? 0);
|
||||
});
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const storedTocWidth = useUIStore((state) => state.walkthroughTocWidth);
|
||||
const setStoredTocWidth = useUIStore((state) => state.setWalkthroughTocWidth);
|
||||
const [draggingToc, setDraggingToc] = useState(false);
|
||||
|
||||
const showToc = panelWidth === 0 || panelWidth >= TOC_MIN_PANEL_WIDTH;
|
||||
// Zero means the observer has not reported yet; assume there is room rather
|
||||
// than rendering a compact header for one frame on every open.
|
||||
const compactHeader = panelWidth > 0 && panelWidth < HEADER_COMPACT_WIDTH;
|
||||
// Clamped on read rather than on write: the panel can be resized after the
|
||||
// width was stored, and a remembered 400px column must not swallow a narrow
|
||||
// panel.
|
||||
const tocWidth = Math.min(
|
||||
Math.max(storedTocWidth, TOC_MIN_WIDTH),
|
||||
Math.max(TOC_MIN_WIDTH, (panelWidth || TOC_MIN_PANEL_WIDTH) * TOC_MAX_FRACTION)
|
||||
);
|
||||
|
||||
const handleTocResizeStart = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
const startWidth = tocWidth;
|
||||
const maxWidth = Math.max(TOC_MIN_WIDTH, (rootRef.current?.clientWidth ?? 0) * TOC_MAX_FRACTION);
|
||||
setDraggingToc(true);
|
||||
|
||||
const onMove = (moveEvent: PointerEvent) => {
|
||||
const next = Math.min(maxWidth, Math.max(TOC_MIN_WIDTH, startWidth + moveEvent.clientX - startX));
|
||||
setStoredTocWidth(next);
|
||||
};
|
||||
const onUp = () => {
|
||||
setDraggingToc(false);
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
window.removeEventListener('pointercancel', onUp);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
window.addEventListener('pointercancel', onUp);
|
||||
},
|
||||
[setStoredTocWidth, tocWidth]
|
||||
);
|
||||
|
||||
const handleTocResizeKey = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const step = event.shiftKey ? 40 : 10;
|
||||
const delta = event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0;
|
||||
if (delta === 0) return;
|
||||
event.preventDefault();
|
||||
const maxWidth = Math.max(TOC_MIN_WIDTH, (rootRef.current?.clientWidth ?? 0) * TOC_MAX_FRACTION);
|
||||
setStoredTocWidth(Math.min(maxWidth, Math.max(TOC_MIN_WIDTH, tocWidth + delta)));
|
||||
},
|
||||
[setStoredTocWidth, tocWidth]
|
||||
);
|
||||
const [scope, setScope] = useState<WalkthroughWorkingTreeScope>('all');
|
||||
const [activeStopId, setActiveStopId] = useState<string | null>(null);
|
||||
const [scrollToStopId, setScrollToStopId] = useState<string | null>(null);
|
||||
const [visitedStopIds, setVisitedStopIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
|
||||
const diffLayoutPreference = useUIStore((state) => state.diffLayoutPreference);
|
||||
const wrapLines = useUIStore((state) => state.diffWrapLines);
|
||||
// The walkthrough column is narrower than the diff surface and stops are read
|
||||
// top-to-bottom, so `dynamic` resolves to inline here rather than guessing
|
||||
// from the window width.
|
||||
const renderSideBySide = diffLayoutPreference === 'side-by-side';
|
||||
|
||||
const requestedSource = useWalkthroughStore((state) => state.requestedSource[directory]);
|
||||
const clearRequestedSource = useWalkthroughStore((state) => state.clearRequestedSource);
|
||||
|
||||
const status = useGitStatus(directory || null);
|
||||
const branches = useGitBranches(directory || null);
|
||||
|
||||
// The branch source reviews everything on this branch that is not on its
|
||||
// base. Three-dot semantics server-side mean merges from the base are
|
||||
// already excluded.
|
||||
const currentBranch = status?.current ?? null;
|
||||
const branchSource = useMemo<WalkthroughSource | null>(() => {
|
||||
const headRef = currentBranch;
|
||||
if (!headRef) return null;
|
||||
const all = branches?.all ?? [];
|
||||
const localBranches = all.filter((name) => !name.startsWith('remotes/'));
|
||||
const remoteNames = new Set(
|
||||
all
|
||||
.filter((name) => name.startsWith('remotes/'))
|
||||
.map((name) => name.slice('remotes/'.length).split('/')[0])
|
||||
.filter(Boolean)
|
||||
);
|
||||
const baseRef = deriveBaseBranch({ remoteNames, localBranches });
|
||||
if (!baseRef || baseRef === headRef) return null;
|
||||
return { kind: 'branch', baseRef, headRef };
|
||||
}, [branches, currentBranch]);
|
||||
|
||||
// The pull request for this branch used to appear only after visiting the PR
|
||||
// panel, because nothing else asked GitHub about it. Ask here too: the status
|
||||
// store already dedupes by signature and throttles by TTL, so several panels
|
||||
// wanting the same answer produce one request.
|
||||
const { github } = useRuntimeAPIs();
|
||||
const githubConnected = useGitHubAuthStore((state) => state.status?.connected ?? false);
|
||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||
const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
|
||||
const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams);
|
||||
const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets);
|
||||
|
||||
useEffect(() => {
|
||||
if (!directory || !currentBranch || !githubAuthChecked || !githubConnected) return;
|
||||
const key = getGitHubPrStatusKey(directory, currentBranch);
|
||||
ensurePrStatusEntry(key);
|
||||
setPrStatusParams(key, {
|
||||
directory,
|
||||
branch: currentBranch,
|
||||
remoteName: null,
|
||||
canShow: true,
|
||||
github,
|
||||
githubAuthChecked,
|
||||
githubConnected,
|
||||
});
|
||||
void refreshPrStatusTargets([{ directory, branch: currentBranch, remoteName: null }]);
|
||||
}, [
|
||||
currentBranch,
|
||||
directory,
|
||||
ensurePrStatusEntry,
|
||||
github,
|
||||
githubAuthChecked,
|
||||
githubConnected,
|
||||
refreshPrStatusTargets,
|
||||
setPrStatusParams,
|
||||
]);
|
||||
|
||||
// Selecting the number rather than the entry map: a primitive keeps this
|
||||
// panel out of every unrelated PR status update.
|
||||
const branchPrNumber = useGitHubPrStatusStore((state) => (
|
||||
directory && currentBranch
|
||||
? getFreshestPrStatusForBranch(state.entries, directory, currentBranch)?.pr?.number ?? null
|
||||
: null
|
||||
));
|
||||
|
||||
const source = useMemo<WalkthroughSource>(
|
||||
() => requestedSource ?? { kind: 'working-tree', scope },
|
||||
[requestedSource, scope]
|
||||
);
|
||||
|
||||
// Offer whichever pull request we know about: the one already selected, or
|
||||
// the one this branch has.
|
||||
const prSource = useMemo<Extract<WalkthroughSource, { kind: 'pr' }> | null>(() => {
|
||||
if (source.kind === 'pr') return source;
|
||||
return branchPrNumber ? { kind: 'pr', number: branchPrNumber } : null;
|
||||
}, [branchPrNumber, source]);
|
||||
|
||||
const selectWorkingTree = useCallback(
|
||||
(value: WalkthroughWorkingTreeScope) => {
|
||||
clearRequestedSource(directory);
|
||||
setScope(value);
|
||||
},
|
||||
[clearRequestedSource, directory]
|
||||
);
|
||||
const entry = useWalkthroughStore((state) => state.getEntry(directory, source));
|
||||
const load = useWalkthroughStore((state) => state.load);
|
||||
const generate = useWalkthroughStore((state) => state.generate);
|
||||
const cancel = useWalkthroughStore((state) => state.cancel);
|
||||
const requestSource = useWalkthroughStore((state) => state.requestSource);
|
||||
const selectModel = useWalkthroughStore((state) => state.selectModel);
|
||||
const selectedModel = useWalkthroughStore((state) => state.getSelectedModel(directory, source));
|
||||
const selectLanguage = useWalkthroughStore((state) => state.selectLanguage);
|
||||
const selectedLanguage = useWalkthroughStore((state) => state.getSelectedLanguage(directory, source));
|
||||
|
||||
// Explicit pick first, then the language the walkthrough on screen is
|
||||
// actually written in, then the interface locale. The middle step matters for
|
||||
// the same reason it does for the model: reopening a review should describe
|
||||
// what is there, not what a fresh one would be.
|
||||
const generatedLanguage = entry.result?.language;
|
||||
const activeLanguage: Locale = (
|
||||
selectedLanguage && locales.includes(selectedLanguage as Locale)
|
||||
? (selectedLanguage as Locale)
|
||||
: generatedLanguage && locales.includes(generatedLanguage as Locale)
|
||||
? (generatedLanguage as Locale)
|
||||
: locale
|
||||
);
|
||||
|
||||
// Reloads on a model or language change: whether this diff fits, and whether
|
||||
// the model can produce structured output, are answers about a specific
|
||||
// request — and the language instruction is part of that request.
|
||||
useEffect(() => {
|
||||
void load(directory, source, { language: activeLanguage });
|
||||
}, [activeLanguage, directory, load, source, selectedModel]);
|
||||
|
||||
const view = useMemo(() => buildWalkthroughView(entry.result), [entry.result]);
|
||||
|
||||
// A new walkthrough is a new reading path: keeping the old progress would
|
||||
// mark stops as visited that the user has never seen.
|
||||
const generatedAt = entry.result?.generatedAt;
|
||||
const lastGeneratedAt = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (lastGeneratedAt.current === generatedAt) return;
|
||||
lastGeneratedAt.current = generatedAt;
|
||||
setVisitedStopIds(new Set());
|
||||
setActiveStopId(view?.stops[0]?.stop.id ?? null);
|
||||
}, [generatedAt, view]);
|
||||
|
||||
const handleActiveStopChange = useCallback((stopId: string) => {
|
||||
setActiveStopId(stopId);
|
||||
setVisitedStopIds((visited) => {
|
||||
if (visited.has(stopId)) return visited;
|
||||
const next = new Set(visited);
|
||||
next.add(stopId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelectStop = useCallback(
|
||||
(stopId: string) => {
|
||||
handleActiveStopChange(stopId);
|
||||
setScrollToStopId(stopId);
|
||||
},
|
||||
[handleActiveStopChange]
|
||||
);
|
||||
|
||||
const step = useCallback(
|
||||
(delta: number) => {
|
||||
if (!view || view.stops.length === 0) return;
|
||||
const currentIndex = view.stops.findIndex((stop) => stop.stop.id === activeStopId);
|
||||
const nextIndex = Math.min(view.stops.length - 1, Math.max(0, (currentIndex < 0 ? 0 : currentIndex) + delta));
|
||||
handleSelectStop(view.stops[nextIndex].stop.id);
|
||||
},
|
||||
[activeStopId, handleSelectStop, view]
|
||||
);
|
||||
|
||||
const [sourceMenuOpen, setSourceMenuOpen] = useState(false);
|
||||
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
|
||||
const sourceValue = source.kind === 'working-tree' ? source.scope : source.kind;
|
||||
const sourceLabel = source.kind === 'branch'
|
||||
? t('walkthrough.scope.branch')
|
||||
: source.kind === 'pr'
|
||||
? t('walkthrough.scope.pullRequest', { number: source.number })
|
||||
: scope === 'all'
|
||||
? t('walkthrough.scope.all')
|
||||
: scope === 'staged'
|
||||
? t('walkthrough.scope.staged')
|
||||
: t('walkthrough.scope.working');
|
||||
|
||||
// Explicit pick first, then the model that actually produced what is on
|
||||
// screen, then whatever settings resolve to. The middle step is what makes
|
||||
// reopening a review show the model behind it rather than the default.
|
||||
const activeModel = selectedModel
|
||||
?? (entry.result?.model ? `${entry.result.model.providerID}/${entry.result.model.modelID}` : undefined)
|
||||
?? (entry.readiness?.model ? `${entry.readiness.model.providerID}/${entry.readiness.model.modelID}` : undefined);
|
||||
const [activeProviderId, ...activeModelParts] = (activeModel ?? '').split('/');
|
||||
const activeModelId = activeModelParts.join('/');
|
||||
|
||||
const modelsMetadata = useConfigStore((state) => state.modelsMetadata);
|
||||
const [modelProviders, setModelProviders] = useState<string[] | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (modelProviders !== undefined) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/small-model', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const payload = (await response.json().catch(() => null)) as { authenticatedProviders?: unknown } | null;
|
||||
if (!cancelled && Array.isArray(payload?.authenticatedProviders)) {
|
||||
setModelProviders(payload.authenticatedProviders.filter((id): id is string => typeof id === 'string'));
|
||||
}
|
||||
} catch {
|
||||
// Leave undefined: the picker then offers every provider, which is
|
||||
// worse but not broken.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [modelProviders]);
|
||||
|
||||
const isStructuredOutputCapable = useCallback(
|
||||
(providerId: string, modelId: string) =>
|
||||
modelsMetadata.get(`${providerId}/${modelId}`)?.structured_output !== false,
|
||||
[modelsMetadata]
|
||||
);
|
||||
|
||||
// Only generation is worth interrupting. A read is a few hundred milliseconds
|
||||
// of git with nothing to cancel, and offering a Cancel button for it made the
|
||||
// action flicker every time the model or language changed.
|
||||
const isGeneratingEntry = entry.status === 'generating';
|
||||
|
||||
// What is on screen versus what is being asked for. A read that has settled
|
||||
// is the only thing that can answer this: while one is in flight the panel is
|
||||
// still showing the previous answer, and a banner claiming something is
|
||||
// missing before we know would be the same flicker in another place.
|
||||
const shownModel = entry.result?.model
|
||||
? `${entry.result.model.providerID}/${entry.result.model.modelID}`
|
||||
: undefined;
|
||||
const shownLanguage = entry.result?.language;
|
||||
const shownLocale = shownLanguage && locales.includes(shownLanguage as Locale)
|
||||
? (shownLanguage as Locale)
|
||||
: undefined;
|
||||
const settled = entry.status === 'ready' && Boolean(view);
|
||||
// An entry written before walkthroughs had a language carries none. Unknown
|
||||
// is not the same as different, so it is not reported as missing.
|
||||
const languageMissing = settled && Boolean(shownLocale) && shownLocale !== activeLanguage;
|
||||
const modelMissing = settled && Boolean(shownModel) && Boolean(activeModel) && shownModel !== activeModel;
|
||||
|
||||
// The stage list outlives the work by a beat. Assembling takes milliseconds,
|
||||
// so without this the result replaces the list before the last step is ever
|
||||
// seen finishing — the user is told about a step they never observe.
|
||||
const stageProgress = useWalkthroughStageProgress(entry.stage, entry.status === 'generating');
|
||||
|
||||
// Only a generation that started from an empty panel gets held: regenerating
|
||||
// over an existing walkthrough keeps the stream on screen with a banner, and
|
||||
// hiding readable content to show a progress list would be a downgrade.
|
||||
const startedFromEmptyRef = useRef(false);
|
||||
const previousStatusRef = useRef(entry.status);
|
||||
useEffect(() => {
|
||||
if (previousStatusRef.current !== 'generating' && entry.status === 'generating') {
|
||||
startedFromEmptyRef.current = !view;
|
||||
}
|
||||
previousStatusRef.current = entry.status;
|
||||
}, [entry.status, view]);
|
||||
|
||||
const showStages = startedFromEmptyRef.current
|
||||
&& (entry.status === 'generating' || stageProgress.holding);
|
||||
const blockedReason = entry.error?.code === 'context-too-small'
|
||||
|| entry.error?.code === 'structured-output-unsupported'
|
||||
|| entry.error?.code === 'no-model'
|
||||
|| entry.error?.code === 'empty-diff'
|
||||
|| entry.error?.code === 'only-generated'
|
||||
|| entry.error?.code === 'output-exhausted'
|
||||
? entry.error.code
|
||||
: entry.readiness && !entry.readiness.ready && !view
|
||||
? entry.readiness.reason
|
||||
: undefined;
|
||||
|
||||
// Both sources carry the model that was tried; the error is the more specific
|
||||
// one when generation actually ran.
|
||||
const blockedModel = entry.error?.model ?? entry.readiness?.model;
|
||||
const blockedRequiredChars = entry.error?.requiredChars ?? entry.readiness?.requiredChars;
|
||||
const blockedAvailableChars = entry.error?.availableChars ?? entry.readiness?.availableChars;
|
||||
|
||||
const handleGenerate = useCallback(
|
||||
(force: boolean) => {
|
||||
void generate(directory, source, { force, language: activeLanguage });
|
||||
},
|
||||
[activeLanguage, directory, generate, source]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="flex h-full min-h-0 flex-col">
|
||||
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
|
||||
<DropdownMenu open={sourceMenuOpen} onOpenChange={setSourceMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 flex-shrink-0 items-center gap-1.5 rounded-md px-2 typography-ui-label font-semibold text-foreground outline-none hover:bg-interactive-hover focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={t('walkthrough.scope.selectorAria')}
|
||||
>
|
||||
<span className="whitespace-nowrap">{sourceLabel}</span>
|
||||
<Icon name="arrow-down-s" className="size-4 flex-shrink-0 opacity-60" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
<DropdownMenuRadioGroup
|
||||
value={sourceValue}
|
||||
onValueChange={(value) => {
|
||||
setSourceMenuOpen(false);
|
||||
if (value === 'branch') {
|
||||
if (branchSource) requestSource(directory, branchSource);
|
||||
return;
|
||||
}
|
||||
if (value === 'pr') {
|
||||
if (prSource) requestSource(directory, prSource);
|
||||
return;
|
||||
}
|
||||
selectWorkingTree(value as WalkthroughWorkingTreeScope);
|
||||
}}
|
||||
>
|
||||
{/* Grouped so "everything" is visibly scoped to uncommitted work:
|
||||
on its own next to "This branch" it read as "all changes that
|
||||
exist", which is the opposite of what it selects. */}
|
||||
<DropdownMenuLabel className={SCOPE_GROUP_LABEL_CLASS}>
|
||||
{t('walkthrough.scope.group.workingTree')}
|
||||
</DropdownMenuLabel>
|
||||
{SCOPES.map((value) => (
|
||||
<DropdownMenuRadioItem key={value} value={value}>
|
||||
{value === 'all'
|
||||
? t('walkthrough.scope.all')
|
||||
: value === 'staged'
|
||||
? t('walkthrough.scope.staged')
|
||||
: t('walkthrough.scope.working')}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
{(branchSource || prSource) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className={SCOPE_GROUP_LABEL_CLASS}>
|
||||
{t('walkthrough.scope.group.committed')}
|
||||
</DropdownMenuLabel>
|
||||
</>
|
||||
)}
|
||||
{branchSource && (
|
||||
<DropdownMenuRadioItem value="branch">
|
||||
{t('walkthrough.scope.branch')}
|
||||
</DropdownMenuRadioItem>
|
||||
)}
|
||||
{prSource && (
|
||||
<DropdownMenuRadioItem value="pr">
|
||||
{t('walkthrough.scope.pullRequest', { number: prSource.number })}
|
||||
</DropdownMenuRadioItem>
|
||||
)}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="ml-auto flex min-w-0 items-center gap-1">
|
||||
{/* A walkthrough nobody can read is worth nothing, so the prose
|
||||
language is a per-review choice like the model — defaulting to the
|
||||
interface language, which is the best evidence of what the reader
|
||||
reads. */}
|
||||
<DropdownMenu open={languageMenuOpen} onOpenChange={setLanguageMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 min-w-0 flex-shrink items-center gap-1.5 rounded-md px-2 typography-ui-label text-muted-foreground outline-none hover:bg-interactive-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={t('walkthrough.language.selectorAria')}
|
||||
title={compactHeader ? label(activeLanguage) : undefined}
|
||||
>
|
||||
<Icon name="global" className="size-4 flex-shrink-0 opacity-70" />
|
||||
{!compactHeader && (
|
||||
<>
|
||||
<span className="truncate">{label(activeLanguage)}</span>
|
||||
<Icon name="arrow-down-s" className="size-4 flex-shrink-0 opacity-60" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel className={SCOPE_GROUP_LABEL_CLASS}>
|
||||
{t('walkthrough.language.menuLabel')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={activeLanguage}
|
||||
onValueChange={(value) => {
|
||||
setLanguageMenuOpen(false);
|
||||
selectLanguage(directory, source, value);
|
||||
}}
|
||||
>
|
||||
{locales.map((value) => (
|
||||
<DropdownMenuRadioItem key={value} value={value}>
|
||||
{label(value)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Choosing a roomier model for a risky change is a per-review call,
|
||||
so this is panel state rather than a settings edit. */}
|
||||
<ModelSelector
|
||||
providerId={activeProviderId ?? ''}
|
||||
modelId={activeModelId}
|
||||
onChange={(providerId, modelId) => {
|
||||
selectModel(directory, source, providerId && modelId ? `${providerId}/${modelId}` : null);
|
||||
}}
|
||||
allowedProviderIds={modelProviders}
|
||||
isModelAllowed={isStructuredOutputCapable}
|
||||
tooltipsEnabled={false}
|
||||
dropdownPortalToBody
|
||||
compact={compactHeader}
|
||||
className={cn('h-8 min-w-0', !compactHeader && 'max-w-48')}
|
||||
/>
|
||||
{view && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={t('walkthrough.action.previous')}
|
||||
onClick={() => step(-1)}
|
||||
>
|
||||
<Icon name="arrow-right-s" className="size-4 rotate-180" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={t('walkthrough.action.next')}
|
||||
onClick={() => step(1)}
|
||||
>
|
||||
<Icon name="arrow-right-s" className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isGeneratingEntry ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={compactHeader ? t('walkthrough.action.cancel') : undefined}
|
||||
title={compactHeader ? t('walkthrough.action.cancel') : undefined}
|
||||
onClick={() => cancel(directory, source)}
|
||||
>
|
||||
{compactHeader
|
||||
? <Icon name="stop" className="size-3.5" />
|
||||
: t('walkthrough.action.cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={WALKTHROUGH_ACTION_CLASS}
|
||||
aria-label={compactHeader
|
||||
? (view ? t('walkthrough.action.regenerate') : t('walkthrough.action.generate'))
|
||||
: undefined}
|
||||
title={compactHeader
|
||||
? (view ? t('walkthrough.action.regenerate') : t('walkthrough.action.generate'))
|
||||
: undefined}
|
||||
onClick={() => handleGenerate(Boolean(view))}
|
||||
>
|
||||
<Icon name={view ? 'refresh' : 'route'} className="size-3.5" />
|
||||
{!compactHeader && (view ? t('walkthrough.action.regenerate') : t('walkthrough.action.generate'))}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* While regenerating over an existing walkthrough the stream keeps showing
|
||||
the old content, so the only other signal would be the button swapping
|
||||
to Cancel — far too quiet for something that runs for tens of seconds. */}
|
||||
{entry.status === 'generating' && view && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border/60 bg-[var(--status-info-background)] px-3 py-2">
|
||||
<Icon name="loader-4" className="size-4 shrink-0 animate-spin text-[var(--status-info)]" />
|
||||
<span className="typography-meta text-foreground">
|
||||
{entry.stage === 'collecting'
|
||||
? t('walkthrough.stage.collecting')
|
||||
: entry.stage === 'assembling'
|
||||
? t('walkthrough.stage.assembling')
|
||||
: t('walkthrough.stage.asking')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Switching the model or the language is a request for a walkthrough
|
||||
that may not exist yet. Falling back to the last one is better than an
|
||||
empty panel, but only if the panel says so — otherwise the picker
|
||||
claims Ukrainian over English prose. */}
|
||||
{(languageMissing || modelMissing) && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border/60 bg-[var(--status-info-background)] px-3 py-2">
|
||||
<Icon name="information" className="size-4 shrink-0 text-[var(--status-info)]" />
|
||||
<span className="typography-meta text-foreground">
|
||||
{languageMissing && modelMissing
|
||||
? t('walkthrough.missing.languageAndModel')
|
||||
: languageMissing
|
||||
? t('walkthrough.missing.language', {
|
||||
requested: label(activeLanguage),
|
||||
shown: label(shownLocale as Locale),
|
||||
})
|
||||
: t('walkthrough.missing.model', { model: activeModelId })}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="ml-auto"
|
||||
// Not forced: if an entry for this exact request existed the banner
|
||||
// would not be here, and a forced run would refuse the cache it may
|
||||
// find on the way.
|
||||
onClick={() => handleGenerate(false)}
|
||||
>
|
||||
{t('walkthrough.action.generate')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view?.isStale && entry.status !== 'generating' && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border/60 bg-status-warning/10 px-3 py-2">
|
||||
<Icon name="error-warning" className="size-4 shrink-0 text-status-warning" />
|
||||
<span className="typography-meta text-foreground">
|
||||
{t('walkthrough.stale.banner', { count: view.staleStopCount })}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="ml-auto"
|
||||
// Clicking this again mid-flight would abort the running generation
|
||||
// and start another — paying for the same answer twice.
|
||||
disabled={isGeneratingEntry}
|
||||
onClick={() => handleGenerate(true)}
|
||||
>
|
||||
{t('walkthrough.action.regenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.error && !blockedReason && (
|
||||
<div className="flex shrink-0 items-start gap-2 border-b border-border/60 bg-status-error/10 px-3 py-2">
|
||||
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-status-error" />
|
||||
{/* Provider errors arrive as raw JSON bodies. Show a readable amount
|
||||
and keep the rest reachable rather than filling the panel. */}
|
||||
<span className="typography-meta line-clamp-2 text-foreground" title={entry.error.message}>
|
||||
{entry.error.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn('flex min-h-0 flex-1', showToc ? 'flex-row' : 'flex-col')}>
|
||||
{blockedReason ? (
|
||||
<WalkthroughBlocker
|
||||
reason={blockedReason}
|
||||
model={blockedModel}
|
||||
requiredChars={blockedRequiredChars}
|
||||
availableChars={blockedAvailableChars}
|
||||
onRetry={() => void load(directory, source)}
|
||||
/>
|
||||
) : showStages ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8">
|
||||
<WalkthroughStages progress={stageProgress} />
|
||||
</div>
|
||||
) : view ? (
|
||||
<>
|
||||
{showToc && (
|
||||
<>
|
||||
<WalkthroughToc
|
||||
view={view}
|
||||
activeStopId={activeStopId}
|
||||
visitedStopIds={visitedStopIds}
|
||||
onSelectStop={handleSelectStop}
|
||||
width={tocWidth}
|
||||
/>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t('walkthrough.toc.resize')}
|
||||
tabIndex={0}
|
||||
onPointerDown={handleTocResizeStart}
|
||||
onKeyDown={handleTocResizeKey}
|
||||
className={cn(
|
||||
'group relative w-1 shrink-0 cursor-col-resize',
|
||||
'before:absolute before:inset-y-0 before:-left-1 before:-right-1 before:content-[\'\']',
|
||||
'hover:bg-interactive-selection focus-visible:bg-interactive-selection focus-visible:outline-none',
|
||||
draggingToc && 'bg-interactive-selection'
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<WalkthroughStream
|
||||
view={view}
|
||||
activeStopId={activeStopId}
|
||||
scrollToStopId={scrollToStopId}
|
||||
onActiveStopChange={handleActiveStopChange}
|
||||
onScrollHandled={() => setScrollToStopId(null)}
|
||||
renderSideBySide={renderSideBySide}
|
||||
wrapLines={wrapLines}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
{entry.status === 'loading' ? (
|
||||
<Icon name="loader-4" className="size-6 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<>
|
||||
<Icon name="route" className="size-6 text-muted-foreground" />
|
||||
<h3 className="typography-ui-label font-semibold text-foreground">
|
||||
{t('walkthrough.empty.title')}
|
||||
</h3>
|
||||
<p className="typography-meta max-w-md text-muted-foreground">
|
||||
{t('walkthrough.empty.description')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
/** DOM id for a stop section, shared by the stream and its scroll callers. */
|
||||
export const stopElementId = (stopId: string): string => `walkthrough-stop-${stopId}`;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { WALKTHROUGH_STAGE_ORDER, __testing } from './useWalkthroughStageProgress';
|
||||
|
||||
const { indexOfStage, nextIndex } = __testing;
|
||||
|
||||
// The pacing itself is a React effect, but the two decisions it rests on are
|
||||
// plain functions and are where the mistakes would live.
|
||||
describe('stage ordering', () => {
|
||||
test('maps every server stage onto a visible step', () => {
|
||||
expect(indexOfStage('collecting')).toBe(0);
|
||||
expect(indexOfStage('asking')).toBe(1);
|
||||
expect(indexOfStage('assembling')).toBe(2);
|
||||
});
|
||||
|
||||
test('folds the schema fallback into the same wait', () => {
|
||||
// From out here it is still "waiting on the model"; the fallback is our
|
||||
// plumbing and must not show up as its own step or as going backwards.
|
||||
expect(indexOfStage('retrying')).toBe(indexOfStage('asking'));
|
||||
});
|
||||
|
||||
test('treats an absent stage as not started', () => {
|
||||
expect(indexOfStage(null)).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('advancing', () => {
|
||||
test('moves one step at a time so none is skipped', () => {
|
||||
expect(nextIndex(-1, 2)).toBe(0);
|
||||
expect(nextIndex(0, 2)).toBe(1);
|
||||
expect(nextIndex(1, 2)).toBe(2);
|
||||
});
|
||||
|
||||
test('stops at the target', () => {
|
||||
expect(nextIndex(2, 2)).toBe(2);
|
||||
});
|
||||
|
||||
test('completion goes one past the last step so everything reads as done', () => {
|
||||
expect(nextIndex(2, WALKTHROUGH_STAGE_ORDER.length)).toBe(WALKTHROUGH_STAGE_ORDER.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { WalkthroughStage } from '@/lib/walkthrough/types';
|
||||
|
||||
/**
|
||||
* Paces the stage display so every step is actually seen.
|
||||
*
|
||||
* Assembling the walkthrough takes milliseconds, so on the real timeline it
|
||||
* flickers past between "waiting on the model" and the finished result — the
|
||||
* user is told about a step they never observe, which is worse than not
|
||||
* naming it. Each step is therefore held for a floor before the next one is
|
||||
* revealed, including the final all-done state.
|
||||
*
|
||||
* This delays the result by well under a second at the end of a wait measured
|
||||
* in minutes, and buys a legible finish in exchange.
|
||||
*/
|
||||
|
||||
const ORDER: WalkthroughStage[] = ['collecting', 'asking', 'assembling'];
|
||||
const MIN_STEP_MS = 450;
|
||||
|
||||
/** `retrying` is the same wait from the user's side: still waiting on the model. */
|
||||
const indexOfStage = (stage: WalkthroughStage | null): number => {
|
||||
if (stage === 'retrying') return ORDER.indexOf('asking');
|
||||
return stage ? ORDER.indexOf(stage) : -1;
|
||||
};
|
||||
|
||||
/** One step at a time, so a fast stage is still shown rather than skipped. */
|
||||
const nextIndex = (current: number, target: number): number => Math.min(current + 1, target);
|
||||
|
||||
export interface WalkthroughStageProgress {
|
||||
/** Steps to render as finished. */
|
||||
completedCount: number;
|
||||
/** Step to render as running, or `null` when everything is done. */
|
||||
activeIndex: number | null;
|
||||
/** True while the display still owes the user time after the work finished. */
|
||||
holding: boolean;
|
||||
}
|
||||
|
||||
export const useWalkthroughStageProgress = (
|
||||
stage: WalkthroughStage | null,
|
||||
active: boolean
|
||||
): WalkthroughStageProgress => {
|
||||
// `ORDER.length` means "all done"; -1 means nothing started.
|
||||
const [shownIndex, setShownIndex] = useState(-1);
|
||||
const shownAtRef = useRef(0);
|
||||
|
||||
const target = active ? Math.max(indexOfStage(stage), 0) : (shownIndex < 0 ? -1 : ORDER.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (target <= shownIndex) {
|
||||
// Work restarted: fall back to the earlier step immediately rather than
|
||||
// pretending the later one is still running.
|
||||
if (target < shownIndex && active) {
|
||||
setShownIndex(target);
|
||||
shownAtRef.current = Date.now();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - shownAtRef.current;
|
||||
const advance = () => {
|
||||
shownAtRef.current = Date.now();
|
||||
setShownIndex((current) => nextIndex(current, target));
|
||||
};
|
||||
|
||||
if (shownIndex < 0 || elapsed >= MIN_STEP_MS) {
|
||||
advance();
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(advance, MIN_STEP_MS - elapsed);
|
||||
return () => clearTimeout(timer);
|
||||
}, [active, shownIndex, target]);
|
||||
|
||||
// A fresh run resets the display so the next generation starts from the top.
|
||||
useEffect(() => {
|
||||
if (active || shownIndex < ORDER.length) return;
|
||||
const timer = setTimeout(() => setShownIndex(-1), MIN_STEP_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [active, shownIndex]);
|
||||
|
||||
const done = shownIndex >= ORDER.length;
|
||||
|
||||
return {
|
||||
completedCount: Math.max(0, Math.min(shownIndex, ORDER.length)),
|
||||
activeIndex: done || shownIndex < 0 ? null : shownIndex,
|
||||
holding: !active && shownIndex >= 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const WALKTHROUGH_STAGE_ORDER = ORDER;
|
||||
|
||||
export const __testing = { indexOfStage, nextIndex };
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Shared chrome for every "open / generate the AI walkthrough" action.
|
||||
*
|
||||
* The walkthrough is informational rather than a primary or destructive act, so
|
||||
* it carries the info status tint instead of competing with the primary button
|
||||
* next to it (Review in the diff toolbar, Merge in the pull request header).
|
||||
* Keeping it in one constant is what stops the three entry points from drifting
|
||||
* apart.
|
||||
*
|
||||
* No sparkle iconography: "this is AI" is not what the button does, and the
|
||||
* cliché tells the user nothing about the outcome.
|
||||
*/
|
||||
export const WALKTHROUGH_ACTION_CLASS =
|
||||
'border-[var(--status-info-border)] bg-[var(--status-info-background)] text-[var(--status-info)] hover:bg-[var(--status-info-background)] hover:text-[var(--status-info)]';
|
||||
@@ -891,7 +891,20 @@ html:not(.dark) .chat-scroll {
|
||||
}
|
||||
}
|
||||
|
||||
/* Diff toolbar: drop low-priority labels only when the context panel is genuinely tight. */
|
||||
/* Diff toolbar: the walkthrough label goes first. Review is the primary action
|
||||
in this row, so it keeps its label longest; the walkthrough is reachable from
|
||||
the rail as well and reads fine as an icon. */
|
||||
@container diff-toolbar (max-width: 36rem) {
|
||||
.diff-toolbar__walkthrough-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.diff-toolbar__walkthrough-button {
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Then the rest, once the panel is genuinely tight. */
|
||||
@container diff-toolbar (max-width: 28rem) {
|
||||
.diff-toolbar__scope-count,
|
||||
.diff-toolbar__review-label,
|
||||
@@ -905,8 +918,27 @@ html:not(.dark) .chat-scroll {
|
||||
}
|
||||
}
|
||||
|
||||
/* Status row: collapse optional text when narrow to keep both sides in one line. */
|
||||
@container status-row (max-width: 30rem) {
|
||||
/* Pull request actions: the walkthrough label goes first, since merge controls
|
||||
are the reason the row exists. */
|
||||
@container pr-actions (max-width: 30rem) {
|
||||
.pr-actions__walkthrough-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pr-actions__walkthrough-button {
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Status row: collapse optional text when narrow to keep both sides in one line.
|
||||
*
|
||||
* The todo text goes first, and it goes early. The changed-files summary on the
|
||||
* left has a floor it cannot shrink past — the file count, the +/- counts and
|
||||
* the chevron are all fixed width — so once the two sides no longer fit, they
|
||||
* collide rather than politely truncating. 30rem was measured against the todo
|
||||
* text alone; with a changed-files summary beside it the collision starts around
|
||||
* 36rem, so this hides one step before that. */
|
||||
@container status-row (max-width: 38rem) {
|
||||
.status-row__active-todo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -146,6 +146,17 @@ export interface GetGitDiffOptions {
|
||||
contextLines?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff between two refs. Uses three-dot (`base...head`) semantics server-side, so changes
|
||||
* pulled into `head` by merging `base` are excluded — only the branch's own work is returned.
|
||||
*/
|
||||
export interface GetGitRangeDiffOptions {
|
||||
base: string;
|
||||
head: string;
|
||||
path?: string;
|
||||
contextLines?: number;
|
||||
}
|
||||
|
||||
export interface GitFileDiffResponse {
|
||||
original: string;
|
||||
modified: string;
|
||||
@@ -453,6 +464,7 @@ export interface GitAPI {
|
||||
getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus>;
|
||||
getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse>;
|
||||
getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse>;
|
||||
getGitRangeDiff?(directory: string, options: GetGitRangeDiffOptions): Promise<GitDiffResponse>;
|
||||
revertGitFile(directory: string, filePath: string, options?: { scope?: 'all' | 'working' }): Promise<void>;
|
||||
stageGitFile(directory: string, filePath: string): Promise<void>;
|
||||
stageGitFiles?(directory: string, filePaths: string[]): Promise<void>;
|
||||
|
||||
@@ -131,6 +131,10 @@ export type DesktopSettings = {
|
||||
sessionGoalDefaultBudgetEnabled?: boolean;
|
||||
sessionGoalDefaultBudget?: number;
|
||||
smallModelOverride?: string; // format: "provider/model"
|
||||
// The walkthrough needs structured output and a roomy context, which the
|
||||
// small model is often deliberately not chosen for. Unset means "use the
|
||||
// small model"; a value replaces it for this feature only.
|
||||
walkthroughModelOverride?: string; // format: "provider/model"
|
||||
defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id
|
||||
openInAppId?: string;
|
||||
autoCreateWorktree?: boolean;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { isIPadApp } from '@/lib/platform';
|
||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||
|
||||
type DeviceType = 'desktop' | 'mobile' | 'tablet';
|
||||
@@ -121,7 +122,7 @@ export function getDeviceInfo(): DeviceInfo {
|
||||
// UI: every component in that tree is built mobile-first, so wide devices
|
||||
// (iPad, Android tablets, rotated phones) must not fall into
|
||||
// tablet/desktop branches scattered across shared components.
|
||||
// iPad-specific layout upgrades gate on isIPadApp()/orientation instead.
|
||||
// Tablet layout upgrades gate on useTabletLayout() (a size class) instead.
|
||||
isMobile = true;
|
||||
isTablet = false;
|
||||
isDesktop = false;
|
||||
@@ -364,6 +365,87 @@ export function useOrientation(): Orientation {
|
||||
return orientation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smallest viewport side that earns the tablet layout, following Android's
|
||||
* long-standing `sw600dp` size class. The SHORT side is what makes this a size
|
||||
* question rather than a device question: a phone reports ~360-430 whichever
|
||||
* way it is held, a 7"+ tablet or an unfolded book foldable reports ~600+, and
|
||||
* a foldable folded shut drops back under it. So a fold is just a resize, and
|
||||
* nothing here has to know what a foldable is.
|
||||
*/
|
||||
const TABLET_LAYOUT_MIN_SHORT_SIDE_PX = 600;
|
||||
/**
|
||||
* Width below which the workspace cannot become a side panel: the sessions
|
||||
* sidebar (~320) plus the panel (~380) plus a chat column that is still worth
|
||||
* reading. Unfolded book foldables land under this even "landscape", so they
|
||||
* keep the full-cover drawer in both orientations — which is the whole point,
|
||||
* their wide side is barely wider than a tablet's narrow one.
|
||||
*/
|
||||
const WORKSPACE_PANEL_MIN_WIDTH_PX = 1000;
|
||||
|
||||
export interface TabletLayout {
|
||||
/** Sessions become a persistent sidebar, dropdowns become anchored popovers. */
|
||||
enabled: boolean;
|
||||
/** There is room for the workspace beside the chat instead of over it. */
|
||||
roomyForPanels: boolean;
|
||||
}
|
||||
|
||||
export const readTabletLayout = (): TabletLayout => {
|
||||
if (typeof window === 'undefined') return { enabled: false, roomyForPanels: false };
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
// iPads answer this on identity too: iPadOS reports odd viewports in Slide
|
||||
// Over / Split View, and a device we KNOW is a tablet should not flip to the
|
||||
// phone layout because it was given a narrow slice.
|
||||
const enabled = isIPadApp() || Math.min(width, height) >= TABLET_LAYOUT_MIN_SHORT_SIDE_PX;
|
||||
return {
|
||||
enabled,
|
||||
roomyForPanels: enabled && width > height && width >= WORKSPACE_PANEL_MIN_WIDTH_PX,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The tablet layout decision, live.
|
||||
*
|
||||
* Deliberately a hook over a one-shot check: foldables change size class while
|
||||
* the app runs, and the Android shell keeps the WebView alive across the fold
|
||||
* (`configChanges` covers screenSize), so every consumer has to re-decide
|
||||
* rather than remember what it saw at mount.
|
||||
*/
|
||||
export function useTabletLayout(): TabletLayout {
|
||||
const [layout, setLayout] = React.useState<TabletLayout>(readTabletLayout);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
let frame: number | undefined;
|
||||
const update = () => {
|
||||
frame = undefined;
|
||||
const next = readTabletLayout();
|
||||
setLayout((current) => (
|
||||
current.enabled === next.enabled && current.roomyForPanels === next.roomyForPanels
|
||||
? current
|
||||
: next
|
||||
));
|
||||
};
|
||||
const schedule = () => {
|
||||
if (frame !== undefined) return;
|
||||
frame = window.requestAnimationFrame(update);
|
||||
};
|
||||
|
||||
update();
|
||||
window.addEventListener('resize', schedule);
|
||||
const orientationQuery = window.matchMedia?.('(orientation: landscape)') ?? null;
|
||||
const detachOrientation = attachMediaQueryListener(orientationQuery, schedule);
|
||||
return () => {
|
||||
window.removeEventListener('resize', schedule);
|
||||
detachOrientation();
|
||||
if (frame !== undefined) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
export function useDeviceInfo(): DeviceInfo {
|
||||
return React.useSyncExternalStore(
|
||||
subscribeDeviceInfo,
|
||||
|
||||
@@ -135,6 +135,15 @@ export async function getGitFileDiff(
|
||||
return gitHttp.getGitFileDiff(directory, options);
|
||||
}
|
||||
|
||||
export async function getGitRangeDiff(
|
||||
directory: string,
|
||||
options: import('./api/types').GetGitRangeDiffOptions
|
||||
): Promise<import('./api/types').GitDiffResponse> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime?.getGitRangeDiff) return runtime.getGitRangeDiff(directory, options);
|
||||
return gitHttp.getGitRangeDiff(directory, options);
|
||||
}
|
||||
|
||||
export async function revertGitFile(
|
||||
directory: string,
|
||||
filePath: string,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
GitStatus,
|
||||
GitDiffResponse,
|
||||
GetGitDiffOptions,
|
||||
GetGitRangeDiffOptions,
|
||||
GitFileDiffResponse,
|
||||
GetGitFileDiffOptions,
|
||||
GitBranch,
|
||||
@@ -222,6 +223,31 @@ export async function getGitDiff(directory: string, options: GetGitDiffOptions):
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getGitRangeDiff(
|
||||
directory: string,
|
||||
options: GetGitRangeDiffOptions
|
||||
): Promise<GitDiffResponse> {
|
||||
const { base, head, path, contextLines } = options;
|
||||
if (!base || !head) {
|
||||
throw new Error('base and head are required to fetch git range diff');
|
||||
}
|
||||
|
||||
const response = await runtimeFetch(
|
||||
buildUrl(`${API_BASE}/range-diff`, directory, {
|
||||
base,
|
||||
head,
|
||||
path: path || undefined,
|
||||
context: contextLines,
|
||||
})
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get git range diff: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse> {
|
||||
const { path, staged } = options;
|
||||
if (!path) {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* "Is a hardware keyboard attached?" — the input the mobile layout uses to
|
||||
* decide whether a soft keyboard will ever eat the screen.
|
||||
*
|
||||
* Two sources, in priority order:
|
||||
*
|
||||
* 1. The native answer. On iOS the shell reads `GCKeyboard` and stamps
|
||||
* `window.__OPENCHAMBER_HARDWARE_KEYBOARD__` at document start, then keeps it
|
||||
* live via `oc:hardware-keyboard` (see BridgeViewController). This is
|
||||
* authoritative and — crucially — known BEFORE the user focuses anything, so
|
||||
* the draft screen and composer start in the right shape instead of
|
||||
* re-laying-out after the first focus.
|
||||
* 2. Inference, for runtimes with no native answer (Android, hosted mobile).
|
||||
* A `keyboardWillShow` with a real height means there IS a soft keyboard; a
|
||||
* tiny height means only iOS' shortcut strip; focus with no event at all
|
||||
* within a short window means nothing was presented. Inference is ignored
|
||||
* entirely once the native source has spoken.
|
||||
*
|
||||
* Everything else stays `false`, which is the safe default: the layout then
|
||||
* behaves exactly as it does on a phone.
|
||||
*
|
||||
* In memory only — a keyboard can be attached and detached while the app runs,
|
||||
* and both sources re-answer the question continuously.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
/** Below this the "keyboard" is only iOS' shortcut bar, not a real keyboard. */
|
||||
const SOFTWARE_KEYBOARD_MIN_HEIGHT_PX = 120;
|
||||
/** iOS starts its keyboard animation well inside this window after focus. */
|
||||
const KEYBOARD_EVENT_GRACE_MS = 600;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCHAMBER_HARDWARE_KEYBOARD__?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
// Read at module init, not just from the bridge effect: the stamp exists from
|
||||
// document start, and the very first render of the draft screen / composer must
|
||||
// already see it — otherwise the layout still settles one frame late.
|
||||
const initialNativeAnswer = typeof window !== 'undefined'
|
||||
&& typeof window.__OPENCHAMBER_HARDWARE_KEYBOARD__ === 'boolean'
|
||||
? window.__OPENCHAMBER_HARDWARE_KEYBOARD__
|
||||
: null;
|
||||
|
||||
let hardwareKeyboardAttached = initialNativeAnswer === true;
|
||||
let hasNativeAnswer = initialNativeAnswer !== null;
|
||||
let focusProbeTimer: number | null = null;
|
||||
let bridgeStarted = false;
|
||||
const subscribers = new Set<() => void>();
|
||||
|
||||
if (hardwareKeyboardAttached && typeof document !== 'undefined') {
|
||||
document.documentElement.classList.add('oc-hardware-keyboard');
|
||||
}
|
||||
|
||||
const clearFocusProbe = (): void => {
|
||||
if (focusProbeTimer === null) return;
|
||||
window.clearTimeout(focusProbeTimer);
|
||||
focusProbeTimer = null;
|
||||
};
|
||||
|
||||
const setHardwareKeyboardAttached = (value: boolean): void => {
|
||||
if (hardwareKeyboardAttached === value) return;
|
||||
hardwareKeyboardAttached = value;
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.toggle('oc-hardware-keyboard', value);
|
||||
}
|
||||
for (const listener of subscribers) listener();
|
||||
};
|
||||
|
||||
/**
|
||||
* Adopt the native shell's answer and stop inferring. Idempotent; safe to call
|
||||
* before the shell has stamped anything (then it is a no-op and inference
|
||||
* stays in charge).
|
||||
*/
|
||||
export const startHardwareKeyboardBridge = (): (() => void) => {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
|
||||
const adopt = (value: boolean) => {
|
||||
hasNativeAnswer = true;
|
||||
clearFocusProbe();
|
||||
setHardwareKeyboardAttached(value);
|
||||
};
|
||||
|
||||
if (typeof window.__OPENCHAMBER_HARDWARE_KEYBOARD__ === 'boolean') {
|
||||
adopt(window.__OPENCHAMBER_HARDWARE_KEYBOARD__);
|
||||
}
|
||||
|
||||
if (bridgeStarted) return () => {};
|
||||
bridgeStarted = true;
|
||||
|
||||
const handleNativeChange = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ attached?: boolean }>).detail;
|
||||
adopt(detail?.attached === true);
|
||||
};
|
||||
window.addEventListener('oc:hardware-keyboard', handleNativeChange);
|
||||
return () => {
|
||||
window.removeEventListener('oc:hardware-keyboard', handleNativeChange);
|
||||
bridgeStarted = false;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Feed a native `keyboardWillShow` height in. Called by the Capacitor keyboard
|
||||
* bridge (see `mobileNativeChrome`) on both platforms. An arriving event always
|
||||
* settles the question, so it cancels any pending focus probe.
|
||||
*/
|
||||
export const observeNativeKeyboardHeight = (heightPx: number): void => {
|
||||
if (hasNativeAnswer || !Number.isFinite(heightPx)) return;
|
||||
clearFocusProbe();
|
||||
setHardwareKeyboardAttached(heightPx > 0 && heightPx < SOFTWARE_KEYBOARD_MIN_HEIGHT_PX);
|
||||
};
|
||||
|
||||
/**
|
||||
* Report that an editor just took focus. If no keyboard event follows, nothing
|
||||
* was presented — which means a hardware keyboard is attached.
|
||||
*
|
||||
* Deliberately one-directional within the window: only the SILENCE concludes
|
||||
* "hardware". A real `keyboardWillShow` cancels the probe above, so a slow
|
||||
* keyboard can never be misread.
|
||||
*/
|
||||
export const observeEditorFocus = (): void => {
|
||||
if (hasNativeAnswer || typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
// A soft keyboard already up sends no second `keyboardWillShow` — a refocus
|
||||
// through it (the overlay-close keyboard restore) would look like silence.
|
||||
if (document.documentElement.classList.contains('oc-keyboard-open')) return;
|
||||
clearFocusProbe();
|
||||
focusProbeTimer = window.setTimeout(() => {
|
||||
focusProbeTimer = null;
|
||||
setHardwareKeyboardAttached(true);
|
||||
}, KEYBOARD_EVENT_GRACE_MS);
|
||||
};
|
||||
|
||||
/** Drop the inferred state when the native bridge tears down. */
|
||||
export const resetHardwareKeyboardDetection = (): void => {
|
||||
clearFocusProbe();
|
||||
if (hasNativeAnswer) return;
|
||||
setHardwareKeyboardAttached(false);
|
||||
};
|
||||
|
||||
export const isHardwareKeyboardAttached = (): boolean => hardwareKeyboardAttached;
|
||||
|
||||
export const subscribeHardwareKeyboard = (listener: () => void): (() => void) => {
|
||||
subscribers.add(listener);
|
||||
return () => {
|
||||
subscribers.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export function useHardwareKeyboard(): boolean {
|
||||
return React.useSyncExternalStore(
|
||||
subscribeHardwareKeyboard,
|
||||
isHardwareKeyboardAttached,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
@@ -209,12 +209,32 @@ const JA_MESSAGES: BootstrapMessages = {
|
||||
loadingData: (providersText, agentsText) => `データを読み込み中 (${providersText}, ${agentsText})…`,
|
||||
};
|
||||
|
||||
const DE_MESSAGES: BootstrapMessages = {
|
||||
startingApi: 'OpenCode API wird gestartet…',
|
||||
initializing: 'Initialisierung…',
|
||||
connecting: 'Verbindung wird hergestellt…',
|
||||
connected: 'Verbunden!',
|
||||
connectionError: 'Verbindungsfehler',
|
||||
disconnected: 'Verbindung getrennt',
|
||||
reconnecting: 'Verbindung wird wiederhergestellt…',
|
||||
initialDataLoadFailed: 'OpenCode ist verbunden, aber die Anfangsdaten konnten nicht geladen werden.',
|
||||
cliNotFound: 'OpenCode CLI wurde nicht gefunden. Installieren Sie es zuerst.',
|
||||
providersReady: '✓ Anbieter',
|
||||
providersLoading: '… Anbieter',
|
||||
agentsReady: '✓ Agents',
|
||||
agentsLoading: '… Agents',
|
||||
startingDevServer: (hostLabel) => `Webview-Entwicklungsserver wird gestartet (${hostLabel})...`,
|
||||
waitingDevServer: (hostLabel, attempt) => `Warten auf den Webview-Entwicklungsserver (${hostLabel})... Versuch ${attempt}`,
|
||||
loadingData: (providersText, agentsText) => `Daten werden geladen (${providersText}, ${agentsText})…`,
|
||||
};
|
||||
|
||||
export const getBootstrapMessages = (locale: Locale): BootstrapMessages => {
|
||||
return BOOTSTRAP_MESSAGES[locale];
|
||||
};
|
||||
|
||||
const BOOTSTRAP_MESSAGES: Record<Locale, BootstrapMessages> = {
|
||||
en: EN_MESSAGES,
|
||||
de: DE_MESSAGES,
|
||||
fr: FR_MESSAGES,
|
||||
'zh-CN': ZH_CN_MESSAGES,
|
||||
'zh-TW': ZH_TW_MESSAGES,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Locale } from './runtime';
|
||||
|
||||
const INTL_LOCALE_BY_LOCALE: Record<Locale, string> = {
|
||||
en: 'en-US',
|
||||
de: 'de-DE',
|
||||
fr: 'fr-FR',
|
||||
'zh-CN': 'zh-CN',
|
||||
'zh-TW': 'zh-TW',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { dict as enDict } from './messages/en';
|
||||
import { dict as esDict } from './messages/es';
|
||||
import { dict as deDict } from './messages/de';
|
||||
import { dict as frDict } from './messages/fr';
|
||||
import { dict as jaDict } from './messages/ja';
|
||||
import { dict as koDict } from './messages/ko';
|
||||
@@ -13,6 +14,7 @@ import { dict as zhTwDict } from './messages/zh-TW';
|
||||
|
||||
const localeDictionaries = {
|
||||
en: enDict,
|
||||
de: deDict,
|
||||
fr: frDict,
|
||||
es: esDict,
|
||||
ja: jaDict,
|
||||
@@ -35,6 +37,7 @@ describe('i18n dictionaries', () => {
|
||||
|
||||
test('all locales expose language label keys', () => {
|
||||
for (const [, dictionary] of Object.entries(localeDictionaries)) {
|
||||
expect(dictionary['common.language.german']).toBeTruthy();
|
||||
expect(dictionary['common.language.french']).toBeTruthy();
|
||||
expect(dictionary['common.language.japanese']).toBeTruthy();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1604,6 +1604,10 @@ export const settingsDict = {
|
||||
'settings.openchamber.defaults.smallModel.useDefault': 'Use default small model',
|
||||
'settings.openchamber.defaults.smallModel.useDefaultAria': 'Use default small model',
|
||||
'settings.openchamber.defaults.smallModel.overrideModel': 'Override model',
|
||||
'settings.openchamber.defaults.walkthroughModel.title': 'Changes Walkthrough Model',
|
||||
'settings.openchamber.defaults.walkthroughModel.description': 'The AI review of your changes needs structured output and room for a whole diff, which a cheap small model often cannot give. Models the catalog reports as unable to produce structured output are hidden from this picker. Leave it unset and the small model is used.',
|
||||
'settings.openchamber.defaults.walkthroughModel.overrideModel': 'Walkthrough model',
|
||||
'settings.openchamber.defaults.walkthroughModel.usesSmallModel': 'Small model',
|
||||
'settings.openchamber.defaults.field.openFilesPreviewAria': 'Open previewable files in preview mode',
|
||||
'settings.openchamber.defaults.field.openFilesPreview': 'Open previewable files in preview mode',
|
||||
'settings.openchamber.defaults.option.default': 'Default',
|
||||
|
||||
@@ -14,6 +14,7 @@ export const dict = {
|
||||
'common.loading': 'Loading...',
|
||||
'common.unavailable': 'Unavailable',
|
||||
'common.language.english': 'English',
|
||||
'common.language.german': 'German',
|
||||
'common.language.french': 'French',
|
||||
'common.language.simplifiedChinese': 'Chinese (Simplified)',
|
||||
'common.language.traditionalChinese': 'Chinese (Traditional)',
|
||||
@@ -90,7 +91,6 @@ export const dict = {
|
||||
'mobile.nav.changes': 'Changes',
|
||||
'mobile.nav.settings': 'Settings',
|
||||
'mobile.surface.closeAria': 'Close',
|
||||
'mobile.header.openMenuAria': 'Open menu',
|
||||
'mobile.header.openWorkspaceAria': 'Open workspace panel',
|
||||
'mobile.header.openMetadataAria': 'Open session metadata',
|
||||
'mobile.header.metadata.context': 'Context',
|
||||
@@ -834,12 +834,15 @@ export const dict = {
|
||||
'gitView.operation.abort': 'Abort',
|
||||
'gitView.operation.continue': 'Continue',
|
||||
'gitView.operation.inProgressTitle': '{operation} in progress',
|
||||
'gitView.operation.inProgressTitleManyConflicts': '{operation} in progress: {count} conflicts',
|
||||
'gitView.operation.inProgressTitleOneConflict': '{operation} in progress: {count} conflict',
|
||||
'gitView.operation.merge': 'Merge',
|
||||
'gitView.operation.mergeInProgressWithHead': 'Merging {head}',
|
||||
'gitView.operation.mergingMessage': 'Merging: {message}',
|
||||
'gitView.operation.rebase': 'Rebase',
|
||||
'gitView.operation.rebaseInProgress': 'Rebase in progress',
|
||||
'gitView.operation.rebasingOnto': 'Rebasing {headName} onto {onto}',
|
||||
'gitView.operation.readyToContinueHint': 'All conflicts resolved. Continue to finish the operation.',
|
||||
'gitView.operation.resolveConflictsHint': 'Resolve conflicts, then continue the operation.',
|
||||
'gitView.operation.resolveWithAi': 'Resolve with AI',
|
||||
'gitView.stash.confirmButton': '{operation}',
|
||||
@@ -1095,6 +1098,61 @@ export const dict = {
|
||||
'contextRail.surface.git.description': 'Commits, branches, and pull requests',
|
||||
'contextRail.surface.terminal.description': 'Built-in terminal',
|
||||
'contextRail.surface.diff.description': 'Review working changes',
|
||||
'contextPanel.mode.walkthrough': 'Walkthrough',
|
||||
'contextRail.surface.walkthrough.description': 'An AI-guided walkthrough of your changes',
|
||||
'walkthrough.scope.all': 'All uncommitted',
|
||||
'walkthrough.scope.group.workingTree': 'Working tree',
|
||||
'walkthrough.scope.group.committed': 'Committed',
|
||||
'walkthrough.scope.staged': 'Staged',
|
||||
'walkthrough.scope.working': 'Unstaged',
|
||||
'walkthrough.scope.branch': 'This branch',
|
||||
'walkthrough.scope.selectorAria': 'Select what to review',
|
||||
'walkthrough.language.menuLabel': 'Walkthrough language',
|
||||
'walkthrough.missing.language': 'No walkthrough in {requested} yet — showing the one in {shown}.',
|
||||
'walkthrough.missing.model': 'No walkthrough from {model} yet — showing the last one generated here.',
|
||||
'walkthrough.missing.languageAndModel': 'No walkthrough in this language from this model yet — showing the last one generated here.',
|
||||
'walkthrough.language.selectorAria': 'Select the walkthrough language',
|
||||
'walkthrough.scope.pullRequest': 'PR #{number}',
|
||||
'walkthrough.action.generate': 'Generate walkthrough',
|
||||
'walkthrough.action.regenerate': 'Regenerate',
|
||||
'walkthrough.action.cancel': 'Cancel',
|
||||
'walkthrough.action.next': 'Next step',
|
||||
'walkthrough.action.previous': 'Previous step',
|
||||
'walkthrough.action.refresh': 'Refresh',
|
||||
'walkthrough.action.open': 'Walkthrough',
|
||||
'walkthrough.stage.collecting': 'Collecting the changes',
|
||||
'walkthrough.stage.asking': 'Waiting on the model',
|
||||
'walkthrough.stage.assembling': 'Assembling the walkthrough',
|
||||
'walkthrough.empty.title': 'No walkthrough yet',
|
||||
'walkthrough.empty.description': 'Build a guided reading path through these changes. This calls the small model and costs tokens, so it only ever runs when you ask.',
|
||||
'walkthrough.stale.banner': 'The code changed after this review. Outdated steps: {count}',
|
||||
'walkthrough.stop.staleAll': 'All of the code this step described has changed.',
|
||||
'walkthrough.stop.stalePartial': 'Part of the code this step described has changed. Missing pieces: {count}',
|
||||
'walkthrough.stop.staleShort': 'Outdated',
|
||||
'walkthrough.stop.noCode': 'None of the code for this step is in the current diff.',
|
||||
'walkthrough.uncovered.title': 'Changes the review left out: {count}',
|
||||
'walkthrough.uncovered.description': 'The review treated these as routine. Expand to check them yourself.',
|
||||
'walkthrough.toc.moreFiles': 'More files: {count}',
|
||||
'walkthrough.toc.uncovered': 'Not covered: {count}',
|
||||
'walkthrough.toc.resize': 'Resize the contents column',
|
||||
'walkthrough.importance.critical': 'Critical',
|
||||
'walkthrough.importance.context': 'Context',
|
||||
'walkthrough.blocked.noModel.title': 'No small model available',
|
||||
'walkthrough.blocked.noModel.description': 'Sign in to a model provider to generate a review.',
|
||||
'walkthrough.blocked.emptyDiff.title': 'Nothing to review',
|
||||
'walkthrough.blocked.emptyDiff.description': 'There are no changes in this scope yet.',
|
||||
'walkthrough.blocked.contextTooSmall.title': 'This diff is too large for the current model',
|
||||
'walkthrough.blocked.contextTooSmall.description': '{model} fits about {available}K characters and this diff needs about {required}K. Nothing gets truncated — pick a model with a larger context instead.',
|
||||
'walkthrough.blocked.structuredOutput.title': 'This model cannot produce structured output',
|
||||
'walkthrough.blocked.structuredOutput.description': '{model} does not support the structured responses a walkthrough needs.',
|
||||
'walkthrough.blocked.chooseModel': 'Choose the small model',
|
||||
'walkthrough.blocked.outputExhausted.title': 'This model ran out of answer budget',
|
||||
'walkthrough.blocked.outputExhausted.description': '{model} spent its whole output allowance on reasoning and returned nothing. Reasoning models often do this on large diffs — a model that thinks less, or reviewing a narrower scope, will get through.',
|
||||
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'The small model spent its whole output allowance on reasoning and returned nothing. Reasoning models often do this on large diffs — a model that thinks less, or reviewing a narrower scope, will get through.',
|
||||
'walkthrough.blocked.onlyGenerated.title': 'Only generated files changed',
|
||||
'walkthrough.blocked.onlyGenerated.description': 'Every change here is a lockfile or other tool-produced output, which the review deliberately skips.',
|
||||
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'The small model fits about {available}K characters and this diff needs about {required}K. Nothing gets truncated — pick a model with a larger context instead.',
|
||||
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'The small model does not support the structured responses a walkthrough needs.',
|
||||
'contextRail.surface.plan.description': 'View the current plan',
|
||||
'contextRail.surface.pr.description': 'Create, review, and merge the pull request for the current branch',
|
||||
'contextRail.surface.notes.description': 'Notes, todos, and plans for the project',
|
||||
|
||||
@@ -1579,6 +1579,10 @@ export const settingsDict = {
|
||||
"settings.openchamber.defaults.smallModel.useDefault": "Usar el modelo pequeño predeterminado",
|
||||
"settings.openchamber.defaults.smallModel.useDefaultAria": "Usar el modelo pequeño predeterminado",
|
||||
"settings.openchamber.defaults.smallModel.overrideModel": "Modelo de anulación",
|
||||
"settings.openchamber.defaults.walkthroughModel.title": "Modelo del recorrido de cambios",
|
||||
"settings.openchamber.defaults.walkthroughModel.description": "La revisión con IA de tus cambios necesita salida estructurada y espacio para un diff completo, algo que un modelo pequeño y barato a menudo no ofrece. Los modelos que el catálogo declara incapaces de producir salida estructurada se ocultan aquí. Si no eliges nada, se usa el modelo pequeño.",
|
||||
"settings.openchamber.defaults.walkthroughModel.overrideModel": "Modelo del recorrido",
|
||||
"settings.openchamber.defaults.walkthroughModel.usesSmallModel": "Modelo pequeño",
|
||||
"settings.openchamber.defaults.field.showDeletionDialog": "Mostrar diálogo de eliminación",
|
||||
"settings.openchamber.defaults.field.openFilesPreviewAria": "Abrir archivos previsualizables en modo vista previa",
|
||||
"settings.openchamber.defaults.field.openFilesPreview": "Abrir archivos previsualizables en modo vista previa",
|
||||
|
||||
@@ -15,6 +15,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"common.loading": "Cargando...",
|
||||
"common.unavailable": "No disponible",
|
||||
"common.language.english": "Inglés",
|
||||
"common.language.german": "Alemán",
|
||||
"common.language.french": "Francés",
|
||||
"common.language.simplifiedChinese": "Chino (simplificado)",
|
||||
"common.language.traditionalChinese": "Chino (tradicional)",
|
||||
@@ -91,7 +92,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
"mobile.nav.changes": "Cambios",
|
||||
"mobile.nav.settings": "Ajustes",
|
||||
"mobile.surface.closeAria": "Cerrar",
|
||||
"mobile.header.openMenuAria": "Abrir menú",
|
||||
"mobile.header.openWorkspaceAria": "Abrir panel de trabajo",
|
||||
"mobile.header.openMetadataAria": "Abrir metadatos de la sesión",
|
||||
"mobile.header.metadata.context": "Contexto",
|
||||
@@ -835,12 +835,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
"gitView.operation.abort": "Abortar",
|
||||
"gitView.operation.continue": "Continuar",
|
||||
"gitView.operation.inProgressTitle": "{operation} en progreso",
|
||||
"gitView.operation.inProgressTitleManyConflicts": "{operation} en curso: {count} conflictos",
|
||||
"gitView.operation.inProgressTitleOneConflict": "{operation} en curso: {count} conflicto",
|
||||
"gitView.operation.merge": "Merge",
|
||||
"gitView.operation.mergeInProgressWithHead": "Haciendo merge de {head}",
|
||||
"gitView.operation.mergingMessage": "Haciendo merge: {message}",
|
||||
"gitView.operation.rebase": "Rebase",
|
||||
"gitView.operation.rebaseInProgress": "Rebase en progreso",
|
||||
"gitView.operation.rebasingOnto": "Haciendo rebase de {headName} sobre {onto}",
|
||||
"gitView.operation.readyToContinueHint": "Todos los conflictos están resueltos. Continúa para finalizar la operación.",
|
||||
"gitView.operation.resolveConflictsHint": "Resuelve los conflictos y luego continúa la operación.",
|
||||
"gitView.operation.resolveWithAi": "Resolver con IA",
|
||||
"gitView.stash.confirmButton": "Guardar cambios y continuar",
|
||||
@@ -1096,6 +1099,61 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextRail.surface.git.description": "Commits, ramas y pull requests",
|
||||
"contextRail.surface.terminal.description": "Terminal integrada",
|
||||
"contextRail.surface.diff.description": "Revisar cambios en curso",
|
||||
"contextPanel.mode.walkthrough": "Recorrido",
|
||||
"contextRail.surface.walkthrough.description": "Un recorrido por tus cambios guiado por IA",
|
||||
"walkthrough.scope.all": "Todo sin confirmar",
|
||||
"walkthrough.scope.group.workingTree": "Árbol de trabajo",
|
||||
"walkthrough.scope.group.committed": "Confirmado",
|
||||
"walkthrough.scope.staged": "Preparados",
|
||||
"walkthrough.scope.working": "Sin preparar",
|
||||
"walkthrough.scope.branch": "Esta rama",
|
||||
"walkthrough.scope.selectorAria": "Elegir qué revisar",
|
||||
"walkthrough.language.menuLabel": "Idioma del recorrido",
|
||||
"walkthrough.missing.language": "Aún no hay un recorrido en {requested}: se muestra el de {shown}.",
|
||||
"walkthrough.missing.model": "Aún no hay un recorrido de {model}: se muestra el último generado aquí.",
|
||||
"walkthrough.missing.languageAndModel": "Aún no hay un recorrido en este idioma con este modelo: se muestra el último generado aquí.",
|
||||
"walkthrough.language.selectorAria": "Elegir el idioma del recorrido",
|
||||
"walkthrough.scope.pullRequest": "PR n.º {number}",
|
||||
"walkthrough.action.generate": "Generar recorrido",
|
||||
"walkthrough.action.regenerate": "Regenerar",
|
||||
"walkthrough.action.cancel": "Cancelar",
|
||||
"walkthrough.action.next": "Paso siguiente",
|
||||
"walkthrough.action.previous": "Paso anterior",
|
||||
"walkthrough.action.refresh": "Actualizar",
|
||||
"walkthrough.action.open": "Recorrido con IA",
|
||||
"walkthrough.stage.collecting": "Reuniendo los cambios",
|
||||
"walkthrough.stage.asking": "Esperando al modelo",
|
||||
"walkthrough.stage.assembling": "Montando el recorrido",
|
||||
"walkthrough.empty.title": "Todavía no hay recorrido",
|
||||
"walkthrough.empty.description": "Crea una ruta de lectura guiada por estos cambios. Esto llama al modelo pequeño y consume tokens, así que solo se ejecuta cuando lo pides.",
|
||||
"walkthrough.stale.banner": "El código cambió después de esta revisión. Pasos desactualizados: {count}",
|
||||
"walkthrough.stop.staleAll": "Todo el código que describía este paso ha cambiado.",
|
||||
"walkthrough.stop.stalePartial": "Parte del código que describía este paso ha cambiado. Fragmentos perdidos: {count}",
|
||||
"walkthrough.stop.staleShort": "Desactualizado",
|
||||
"walkthrough.stop.noCode": "El código de este paso no está en el diff actual.",
|
||||
"walkthrough.uncovered.title": "Cambios que la revisión omitió: {count}",
|
||||
"walkthrough.uncovered.description": "La revisión los consideró rutinarios. Despliega para revisarlos tú mismo.",
|
||||
"walkthrough.toc.moreFiles": "Más archivos: {count}",
|
||||
"walkthrough.toc.uncovered": "Sin cubrir: {count}",
|
||||
"walkthrough.toc.resize": "Cambiar el ancho de la columna de contenidos",
|
||||
"walkthrough.importance.critical": "Crítico",
|
||||
"walkthrough.importance.context": "Contexto",
|
||||
"walkthrough.blocked.noModel.title": "No hay ningún modelo pequeño disponible",
|
||||
"walkthrough.blocked.noModel.description": "Inicia sesión en un proveedor de modelos para generar una revisión.",
|
||||
"walkthrough.blocked.emptyDiff.title": "Nada que revisar",
|
||||
"walkthrough.blocked.emptyDiff.description": "Todavía no hay cambios en este ámbito.",
|
||||
"walkthrough.blocked.contextTooSmall.title": "Este diff es demasiado grande para el modelo actual",
|
||||
"walkthrough.blocked.contextTooSmall.description": "{model} admite unos {available} mil caracteres y este diff necesita unos {required} mil. No se recorta nada: elige un modelo con más contexto.",
|
||||
"walkthrough.blocked.structuredOutput.title": "Este modelo no puede generar salida estructurada",
|
||||
"walkthrough.blocked.structuredOutput.description": "{model} no admite las respuestas estructuradas que necesita un recorrido.",
|
||||
"walkthrough.blocked.chooseModel": "Elige el modelo pequeño",
|
||||
"walkthrough.blocked.outputExhausted.title": "El modelo se quedó sin presupuesto de respuesta",
|
||||
"walkthrough.blocked.outputExhausted.description": "{model} gastó todo su margen de salida razonando y no devolvió nada. Los modelos de razonamiento suelen hacerlo con diffs grandes: prueba con un modelo que razone menos o revisa un ámbito más reducido.",
|
||||
"walkthrough.blocked.outputExhausted.descriptionUnknownModel": "El modelo pequeño gastó todo su margen de salida razonando y no devolvió nada. Los modelos de razonamiento suelen hacerlo con diffs grandes: prueba con un modelo que razone menos o revisa un ámbito más reducido.",
|
||||
"walkthrough.blocked.onlyGenerated.title": "Solo cambiaron archivos generados",
|
||||
"walkthrough.blocked.onlyGenerated.description": "Todos los cambios son archivos de bloqueo u otra salida generada por herramientas, que la revisión omite a propósito.",
|
||||
"walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "El modelo pequeño admite unos {available} mil caracteres y este diff necesita unos {required} mil. No se recorta nada: elige un modelo con más contexto.",
|
||||
"walkthrough.blocked.structuredOutput.descriptionUnknownModel": "El modelo pequeño no admite las respuestas estructuradas que necesita un recorrido.",
|
||||
"contextRail.surface.plan.description": "Ver el plan actual",
|
||||
"contextRail.surface.pr.description": "Crea, revisa y fusiona el pull request de la rama actual",
|
||||
"contextRail.surface.notes.description": "Notas, tareas y planes del proyecto",
|
||||
|
||||
@@ -1500,6 +1500,10 @@ export const settingsDict = {
|
||||
'settings.openchamber.defaults.smallModel.useDefault': 'Utiliser le petit modèle par défaut',
|
||||
'settings.openchamber.defaults.smallModel.useDefaultAria': 'Utiliser le petit modèle par défaut',
|
||||
'settings.openchamber.defaults.smallModel.overrideModel': 'Modèle de remplacement',
|
||||
'settings.openchamber.defaults.walkthroughModel.title': 'Modèle du parcours des modifications',
|
||||
'settings.openchamber.defaults.walkthroughModel.description': 'La revue par IA de vos modifications exige une sortie structurée et de la place pour un diff entier, ce qu’un petit modèle économique ne fournit pas toujours. Les modèles que le catalogue déclare incapables de sortie structurée sont masqués ici. Sans choix explicite, le petit modèle est utilisé.',
|
||||
'settings.openchamber.defaults.walkthroughModel.overrideModel': 'Modèle du parcours',
|
||||
'settings.openchamber.defaults.walkthroughModel.usesSmallModel': 'Petit modèle',
|
||||
'settings.openchamber.defaults.field.showDeletionDialog': 'Afficher la boîte de dialogue de suppression',
|
||||
'settings.openchamber.defaults.field.openFilesPreviewAria': 'Ouvrir les fichiers prévisualisables en mode aperçu',
|
||||
'settings.openchamber.defaults.field.openFilesPreview': 'Ouvrir les fichiers prévisualisables en mode aperçu',
|
||||
|
||||
@@ -14,6 +14,7 @@ export const dict = {
|
||||
'common.loading': 'Chargement...',
|
||||
'common.unavailable': 'Indisponible',
|
||||
'common.language.english': 'Anglais',
|
||||
'common.language.german': 'Allemand',
|
||||
'common.language.french': 'Français',
|
||||
'common.language.simplifiedChinese': 'Chinois (simplifié)',
|
||||
'common.language.traditionalChinese': 'Chinois (traditionnel)',
|
||||
@@ -661,12 +662,15 @@ export const dict = {
|
||||
'gitView.operation.abort': 'Avorter',
|
||||
'gitView.operation.continue': 'Continuer',
|
||||
'gitView.operation.inProgressTitle': '{operation} en cours',
|
||||
'gitView.operation.inProgressTitleManyConflicts': '{operation} en cours : {count} conflits',
|
||||
'gitView.operation.inProgressTitleOneConflict': '{operation} en cours : {count} conflit',
|
||||
'gitView.operation.merge': 'Fusionner',
|
||||
'gitView.operation.mergeInProgressWithHead': 'Fusion de {head}',
|
||||
'gitView.operation.mergingMessage': 'Fusion : {message}',
|
||||
'gitView.operation.rebase': 'Rebase',
|
||||
'gitView.operation.rebaseInProgress': 'Rebase en cours',
|
||||
'gitView.operation.rebasingOnto': 'Rebaser {headName} sur {onto}',
|
||||
'gitView.operation.readyToContinueHint': 'Tous les conflits sont résolus. Poursuivez pour terminer l’opération.',
|
||||
'gitView.operation.resolveConflictsHint': 'Résolvez les conflits, puis poursuivez l\'opération.',
|
||||
'gitView.operation.resolveWithAi': 'Résoudre avec l\'IA',
|
||||
'gitView.stash.confirmButton': 'Confirmer',
|
||||
@@ -919,6 +923,61 @@ export const dict = {
|
||||
'contextRail.surface.git.description': 'Commits, branches et pull requests',
|
||||
'contextRail.surface.terminal.description': 'Terminal intégré',
|
||||
'contextRail.surface.diff.description': 'Passer en revue les modifications',
|
||||
'contextPanel.mode.walkthrough': 'Parcours',
|
||||
'contextRail.surface.walkthrough.description': 'Un parcours de vos modifications guidé par l’IA',
|
||||
'walkthrough.scope.all': 'Tout non validé',
|
||||
'walkthrough.scope.group.workingTree': 'Copie de travail',
|
||||
'walkthrough.scope.group.committed': 'Validé',
|
||||
'walkthrough.scope.staged': 'Indexées',
|
||||
'walkthrough.scope.working': 'Non indexées',
|
||||
'walkthrough.scope.branch': 'Cette branche',
|
||||
'walkthrough.scope.selectorAria': 'Choisir ce qui est examiné',
|
||||
'walkthrough.language.menuLabel': 'Langue du parcours',
|
||||
'walkthrough.missing.language': 'Pas encore de parcours en {requested} — voici celui en {shown}.',
|
||||
'walkthrough.missing.model': 'Pas encore de parcours généré par {model} — voici le dernier généré ici.',
|
||||
'walkthrough.missing.languageAndModel': 'Pas encore de parcours dans cette langue avec ce modèle — voici le dernier généré ici.',
|
||||
'walkthrough.language.selectorAria': 'Choisir la langue du parcours',
|
||||
'walkthrough.scope.pullRequest': 'PR n° {number}',
|
||||
'walkthrough.action.generate': 'Générer le parcours',
|
||||
'walkthrough.action.regenerate': 'Régénérer',
|
||||
'walkthrough.action.cancel': 'Annuler',
|
||||
'walkthrough.action.next': 'Étape suivante',
|
||||
'walkthrough.action.previous': 'Étape précédente',
|
||||
'walkthrough.action.refresh': 'Actualiser',
|
||||
'walkthrough.action.open': 'Parcours',
|
||||
'walkthrough.stage.collecting': 'Collecte des modifications',
|
||||
'walkthrough.stage.asking': 'En attente du modèle',
|
||||
'walkthrough.stage.assembling': 'Assemblage du parcours',
|
||||
'walkthrough.empty.title': 'Aucun parcours pour l’instant',
|
||||
'walkthrough.empty.description': 'Construisez un parcours de lecture guidé à travers ces modifications. Cela appelle le petit modèle et consomme des jetons, donc rien ne se lance sans votre demande.',
|
||||
'walkthrough.stale.banner': 'Le code a changé après cette revue. Étapes obsolètes : {count}',
|
||||
'walkthrough.stop.staleAll': 'Tout le code décrit par cette étape a changé.',
|
||||
'walkthrough.stop.stalePartial': 'Une partie du code décrit par cette étape a changé. Fragments manquants : {count}',
|
||||
'walkthrough.stop.staleShort': 'Obsolète',
|
||||
'walkthrough.stop.noCode': 'Le code de cette étape est absent du diff actuel.',
|
||||
'walkthrough.uncovered.title': 'Modifications non traitées par la revue : {count}',
|
||||
'walkthrough.uncovered.description': 'La revue les a jugées courantes. Dépliez pour les vérifier vous-même.',
|
||||
'walkthrough.toc.moreFiles': 'Autres fichiers : {count}',
|
||||
'walkthrough.toc.uncovered': 'Non traité : {count}',
|
||||
'walkthrough.toc.resize': 'Redimensionner la colonne du sommaire',
|
||||
'walkthrough.importance.critical': 'Critique',
|
||||
'walkthrough.importance.context': 'Contexte',
|
||||
'walkthrough.blocked.noModel.title': 'Aucun petit modèle disponible',
|
||||
'walkthrough.blocked.noModel.description': 'Connectez-vous à un fournisseur de modèles pour générer une revue.',
|
||||
'walkthrough.blocked.emptyDiff.title': 'Rien à examiner',
|
||||
'walkthrough.blocked.emptyDiff.description': 'Il n’y a encore aucune modification dans cette portée.',
|
||||
'walkthrough.blocked.contextTooSmall.title': 'Ce diff est trop volumineux pour le modèle actuel',
|
||||
'walkthrough.blocked.contextTooSmall.description': '{model} accepte environ {available} k caractères et ce diff en demande environ {required} k. Rien n’est tronqué : choisissez un modèle au contexte plus large.',
|
||||
'walkthrough.blocked.structuredOutput.title': 'Ce modèle ne produit pas de sortie structurée',
|
||||
'walkthrough.blocked.structuredOutput.description': '{model} ne prend pas en charge les réponses structurées nécessaires à un parcours.',
|
||||
'walkthrough.blocked.chooseModel': 'Choisissez le petit modèle',
|
||||
'walkthrough.blocked.outputExhausted.title': 'Le modèle a épuisé son budget de réponse',
|
||||
'walkthrough.blocked.outputExhausted.description': '{model} a dépensé toute sa marge de sortie en raisonnement et n’a rien renvoyé. Les modèles de raisonnement le font souvent sur de gros diffs : essayez un modèle qui réfléchit moins, ou une portée plus étroite.',
|
||||
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'Le petit modèle a dépensé toute sa marge de sortie en raisonnement et n’a rien renvoyé. Les modèles de raisonnement le font souvent sur de gros diffs : essayez un modèle qui réfléchit moins, ou une portée plus étroite.',
|
||||
'walkthrough.blocked.onlyGenerated.title': 'Seuls des fichiers générés ont changé',
|
||||
'walkthrough.blocked.onlyGenerated.description': 'Toutes les modifications concernent des fichiers de verrouillage ou d’autres sorties générées, que la revue ignore délibérément.',
|
||||
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'Le petit modèle accepte environ {available} k caractères et ce diff en demande environ {required} k. Rien n’est tronqué : choisissez un modèle au contexte plus large.',
|
||||
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'Le petit modèle ne prend pas en charge les réponses structurées nécessaires à un parcours.',
|
||||
'contextRail.surface.plan.description': 'Voir le plan actuel',
|
||||
'contextRail.surface.pr.description': 'Créer, relire et fusionner la pull request de la branche actuelle',
|
||||
'contextRail.surface.notes.description': 'Notes, tâches et plans du projet',
|
||||
@@ -2649,7 +2708,6 @@ export const dict = {
|
||||
'mobile.nav.changes': 'Modifications',
|
||||
'mobile.nav.settings': 'Paramètres',
|
||||
'mobile.surface.closeAria': 'Fermer',
|
||||
'mobile.header.openMenuAria': 'Ouvrir le menu',
|
||||
'mobile.header.openWorkspaceAria': 'Ouvrir le panneau de travail',
|
||||
'mobile.header.openMetadataAria': 'Ouvrir les métadonnées de session',
|
||||
'mobile.header.metadata.context': 'Contexte',
|
||||
|
||||
@@ -1612,6 +1612,10 @@ export const settingsDict = {
|
||||
'settings.openchamber.defaults.smallModel.useDefault': 'デフォルトの小型モデルを使用',
|
||||
'settings.openchamber.defaults.smallModel.useDefaultAria': 'デフォルトの小型モデルを使用',
|
||||
'settings.openchamber.defaults.smallModel.overrideModel': '上書きモデル',
|
||||
'settings.openchamber.defaults.walkthroughModel.title': '変更ウォークスルーのモデル',
|
||||
'settings.openchamber.defaults.walkthroughModel.description': '変更の AI レビューには構造化出力と差分全体が収まる余裕が必要で、安価なスモールモデルでは足りないことがあります。カタログが構造化出力に非対応と示すモデルは、この一覧から除外されます。 未選択のままならスモールモデルが使われます。',
|
||||
'settings.openchamber.defaults.walkthroughModel.overrideModel': 'ウォークスルーのモデル',
|
||||
'settings.openchamber.defaults.walkthroughModel.usesSmallModel': 'スモールモデル',
|
||||
'settings.openchamber.defaults.field.showDeletionDialog': '削除ダイアログを表示',
|
||||
'settings.openchamber.defaults.field.openFilesPreviewAria': 'プレビュー可能なファイルをプレビューモードで開く',
|
||||
'settings.openchamber.defaults.field.openFilesPreview': 'プレビュー可能なファイルをプレビューモードで開く',
|
||||
|
||||
@@ -15,6 +15,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'common.loading': '読み込み中...',
|
||||
'common.unavailable': '利用できません',
|
||||
'common.language.english': '英語',
|
||||
'common.language.german': 'ドイツ語',
|
||||
'common.language.french': 'フランス語',
|
||||
'common.language.simplifiedChinese': '中国語(簡体字)',
|
||||
'common.language.traditionalChinese': '中国語(繁体字)',
|
||||
@@ -92,7 +93,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
'mobile.instances.confirmDeleteAria': '{label} の削除を確定',
|
||||
'mobile.instances.cancelDeleteAria': '{label} を残す',
|
||||
'mobile.surface.closeAria': '閉じる',
|
||||
'mobile.header.openMenuAria': 'メニューを開く',
|
||||
'mobile.header.openWorkspaceAria': 'ワークスペースパネルを開く',
|
||||
'mobile.header.openMetadataAria': 'セッションメタデータを開く',
|
||||
'mobile.header.metadata.context': 'コンテキスト',
|
||||
@@ -831,12 +831,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.operation.abort': '中止',
|
||||
'gitView.operation.continue': '続行',
|
||||
'gitView.operation.inProgressTitle': '{operation}進行中',
|
||||
'gitView.operation.inProgressTitleManyConflicts': '{operation} 進行中: 競合 {count} 件',
|
||||
'gitView.operation.inProgressTitleOneConflict': '{operation} 進行中: 競合 {count} 件',
|
||||
'gitView.operation.merge': 'マージ',
|
||||
'gitView.operation.mergeInProgressWithHead': '{head}をマージ中',
|
||||
'gitView.operation.mergingMessage': 'マージ中: {message}',
|
||||
'gitView.operation.rebase': 'リベース',
|
||||
'gitView.operation.rebaseInProgress': 'リベース進行中',
|
||||
'gitView.operation.rebasingOnto': '{headName}を{onto}にリベース中',
|
||||
'gitView.operation.readyToContinueHint': 'すべての競合が解決されました。続行して操作を完了してください。',
|
||||
'gitView.operation.resolveConflictsHint': '競合を解決してから操作を続行してください。',
|
||||
'gitView.operation.resolveWithAi': 'AIで解決',
|
||||
'gitView.stash.confirmButton': '{operation}',
|
||||
@@ -1092,6 +1095,61 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextRail.surface.git.description': 'コミット・ブランチ・プルリクエスト',
|
||||
'contextRail.surface.terminal.description': '内蔵ターミナル',
|
||||
'contextRail.surface.diff.description': '作業中の変更をレビュー',
|
||||
'contextPanel.mode.walkthrough': 'ウォークスルー',
|
||||
'contextRail.surface.walkthrough.description': 'AI による変更のガイド付きウォークスルー',
|
||||
'walkthrough.scope.all': '未コミットすべて',
|
||||
'walkthrough.scope.group.workingTree': '作業ツリー',
|
||||
'walkthrough.scope.group.committed': 'コミット済み',
|
||||
'walkthrough.scope.staged': 'ステージ済み',
|
||||
'walkthrough.scope.working': '未ステージ',
|
||||
'walkthrough.scope.branch': 'このブランチ',
|
||||
'walkthrough.scope.selectorAria': 'レビュー対象を選択',
|
||||
'walkthrough.language.menuLabel': 'ウォークスルーの言語',
|
||||
'walkthrough.missing.language': '{requested}のウォークスルーはまだありません。{shown}のものを表示しています。',
|
||||
'walkthrough.missing.model': '{model} が生成したウォークスルーはまだありません。ここで最後に生成されたものを表示しています。',
|
||||
'walkthrough.missing.languageAndModel': 'この言語・このモデルのウォークスルーはまだありません。ここで最後に生成されたものを表示しています。',
|
||||
'walkthrough.language.selectorAria': 'ウォークスルーの言語を選択',
|
||||
'walkthrough.scope.pullRequest': 'PR #{number}',
|
||||
'walkthrough.action.generate': 'ウォークスルーを生成',
|
||||
'walkthrough.action.regenerate': '再生成',
|
||||
'walkthrough.action.cancel': 'キャンセル',
|
||||
'walkthrough.action.next': '次のステップ',
|
||||
'walkthrough.action.previous': '前のステップ',
|
||||
'walkthrough.action.refresh': '更新',
|
||||
'walkthrough.action.open': 'ウォークスルー',
|
||||
'walkthrough.stage.collecting': '変更を集めています',
|
||||
'walkthrough.stage.asking': 'モデルの応答を待っています',
|
||||
'walkthrough.stage.assembling': 'ウォークスルーを組み立てています',
|
||||
'walkthrough.empty.title': 'ウォークスルーはまだありません',
|
||||
'walkthrough.empty.description': 'この変更を読み進めるためのガイドを作成します。スモールモデルを呼び出してトークンを消費するため、実行は明示的な操作時のみです。',
|
||||
'walkthrough.stale.banner': 'このレビュー後にコードが変更されました。古くなったステップ: {count}',
|
||||
'walkthrough.stop.staleAll': 'このステップが説明していたコードはすべて変更されました。',
|
||||
'walkthrough.stop.stalePartial': 'このステップが説明していたコードの一部が変更されました。失われた箇所: {count}',
|
||||
'walkthrough.stop.staleShort': '古い内容',
|
||||
'walkthrough.stop.noCode': 'このステップのコードは現在の差分にありません。',
|
||||
'walkthrough.uncovered.title': 'レビューが扱わなかった変更: {count}',
|
||||
'walkthrough.uncovered.description': 'レビューは定型的と判断しました。展開してご自身で確認できます。',
|
||||
'walkthrough.toc.moreFiles': 'その他のファイル: {count}',
|
||||
'walkthrough.toc.uncovered': '未対応: {count}',
|
||||
'walkthrough.toc.resize': '目次の列幅を変更',
|
||||
'walkthrough.importance.critical': '重要',
|
||||
'walkthrough.importance.context': '補足',
|
||||
'walkthrough.blocked.noModel.title': '利用できるスモールモデルがありません',
|
||||
'walkthrough.blocked.noModel.description': 'レビューを生成するにはモデルプロバイダーにサインインしてください。',
|
||||
'walkthrough.blocked.emptyDiff.title': 'レビュー対象がありません',
|
||||
'walkthrough.blocked.emptyDiff.description': 'この範囲にはまだ変更がありません。',
|
||||
'walkthrough.blocked.contextTooSmall.title': 'この差分は現在のモデルには大きすぎます',
|
||||
'walkthrough.blocked.contextTooSmall.description': '{model} が扱えるのは約 {available} 千文字ですが、この差分には約 {required} 千文字が必要です。切り詰めは行いません。コンテキストの大きいモデルを選んでください。',
|
||||
'walkthrough.blocked.structuredOutput.title': 'このモデルは構造化出力に対応していません',
|
||||
'walkthrough.blocked.structuredOutput.description': '{model} はウォークスルーに必要な構造化応答をサポートしていません。',
|
||||
'walkthrough.blocked.chooseModel': 'スモールモデルを選択',
|
||||
'walkthrough.blocked.outputExhausted.title': 'モデルの出力枠が尽きました',
|
||||
'walkthrough.blocked.outputExhausted.description': '{model} は出力枠をすべて推論に使い、回答を返しませんでした。推論モデルは大きな差分でよくこうなります。推論の少ないモデルを選ぶか、対象範囲を絞ってください。',
|
||||
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'スモールモデルは出力枠をすべて推論に使い、回答を返しませんでした。推論モデルは大きな差分でよくこうなります。推論の少ないモデルを選ぶか、対象範囲を絞ってください。',
|
||||
'walkthrough.blocked.onlyGenerated.title': '生成ファイルのみが変更されています',
|
||||
'walkthrough.blocked.onlyGenerated.description': 'ここでの変更はロックファイルなどツールが生成した出力だけで、レビューは意図的にこれらを対象外にしています。',
|
||||
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'スモールモデルが扱えるのは約 {available} 千文字ですが、この差分には約 {required} 千文字が必要です。切り詰めは行いません。コンテキストの大きいモデルを選んでください。',
|
||||
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'スモールモデルはウォークスルーに必要な構造化応答をサポートしていません。',
|
||||
'contextRail.surface.plan.description': '現在のプランを表示',
|
||||
'contextRail.surface.pr.description': '現在のブランチのプルリクエストを作成・確認・マージ',
|
||||
'contextRail.surface.notes.description': 'プロジェクトのノート・ToDo・プラン',
|
||||
|
||||
@@ -1579,6 +1579,10 @@ export const settingsDict = {
|
||||
'settings.openchamber.defaults.smallModel.useDefault': '기본 소형 모델 사용',
|
||||
'settings.openchamber.defaults.smallModel.useDefaultAria': '기본 소형 모델 사용',
|
||||
'settings.openchamber.defaults.smallModel.overrideModel': '재정의 모델',
|
||||
'settings.openchamber.defaults.walkthroughModel.title': '변경 워크스루 모델',
|
||||
'settings.openchamber.defaults.walkthroughModel.description': '변경 사항의 AI 리뷰에는 구조화된 출력과 전체 diff를 담을 여유가 필요한데, 저렴한 스몰 모델로는 부족할 때가 많습니다. 카탈로그가 구조화된 출력을 지원하지 않는다고 알리는 모델은 이 목록에서 숨겨집니다. 선택하지 않으면 스몰 모델이 사용됩니다.',
|
||||
'settings.openchamber.defaults.walkthroughModel.overrideModel': '워크스루 모델',
|
||||
'settings.openchamber.defaults.walkthroughModel.usesSmallModel': '스몰 모델',
|
||||
'settings.openchamber.defaults.field.showDeletionDialog': '삭제 확인 대화상자 표시',
|
||||
'settings.openchamber.defaults.field.openFilesPreviewAria': '미리보기 가능한 파일을 미리보기 모드로 열기',
|
||||
'settings.openchamber.defaults.field.openFilesPreview': '미리보기 가능한 파일을 미리보기 모드로 열기',
|
||||
|
||||
@@ -15,6 +15,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'common.loading': '로딩 중...',
|
||||
'common.unavailable': '사용할 수 없음',
|
||||
'common.language.english': '영어',
|
||||
'common.language.german': '독일어',
|
||||
'common.language.french': '프랑스어',
|
||||
'common.language.simplifiedChinese': '중국어(간체)',
|
||||
'common.language.traditionalChinese': '중국어(번체)',
|
||||
@@ -91,7 +92,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
'mobile.nav.changes': '변경사항',
|
||||
'mobile.nav.settings': '설정',
|
||||
'mobile.surface.closeAria': '닫기',
|
||||
'mobile.header.openMenuAria': '메뉴 열기',
|
||||
'mobile.header.openWorkspaceAria': '작업 공간 패널 열기',
|
||||
'mobile.header.openMetadataAria': '세션 메타데이터 열기',
|
||||
'mobile.header.metadata.context': '컨텍스트',
|
||||
@@ -835,12 +835,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.operation.abort': '중단',
|
||||
'gitView.operation.continue': '계속',
|
||||
'gitView.operation.inProgressTitle': '{operation} 진행 중',
|
||||
'gitView.operation.inProgressTitleManyConflicts': '{operation} 진행 중: 충돌 {count}개',
|
||||
'gitView.operation.inProgressTitleOneConflict': '{operation} 진행 중: 충돌 {count}개',
|
||||
'gitView.operation.merge': '병합',
|
||||
'gitView.operation.mergeInProgressWithHead': '{head} 병합 진행 중',
|
||||
'gitView.operation.mergingMessage': '병합 중: {message}',
|
||||
'gitView.operation.rebase': '리베이스',
|
||||
'gitView.operation.rebaseInProgress': '리베이스 진행 중',
|
||||
'gitView.operation.rebasingOnto': '{headName}을(를) {onto} 위로 리베이스 중',
|
||||
'gitView.operation.readyToContinueHint': '모든 충돌이 해결되었습니다. 계속하여 작업을 완료하세요.',
|
||||
'gitView.operation.resolveConflictsHint': '충돌을 해결한 뒤 작업을 계속하세요.',
|
||||
'gitView.operation.resolveWithAi': 'AI로 해결',
|
||||
'gitView.stash.confirmButton': '확인',
|
||||
@@ -1096,6 +1099,61 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextRail.surface.git.description': '커밋, 브랜치, 풀 리퀘스트',
|
||||
'contextRail.surface.terminal.description': '내장 터미널',
|
||||
'contextRail.surface.diff.description': '작업 중인 변경 사항 검토',
|
||||
'contextPanel.mode.walkthrough': '워크스루',
|
||||
'contextRail.surface.walkthrough.description': 'AI가 안내하는 변경 사항 워크스루',
|
||||
'walkthrough.scope.all': '커밋되지 않은 전체',
|
||||
'walkthrough.scope.group.workingTree': '작업 트리',
|
||||
'walkthrough.scope.group.committed': '커밋됨',
|
||||
'walkthrough.scope.staged': '스테이지됨',
|
||||
'walkthrough.scope.working': '스테이지 안 됨',
|
||||
'walkthrough.scope.branch': '이 브랜치',
|
||||
'walkthrough.scope.selectorAria': '리뷰 대상 선택',
|
||||
'walkthrough.language.menuLabel': '워크스루 언어',
|
||||
'walkthrough.missing.language': '{requested} 워크스루가 아직 없어 {shown} 워크스루를 표시합니다.',
|
||||
'walkthrough.missing.model': '{model}(으)로 생성한 워크스루가 아직 없어 마지막으로 생성된 것을 표시합니다.',
|
||||
'walkthrough.missing.languageAndModel': '이 언어와 이 모델로 생성한 워크스루가 아직 없어 마지막으로 생성된 것을 표시합니다.',
|
||||
'walkthrough.language.selectorAria': '워크스루 언어 선택',
|
||||
'walkthrough.scope.pullRequest': 'PR #{number}',
|
||||
'walkthrough.action.generate': '워크스루 생성',
|
||||
'walkthrough.action.regenerate': '다시 생성',
|
||||
'walkthrough.action.cancel': '취소',
|
||||
'walkthrough.action.next': '다음 단계',
|
||||
'walkthrough.action.previous': '이전 단계',
|
||||
'walkthrough.action.refresh': '새로 고침',
|
||||
'walkthrough.action.open': '워크스루',
|
||||
'walkthrough.stage.collecting': '변경 사항을 모으는 중',
|
||||
'walkthrough.stage.asking': '모델을 기다리는 중',
|
||||
'walkthrough.stage.assembling': '워크스루를 조립하는 중',
|
||||
'walkthrough.empty.title': '아직 워크스루가 없습니다',
|
||||
'walkthrough.empty.description': '이 변경 사항을 따라 읽을 수 있는 경로를 만듭니다. 스몰 모델을 호출해 토큰을 사용하므로 요청할 때만 실행됩니다.',
|
||||
'walkthrough.stale.banner': '이 리뷰 이후에 코드가 바뀌었습니다. 오래된 단계: {count}',
|
||||
'walkthrough.stop.staleAll': '이 단계가 설명하던 코드가 모두 바뀌었습니다.',
|
||||
'walkthrough.stop.stalePartial': '이 단계가 설명하던 코드 일부가 바뀌었습니다. 사라진 부분: {count}',
|
||||
'walkthrough.stop.staleShort': '오래됨',
|
||||
'walkthrough.stop.noCode': '이 단계의 코드는 현재 diff에 없습니다.',
|
||||
'walkthrough.uncovered.title': '리뷰가 다루지 않은 변경: {count}',
|
||||
'walkthrough.uncovered.description': '리뷰가 일상적인 변경으로 판단했습니다. 펼쳐서 직접 확인하세요.',
|
||||
'walkthrough.toc.moreFiles': '다른 파일: {count}',
|
||||
'walkthrough.toc.uncovered': '미포함: {count}',
|
||||
'walkthrough.toc.resize': '목차 열 너비 조절',
|
||||
'walkthrough.importance.critical': '중요',
|
||||
'walkthrough.importance.context': '참고',
|
||||
'walkthrough.blocked.noModel.title': '사용할 수 있는 스몰 모델이 없습니다',
|
||||
'walkthrough.blocked.noModel.description': '리뷰를 생성하려면 모델 제공자에 로그인하세요.',
|
||||
'walkthrough.blocked.emptyDiff.title': '리뷰할 내용이 없습니다',
|
||||
'walkthrough.blocked.emptyDiff.description': '이 범위에는 아직 변경 사항이 없습니다.',
|
||||
'walkthrough.blocked.contextTooSmall.title': '이 diff는 현재 모델에 너무 큽니다',
|
||||
'walkthrough.blocked.contextTooSmall.description': '{model}은 약 {available}천 자를 담을 수 있는데 이 diff에는 약 {required}천 자가 필요합니다. 잘라내지 않으니 컨텍스트가 더 큰 모델을 선택하세요.',
|
||||
'walkthrough.blocked.structuredOutput.title': '이 모델은 구조화된 출력을 지원하지 않습니다',
|
||||
'walkthrough.blocked.structuredOutput.description': '{model}은 워크스루에 필요한 구조화된 응답을 지원하지 않습니다.',
|
||||
'walkthrough.blocked.chooseModel': '스몰 모델 선택',
|
||||
'walkthrough.blocked.outputExhausted.title': '모델의 응답 예산이 바닥났습니다',
|
||||
'walkthrough.blocked.outputExhausted.description': '{model}이(가) 출력 예산을 모두 추론에 쓰고 아무것도 반환하지 않았습니다. 추론 모델은 큰 diff에서 흔히 이렇게 됩니다. 덜 추론하는 모델을 고르거나 범위를 좁혀 보세요.',
|
||||
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '스몰 모델이 출력 예산을 모두 추론에 쓰고 아무것도 반환하지 않았습니다. 추론 모델은 큰 diff에서 흔히 이렇게 됩니다. 덜 추론하는 모델을 고르거나 범위를 좁혀 보세요.',
|
||||
'walkthrough.blocked.onlyGenerated.title': '생성된 파일만 변경되었습니다',
|
||||
'walkthrough.blocked.onlyGenerated.description': '여기의 변경은 모두 잠금 파일이거나 도구가 만든 산출물이며, 리뷰는 이런 파일을 의도적으로 건너뜁니다.',
|
||||
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '스몰 모델은 약 {available}천 자를 담을 수 있는데 이 diff에는 약 {required}천 자가 필요합니다. 잘라내지 않으니 컨텍스트가 더 큰 모델을 선택하세요.',
|
||||
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '스몰 모델은 워크스루에 필요한 구조화된 응답을 지원하지 않습니다.',
|
||||
'contextRail.surface.plan.description': '현재 계획 보기',
|
||||
'contextRail.surface.pr.description': '현재 브랜치의 풀 리퀘스트를 생성, 검토, 병합',
|
||||
'contextRail.surface.notes.description': '프로젝트의 노트, 할 일, 계획',
|
||||
|
||||
@@ -739,6 +739,10 @@ export const settingsDict = {
|
||||
'settings.openchamber.defaults.smallModel.useDefault': 'Używaj domyślnego małego modelu',
|
||||
'settings.openchamber.defaults.smallModel.useDefaultAria': 'Używaj domyślnego małego modelu',
|
||||
'settings.openchamber.defaults.smallModel.overrideModel': 'Model zastępczy',
|
||||
'settings.openchamber.defaults.walkthroughModel.title': 'Model przewodnika po zmianach',
|
||||
'settings.openchamber.defaults.walkthroughModel.description': 'Przegląd zmian przez AI wymaga ustrukturyzowanej odpowiedzi i miejsca na całe różnice, czego tani mały model często nie zapewnia. Modele, które katalog zgłasza jako niezdolne do ustrukturyzowanej odpowiedzi, są tu ukryte. Bez wyboru używany jest mały model.',
|
||||
'settings.openchamber.defaults.walkthroughModel.overrideModel': 'Model przewodnika',
|
||||
'settings.openchamber.defaults.walkthroughModel.usesSmallModel': 'Mały model',
|
||||
'settings.openchamber.defaults.field.thinkingPlaceholder': 'Myślenie',
|
||||
'settings.openchamber.defaults.option.default': 'Domyślne',
|
||||
'settings.openchamber.defaults.option.defaultLowercase': 'domyślne',
|
||||
|
||||
@@ -16,6 +16,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'common.loading': 'Ładowanie...',
|
||||
'common.unavailable': 'Niedostępne',
|
||||
'common.language.english': 'Angielski',
|
||||
'common.language.german': 'Niemiecki',
|
||||
'common.language.french': 'Francuski',
|
||||
'common.language.simplifiedChinese': 'Chiński (Uproszczony)',
|
||||
'common.language.traditionalChinese': 'Chiński (Tradycyjny)',
|
||||
@@ -92,7 +93,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
'mobile.nav.changes': 'Zmiany',
|
||||
'mobile.nav.settings': 'Ustawienia',
|
||||
'mobile.surface.closeAria': 'Zamknij',
|
||||
'mobile.header.openMenuAria': 'Otwórz menu',
|
||||
'mobile.header.openWorkspaceAria': 'Otwórz panel roboczy',
|
||||
'mobile.header.openMetadataAria': 'Otwórz metadane sesji',
|
||||
'mobile.header.metadata.context': 'Kontekst',
|
||||
@@ -1411,6 +1411,61 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextRail.surface.git.description': 'Commity, gałęzie i pull requesty',
|
||||
'contextRail.surface.terminal.description': 'Wbudowany terminal',
|
||||
'contextRail.surface.diff.description': 'Przeglądaj bieżące zmiany',
|
||||
'contextPanel.mode.walkthrough': 'Przewodnik',
|
||||
'contextRail.surface.walkthrough.description': 'Przewodnik po zmianach prowadzony przez AI',
|
||||
'walkthrough.scope.all': 'Wszystko niezatwierdzone',
|
||||
'walkthrough.scope.group.workingTree': 'Drzewo robocze',
|
||||
'walkthrough.scope.group.committed': 'Zatwierdzone',
|
||||
'walkthrough.scope.staged': 'W poczekalni',
|
||||
'walkthrough.scope.working': 'Poza poczekalnią',
|
||||
'walkthrough.scope.branch': 'Ta gałąź',
|
||||
'walkthrough.scope.selectorAria': 'Wybierz, co przejrzeć',
|
||||
'walkthrough.language.menuLabel': 'Język przewodnika',
|
||||
'walkthrough.missing.language': 'Nie ma jeszcze przewodnika w języku {requested} — pokazujemy ten w języku {shown}.',
|
||||
'walkthrough.missing.model': 'Nie ma jeszcze przewodnika od modelu {model} — pokazujemy ostatni wygenerowany tutaj.',
|
||||
'walkthrough.missing.languageAndModel': 'Nie ma jeszcze przewodnika w tym języku od tego modelu — pokazujemy ostatni wygenerowany tutaj.',
|
||||
'walkthrough.language.selectorAria': 'Wybierz język przewodnika',
|
||||
'walkthrough.scope.pullRequest': 'PR #{number}',
|
||||
'walkthrough.action.generate': 'Wygeneruj przewodnik',
|
||||
'walkthrough.action.regenerate': 'Wygeneruj ponownie',
|
||||
'walkthrough.action.cancel': 'Anuluj',
|
||||
'walkthrough.action.next': 'Następny krok',
|
||||
'walkthrough.action.previous': 'Poprzedni krok',
|
||||
'walkthrough.action.refresh': 'Odśwież',
|
||||
'walkthrough.action.open': 'Przewodnik',
|
||||
'walkthrough.stage.collecting': 'Zbieram zmiany',
|
||||
'walkthrough.stage.asking': 'Czekam na model',
|
||||
'walkthrough.stage.assembling': 'Składam przewodnik',
|
||||
'walkthrough.empty.title': 'Nie ma jeszcze przewodnika',
|
||||
'walkthrough.empty.description': 'Zbuduj prowadzoną ścieżkę czytania tych zmian. Wywołuje to mały model i zużywa tokeny, więc uruchamia się tylko na Twoje żądanie.',
|
||||
'walkthrough.stale.banner': 'Kod zmienił się po tym przeglądzie. Nieaktualne kroki: {count}',
|
||||
'walkthrough.stop.staleAll': 'Cały kod opisywany przez ten krok uległ zmianie.',
|
||||
'walkthrough.stop.stalePartial': 'Część kodu opisywanego przez ten krok uległa zmianie. Brakujące fragmenty: {count}',
|
||||
'walkthrough.stop.staleShort': 'Nieaktualne',
|
||||
'walkthrough.stop.noCode': 'Kodu z tego kroku nie ma w bieżących różnicach.',
|
||||
'walkthrough.uncovered.title': 'Zmiany pominięte w przeglądzie: {count}',
|
||||
'walkthrough.uncovered.description': 'Przegląd uznał je za rutynowe. Rozwiń, aby sprawdzić je samodzielnie.',
|
||||
'walkthrough.toc.moreFiles': 'Więcej plików: {count}',
|
||||
'walkthrough.toc.uncovered': 'Nieuwzględnione: {count}',
|
||||
'walkthrough.toc.resize': 'Zmień szerokość kolumny spisu treści',
|
||||
'walkthrough.importance.critical': 'Krytyczne',
|
||||
'walkthrough.importance.context': 'Kontekst',
|
||||
'walkthrough.blocked.noModel.title': 'Brak dostępnego małego modelu',
|
||||
'walkthrough.blocked.noModel.description': 'Zaloguj się u dostawcy modeli, aby wygenerować przegląd.',
|
||||
'walkthrough.blocked.emptyDiff.title': 'Nie ma czego przeglądać',
|
||||
'walkthrough.blocked.emptyDiff.description': 'W tym zakresie nie ma jeszcze zmian.',
|
||||
'walkthrough.blocked.contextTooSmall.title': 'Te różnice są za duże dla bieżącego modelu',
|
||||
'walkthrough.blocked.contextTooSmall.description': '{model} mieści około {available} tys. znaków, a te różnice potrzebują około {required} tys. Nic nie jest obcinane — wybierz model z większym kontekstem.',
|
||||
'walkthrough.blocked.structuredOutput.title': 'Ten model nie tworzy ustrukturyzowanych odpowiedzi',
|
||||
'walkthrough.blocked.structuredOutput.description': '{model} nie obsługuje ustrukturyzowanych odpowiedzi wymaganych przez przewodnik.',
|
||||
'walkthrough.blocked.chooseModel': 'Wybierz mały model',
|
||||
'walkthrough.blocked.outputExhausted.title': 'Modelowi zabrakło budżetu na odpowiedź',
|
||||
'walkthrough.blocked.outputExhausted.description': '{model} zużył cały limit wyjścia na rozumowanie i nic nie zwrócił. Modele rozumujące często tak robią przy dużych różnicach — pomoże model mniej „myślący” albo węższy zakres przeglądu.',
|
||||
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'Mały model zużył cały limit wyjścia na rozumowanie i nic nie zwrócił. Modele rozumujące często tak robią przy dużych różnicach — pomoże model mniej „myślący” albo węższy zakres przeglądu.',
|
||||
'walkthrough.blocked.onlyGenerated.title': 'Zmieniły się tylko pliki generowane',
|
||||
'walkthrough.blocked.onlyGenerated.description': 'Wszystkie zmiany to pliki blokad lub inne wyniki pracy narzędzi, które przegląd celowo pomija.',
|
||||
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'Mały model mieści około {available} tys. znaków, a te różnice potrzebują około {required} tys. Nic nie jest obcinane — wybierz model z większym kontekstem.',
|
||||
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'Mały model nie obsługuje ustrukturyzowanych odpowiedzi wymaganych przez przewodnik.',
|
||||
'contextRail.surface.plan.description': 'Zobacz bieżący plan',
|
||||
'contextRail.surface.pr.description': 'Twórz, przeglądaj i scalaj pull request bieżącej gałęzi',
|
||||
'contextRail.surface.notes.description': 'Notatki, zadania i plany projektu',
|
||||
@@ -1974,12 +2029,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.operation.abort': 'Przerwij',
|
||||
'gitView.operation.continue': 'Kontynuuj',
|
||||
'gitView.operation.inProgressTitle': 'Operacja w toku',
|
||||
'gitView.operation.inProgressTitleManyConflicts': '{operation} w toku: {count} konfliktów',
|
||||
'gitView.operation.inProgressTitleOneConflict': '{operation} w toku: {count} konflikt',
|
||||
'gitView.operation.merge': 'Scal',
|
||||
'gitView.operation.mergeInProgressWithHead': 'Trwa scalanie z HEAD',
|
||||
'gitView.operation.mergingMessage': 'Trwa scalanie',
|
||||
'gitView.operation.rebase': 'Rebase',
|
||||
'gitView.operation.rebaseInProgress': 'Trwa rebase',
|
||||
'gitView.operation.rebasingOnto': 'Wykonywanie rebase na',
|
||||
'gitView.operation.readyToContinueHint': 'Wszystkie konflikty rozwiązane. Kontynuuj, aby zakończyć operację.',
|
||||
'gitView.operation.resolveConflictsHint': 'Rozwiąż konflikty, aby kontynuować.',
|
||||
'gitView.operation.resolveWithAi': 'Rozwiąż za pomocą AI',
|
||||
'gitView.pr.actions.add': 'Dodaj',
|
||||
|
||||
@@ -1579,6 +1579,10 @@ export const settingsDict = {
|
||||
"settings.openchamber.defaults.smallModel.useDefault": "Usar o modelo pequeno padrão",
|
||||
"settings.openchamber.defaults.smallModel.useDefaultAria": "Usar o modelo pequeno padrão",
|
||||
"settings.openchamber.defaults.smallModel.overrideModel": "Modelo de substituição",
|
||||
"settings.openchamber.defaults.walkthroughModel.title": "Modelo do percurso de mudanças",
|
||||
"settings.openchamber.defaults.walkthroughModel.description": "A revisão das suas mudanças por IA precisa de saída estruturada e espaço para um diff inteiro, o que um modelo pequeno e barato muitas vezes não oferece. Modelos que o catálogo indica como incapazes de saída estruturada ficam ocultos aqui. Sem escolha, o modelo pequeno é usado.",
|
||||
"settings.openchamber.defaults.walkthroughModel.overrideModel": "Modelo do percurso",
|
||||
"settings.openchamber.defaults.walkthroughModel.usesSmallModel": "Modelo pequeno",
|
||||
"settings.openchamber.defaults.field.showDeletionDialog": "Mostrar diálogo de eliminación",
|
||||
"settings.openchamber.defaults.field.openFilesPreviewAria": "Abrir arquivos com visualização em modo de prévia",
|
||||
"settings.openchamber.defaults.field.openFilesPreview": "Abrir arquivos com visualização em modo de prévia",
|
||||
|
||||
@@ -15,6 +15,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"common.loading": "Carregando...",
|
||||
"common.unavailable": "Indisponível",
|
||||
"common.language.english": "Inglês",
|
||||
"common.language.german": "Alemão",
|
||||
"common.language.french": "Francês",
|
||||
"common.language.simplifiedChinese": "Chinês (simplificado)",
|
||||
"common.language.traditionalChinese": "Chinês (tradicional)",
|
||||
@@ -91,7 +92,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
"mobile.nav.changes": "Alterações",
|
||||
"mobile.nav.settings": "Configurações",
|
||||
"mobile.surface.closeAria": "Fechar",
|
||||
"mobile.header.openMenuAria": "Abrir menu",
|
||||
"mobile.header.openWorkspaceAria": "Abrir painel de trabalho",
|
||||
"mobile.header.openMetadataAria": "Abrir metadados da sessão",
|
||||
"mobile.header.metadata.context": "Contexto",
|
||||
@@ -835,12 +835,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
"gitView.operation.abort": "Abortar",
|
||||
"gitView.operation.continue": "Continuar",
|
||||
"gitView.operation.inProgressTitle": "{operation} em andamento",
|
||||
"gitView.operation.inProgressTitleManyConflicts": "{operation} em andamento: {count} conflitos",
|
||||
"gitView.operation.inProgressTitleOneConflict": "{operation} em andamento: {count} conflito",
|
||||
"gitView.operation.merge": "Merge",
|
||||
"gitView.operation.mergeInProgressWithHead": "Fazendo merge de {head}",
|
||||
"gitView.operation.mergingMessage": "Fazendo merge: {message}",
|
||||
"gitView.operation.rebase": "Rebase",
|
||||
"gitView.operation.rebaseInProgress": "Rebase em andamento",
|
||||
"gitView.operation.rebasingOnto": "Fazendo rebase de {headName} sobre {onto}",
|
||||
"gitView.operation.readyToContinueHint": "Todos os conflitos foram resolvidos. Continue para finalizar a operação.",
|
||||
"gitView.operation.resolveConflictsHint": "Resolva os conflitos e depois continue a operação.",
|
||||
"gitView.operation.resolveWithAi": "Resolver com IA",
|
||||
"gitView.stash.confirmButton": "Salvar alterações e continuar",
|
||||
@@ -1096,6 +1099,61 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextRail.surface.git.description": "Commits, branches e pull requests",
|
||||
"contextRail.surface.terminal.description": "Terminal integrado",
|
||||
"contextRail.surface.diff.description": "Revisar alterações em andamento",
|
||||
"contextPanel.mode.walkthrough": "Percurso",
|
||||
"contextRail.surface.walkthrough.description": "Um percurso pelas suas mudanças guiado por IA",
|
||||
"walkthrough.scope.all": "Tudo sem commit",
|
||||
"walkthrough.scope.group.workingTree": "Árvore de trabalho",
|
||||
"walkthrough.scope.group.committed": "Com commit",
|
||||
"walkthrough.scope.staged": "No stage",
|
||||
"walkthrough.scope.working": "Fora do stage",
|
||||
"walkthrough.scope.branch": "Este branch",
|
||||
"walkthrough.scope.selectorAria": "Escolher o que revisar",
|
||||
"walkthrough.language.menuLabel": "Idioma do percurso",
|
||||
"walkthrough.missing.language": "Ainda não há um percurso em {requested} — exibindo o de {shown}.",
|
||||
"walkthrough.missing.model": "Ainda não há um percurso gerado por {model} — exibindo o último gerado aqui.",
|
||||
"walkthrough.missing.languageAndModel": "Ainda não há um percurso neste idioma com este modelo — exibindo o último gerado aqui.",
|
||||
"walkthrough.language.selectorAria": "Escolher o idioma do percurso",
|
||||
"walkthrough.scope.pullRequest": "PR nº {number}",
|
||||
"walkthrough.action.generate": "Gerar percurso",
|
||||
"walkthrough.action.regenerate": "Gerar novamente",
|
||||
"walkthrough.action.cancel": "Cancelar",
|
||||
"walkthrough.action.next": "Próxima etapa",
|
||||
"walkthrough.action.previous": "Etapa anterior",
|
||||
"walkthrough.action.refresh": "Atualizar",
|
||||
"walkthrough.action.open": "Percurso com IA",
|
||||
"walkthrough.stage.collecting": "Reunindo as mudanças",
|
||||
"walkthrough.stage.asking": "Aguardando o modelo",
|
||||
"walkthrough.stage.assembling": "Montando o percurso",
|
||||
"walkthrough.empty.title": "Ainda não há percurso",
|
||||
"walkthrough.empty.description": "Monte um percurso de leitura por estas mudanças. Isso chama o modelo pequeno e consome tokens, então só roda quando você pede.",
|
||||
"walkthrough.stale.banner": "O código mudou depois desta revisão. Etapas desatualizadas: {count}",
|
||||
"walkthrough.stop.staleAll": "Todo o código que esta etapa descrevia mudou.",
|
||||
"walkthrough.stop.stalePartial": "Parte do código que esta etapa descrevia mudou. Trechos perdidos: {count}",
|
||||
"walkthrough.stop.staleShort": "Desatualizado",
|
||||
"walkthrough.stop.noCode": "O código desta etapa não está no diff atual.",
|
||||
"walkthrough.uncovered.title": "Mudanças que a revisão deixou de fora: {count}",
|
||||
"walkthrough.uncovered.description": "A revisão considerou essas mudanças rotineiras. Expanda para conferir você mesmo.",
|
||||
"walkthrough.toc.moreFiles": "Mais arquivos: {count}",
|
||||
"walkthrough.toc.uncovered": "Sem cobertura: {count}",
|
||||
"walkthrough.toc.resize": "Redimensionar a coluna de conteúdo",
|
||||
"walkthrough.importance.critical": "Crítico",
|
||||
"walkthrough.importance.context": "Contexto",
|
||||
"walkthrough.blocked.noModel.title": "Nenhum modelo pequeno disponível",
|
||||
"walkthrough.blocked.noModel.description": "Entre em um provedor de modelos para gerar uma revisão.",
|
||||
"walkthrough.blocked.emptyDiff.title": "Nada para revisar",
|
||||
"walkthrough.blocked.emptyDiff.description": "Ainda não há mudanças neste escopo.",
|
||||
"walkthrough.blocked.contextTooSmall.title": "Este diff é grande demais para o modelo atual",
|
||||
"walkthrough.blocked.contextTooSmall.description": "{model} comporta cerca de {available} mil caracteres e este diff precisa de cerca de {required} mil. Nada é cortado — escolha um modelo com contexto maior.",
|
||||
"walkthrough.blocked.structuredOutput.title": "Este modelo não produz saída estruturada",
|
||||
"walkthrough.blocked.structuredOutput.description": "{model} não suporta as respostas estruturadas que um percurso exige.",
|
||||
"walkthrough.blocked.chooseModel": "Escolha o modelo pequeno",
|
||||
"walkthrough.blocked.outputExhausted.title": "O modelo ficou sem orçamento de resposta",
|
||||
"walkthrough.blocked.outputExhausted.description": "{model} gastou toda a margem de saída raciocinando e não devolveu nada. Modelos de raciocínio costumam fazer isso em diffs grandes — escolha um modelo que raciocine menos ou revise um escopo menor.",
|
||||
"walkthrough.blocked.outputExhausted.descriptionUnknownModel": "O modelo pequeno gastou toda a margem de saída raciocinando e não devolveu nada. Modelos de raciocínio costumam fazer isso em diffs grandes — escolha um modelo que raciocine menos ou revise um escopo menor.",
|
||||
"walkthrough.blocked.onlyGenerated.title": "Só mudaram arquivos gerados",
|
||||
"walkthrough.blocked.onlyGenerated.description": "Todas as mudanças são arquivos de lock ou outra saída gerada por ferramentas, que a revisão ignora de propósito.",
|
||||
"walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "O modelo pequeno comporta cerca de {available} mil caracteres e este diff precisa de cerca de {required} mil. Nada é cortado — escolha um modelo com contexto maior.",
|
||||
"walkthrough.blocked.structuredOutput.descriptionUnknownModel": "O modelo pequeno não suporta as respostas estruturadas que um percurso exige.",
|
||||
"contextRail.surface.plan.description": "Ver o plano atual",
|
||||
"contextRail.surface.pr.description": "Crie, revise e faça merge do pull request do branch atual",
|
||||
"contextRail.surface.notes.description": "Notas, tarefas e planos do projeto",
|
||||
|
||||
@@ -1579,6 +1579,10 @@ export const settingsDict = {
|
||||
"settings.openchamber.defaults.smallModel.useDefault": "Використовувати типову малу модель",
|
||||
"settings.openchamber.defaults.smallModel.useDefaultAria": "Використовувати типову малу модель",
|
||||
"settings.openchamber.defaults.smallModel.overrideModel": "Модель заміни",
|
||||
"settings.openchamber.defaults.walkthroughModel.title": "Модель для розбору змін",
|
||||
"settings.openchamber.defaults.walkthroughModel.description": "AI-розбір ваших змін потребує structured output і місця під увесь diff, чого дешева small model часто дати не може. Моделі, які за каталогом не вміють structured output, у цьому списку приховані. Якщо нічого не обрано, використовується small model.",
|
||||
"settings.openchamber.defaults.walkthroughModel.overrideModel": "Модель для розбору",
|
||||
"settings.openchamber.defaults.walkthroughModel.usesSmallModel": "Small model",
|
||||
"settings.openchamber.defaults.field.showDeletionDialog": "Показати діалогове вікно видалення",
|
||||
"settings.openchamber.defaults.field.openFilesPreviewAria": "Відкривати файли з підтримкою перегляду в режимі попереднього перегляду",
|
||||
"settings.openchamber.defaults.field.openFilesPreview": "Відкривати файли з підтримкою перегляду в режимі попереднього перегляду",
|
||||
|
||||
@@ -15,6 +15,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"common.loading": "Завантаження...",
|
||||
"common.unavailable": "Недоступно",
|
||||
"common.language.english": "англійська",
|
||||
"common.language.german": "Німецька",
|
||||
"common.language.french": "Французька",
|
||||
"common.language.simplifiedChinese": "Китайська (спрощена)",
|
||||
"common.language.traditionalChinese": "Китайська (традиційна)",
|
||||
@@ -91,7 +92,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
"mobile.nav.changes": "Зміни",
|
||||
"mobile.nav.settings": "Налаштування",
|
||||
"mobile.surface.closeAria": "Закрити",
|
||||
"mobile.header.openMenuAria": "Відкрити меню",
|
||||
"mobile.header.openWorkspaceAria": "Відкрити робочу панель",
|
||||
"mobile.header.openMetadataAria": "Відкрити метадані сесії",
|
||||
"mobile.header.metadata.context": "Контекст",
|
||||
@@ -835,12 +835,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
"gitView.operation.abort": "Перервати",
|
||||
"gitView.operation.continue": "Продовжити",
|
||||
"gitView.operation.inProgressTitle": "Виконується {operation}",
|
||||
"gitView.operation.inProgressTitleManyConflicts": "{operation} у процесі: {count} конфліктів",
|
||||
"gitView.operation.inProgressTitleOneConflict": "{operation} у процесі: {count} конфлікт",
|
||||
"gitView.operation.merge": "Злити",
|
||||
"gitView.operation.mergeInProgressWithHead": "Виконується злиття з {head}",
|
||||
"gitView.operation.mergingMessage": "Виконується злиття: {message}",
|
||||
"gitView.operation.rebase": "Перебазувати",
|
||||
"gitView.operation.rebaseInProgress": "Виконується перебазування",
|
||||
"gitView.operation.rebasingOnto": "Перебазування {headName} на {onto}",
|
||||
"gitView.operation.readyToContinueHint": "Усі конфлікти вирішено. Продовжте, щоб завершити операцію.",
|
||||
"gitView.operation.resolveConflictsHint": "Вирішіть конфлікти, потім продовжіть операцію.",
|
||||
"gitView.operation.resolveWithAi": "Вирішити за допомогою ШІ",
|
||||
"gitView.stash.confirmButton": "Підтвердити",
|
||||
@@ -1096,6 +1099,61 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextRail.surface.git.description": "Коміти, гілки та pull request-и",
|
||||
"contextRail.surface.terminal.description": "Вбудований термінал",
|
||||
"contextRail.surface.diff.description": "Перегляд поточних змін",
|
||||
"contextPanel.mode.walkthrough": "Розбір",
|
||||
"contextRail.surface.walkthrough.description": "Покроковий розбір ваших змін за допомогою AI",
|
||||
"walkthrough.scope.all": "Усе незакомічене",
|
||||
"walkthrough.scope.group.workingTree": "Робоче дерево",
|
||||
"walkthrough.scope.group.committed": "Закомічене",
|
||||
"walkthrough.scope.staged": "В індексі",
|
||||
"walkthrough.scope.working": "Поза індексом",
|
||||
"walkthrough.scope.branch": "Ця гілка",
|
||||
"walkthrough.scope.selectorAria": "Обрати, що розбирати",
|
||||
"walkthrough.language.menuLabel": "Мова розбору",
|
||||
"walkthrough.missing.language": "Розбору мовою {requested} ще немає — показано той, що мовою {shown}.",
|
||||
"walkthrough.missing.model": "Розбору від {model} ще немає — показано останній згенерований тут.",
|
||||
"walkthrough.missing.languageAndModel": "Розбору цією мовою від цієї моделі ще немає — показано останній згенерований тут.",
|
||||
"walkthrough.language.selectorAria": "Обрати мову розбору",
|
||||
"walkthrough.scope.pullRequest": "PR #{number}",
|
||||
"walkthrough.action.generate": "Створити розбір",
|
||||
"walkthrough.action.regenerate": "Створити заново",
|
||||
"walkthrough.action.cancel": "Скасувати",
|
||||
"walkthrough.action.next": "Наступний крок",
|
||||
"walkthrough.action.previous": "Попередній крок",
|
||||
"walkthrough.action.refresh": "Оновити",
|
||||
"walkthrough.action.open": "AI-розбір",
|
||||
"walkthrough.stage.collecting": "Збираю зміни",
|
||||
"walkthrough.stage.asking": "Чекаю на модель",
|
||||
"walkthrough.stage.assembling": "Складаю розбір",
|
||||
"walkthrough.empty.title": "Розбору ще немає",
|
||||
"walkthrough.empty.description": "Побудуйте маршрут читання цих змін. Це звертається до small model і витрачає токени, тому запускається лише на ваш запит.",
|
||||
"walkthrough.stale.banner": "Код змінився після цього розбору. Застарілих кроків: {count}",
|
||||
"walkthrough.stop.staleAll": "Увесь код, який описував цей крок, змінився.",
|
||||
"walkthrough.stop.stalePartial": "Частина коду, який описував цей крок, змінилася. Втрачених фрагментів: {count}",
|
||||
"walkthrough.stop.staleShort": "Застаріле",
|
||||
"walkthrough.stop.noCode": "Коду для цього кроку немає в поточному diff.",
|
||||
"walkthrough.uncovered.title": "Зміни, які розбір не описав: {count}",
|
||||
"walkthrough.uncovered.description": "Розбір визнав їх рутинними. Розгорніть, щоб переглянути самостійно.",
|
||||
"walkthrough.toc.moreFiles": "Ще файлів: {count}",
|
||||
"walkthrough.toc.uncovered": "Не описано: {count}",
|
||||
"walkthrough.toc.resize": "Змінити ширину колонки змісту",
|
||||
"walkthrough.importance.critical": "Критично",
|
||||
"walkthrough.importance.context": "Контекст",
|
||||
"walkthrough.blocked.noModel.title": "Немає доступної small model",
|
||||
"walkthrough.blocked.noModel.description": "Увійдіть до провайдера моделей, щоб створити розбір.",
|
||||
"walkthrough.blocked.emptyDiff.title": "Немає що оглядати",
|
||||
"walkthrough.blocked.emptyDiff.description": "У цій області поки що немає змін.",
|
||||
"walkthrough.blocked.contextTooSmall.title": "Цей diff завеликий для поточної моделі",
|
||||
"walkthrough.blocked.contextTooSmall.description": "{model} вміщає близько {available} тис. символів, а цьому diff потрібно близько {required} тис. Нічого не обрізається — оберіть модель із більшим контекстом.",
|
||||
"walkthrough.blocked.structuredOutput.title": "Ця модель не вміє structured output",
|
||||
"walkthrough.blocked.structuredOutput.description": "{model} не підтримує структуровані відповіді, потрібні для розбору.",
|
||||
"walkthrough.blocked.chooseModel": "Оберіть small model",
|
||||
"walkthrough.blocked.outputExhausted.title": "Моделі забракло бюджету на відповідь",
|
||||
"walkthrough.blocked.outputExhausted.description": "{model} витратила весь бюджет виводу на роздуми й нічого не повернула. Reasoning-моделі часто так поводяться на великих diff — допоможе модель, яка менше «думає», або вужча область огляду.",
|
||||
"walkthrough.blocked.outputExhausted.descriptionUnknownModel": "Small model витратила весь бюджет виводу на роздуми й нічого не повернула. Reasoning-моделі часто так поводяться на великих diff — допоможе модель, яка менше «думає», або вужча область огляду.",
|
||||
"walkthrough.blocked.onlyGenerated.title": "Змінились лише згенеровані файли",
|
||||
"walkthrough.blocked.onlyGenerated.description": "Усі зміни тут — це lock-файли чи інший результат роботи інструментів, які розбір свідомо пропускає.",
|
||||
"walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "Small model вміщає близько {available} тис. символів, а цьому diff потрібно близько {required} тис. Нічого не обрізається — оберіть модель із більшим контекстом.",
|
||||
"walkthrough.blocked.structuredOutput.descriptionUnknownModel": "Small model не підтримує структуровані відповіді, потрібні для розбору.",
|
||||
"contextRail.surface.plan.description": "Перегляд поточного плану",
|
||||
"contextRail.surface.pr.description": "Створюйте, переглядайте та зливайте pull request поточної гілки",
|
||||
"contextRail.surface.notes.description": "Нотатки, задачі та плани проєкту",
|
||||
|
||||
@@ -1579,6 +1579,10 @@ export const settingsDict = {
|
||||
'settings.openchamber.defaults.smallModel.useDefault': '使用默认小模型',
|
||||
'settings.openchamber.defaults.smallModel.useDefaultAria': '使用默认小模型',
|
||||
'settings.openchamber.defaults.smallModel.overrideModel': '覆盖模型',
|
||||
'settings.openchamber.defaults.walkthroughModel.title': '改动导读模型',
|
||||
'settings.openchamber.defaults.walkthroughModel.description': 'AI 评审改动需要结构化输出和容纳整份差异的空间,廉价的小模型往往做不到。目录中标注为不支持结构化输出的模型不会出现在此列表里。 未选择时将使用小模型。',
|
||||
'settings.openchamber.defaults.walkthroughModel.overrideModel': '导读模型',
|
||||
'settings.openchamber.defaults.walkthroughModel.usesSmallModel': '小模型',
|
||||
'settings.openchamber.defaults.field.showDeletionDialog': '显示删除对话框',
|
||||
'settings.openchamber.defaults.field.openFilesPreviewAria': '以预览模式打开可预览文件',
|
||||
'settings.openchamber.defaults.field.openFilesPreview': '以预览模式打开可预览文件',
|
||||
|
||||
@@ -15,6 +15,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'common.loading': '加载中...',
|
||||
'common.unavailable': '不可用',
|
||||
'common.language.english': 'English',
|
||||
'common.language.german': '德语',
|
||||
'common.language.french': '法语',
|
||||
'common.language.simplifiedChinese': '简体中文',
|
||||
'common.language.traditionalChinese': '繁体中文',
|
||||
@@ -91,7 +92,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
'mobile.nav.changes': '更改',
|
||||
'mobile.nav.settings': '设置',
|
||||
'mobile.surface.closeAria': '关闭',
|
||||
'mobile.header.openMenuAria': '打开菜单',
|
||||
'mobile.header.openWorkspaceAria': '打开工作区面板',
|
||||
'mobile.header.openMetadataAria': '打开会话元数据',
|
||||
'mobile.header.metadata.context': '上下文',
|
||||
@@ -835,12 +835,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.operation.abort': '中止',
|
||||
'gitView.operation.continue': '继续',
|
||||
'gitView.operation.inProgressTitle': '{operation} 进行中',
|
||||
'gitView.operation.inProgressTitleManyConflicts': '{operation} 进行中:{count} 个冲突',
|
||||
'gitView.operation.inProgressTitleOneConflict': '{operation} 进行中:{count} 个冲突',
|
||||
'gitView.operation.merge': '合并',
|
||||
'gitView.operation.mergeInProgressWithHead': '合并进行中({head})',
|
||||
'gitView.operation.mergingMessage': '正在合并:{message}',
|
||||
'gitView.operation.rebase': '变基',
|
||||
'gitView.operation.rebaseInProgress': '变基进行中',
|
||||
'gitView.operation.rebasingOnto': '正在将 {headName} 变基到 {onto}',
|
||||
'gitView.operation.readyToContinueHint': '所有冲突已解决。继续以完成操作。',
|
||||
'gitView.operation.resolveConflictsHint': '解决冲突,然后继续操作。',
|
||||
'gitView.operation.resolveWithAi': '使用 AI 解决',
|
||||
'gitView.stash.confirmButton': '储藏并{operation}',
|
||||
@@ -1096,6 +1099,61 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextRail.surface.git.description': '提交、分支和拉取请求',
|
||||
'contextRail.surface.terminal.description': '内置终端',
|
||||
'contextRail.surface.diff.description': '查看工作区更改',
|
||||
'contextPanel.mode.walkthrough': '导读',
|
||||
'contextRail.surface.walkthrough.description': '由 AI 引导的改动导读',
|
||||
'walkthrough.scope.all': '全部未提交',
|
||||
'walkthrough.scope.group.workingTree': '工作区',
|
||||
'walkthrough.scope.group.committed': '已提交',
|
||||
'walkthrough.scope.staged': '已暂存',
|
||||
'walkthrough.scope.working': '未暂存',
|
||||
'walkthrough.scope.branch': '当前分支',
|
||||
'walkthrough.scope.selectorAria': '选择评审范围',
|
||||
'walkthrough.language.menuLabel': '导读语言',
|
||||
'walkthrough.missing.language': '尚无{requested}导读,当前显示的是{shown}版本。',
|
||||
'walkthrough.missing.model': '尚无由 {model} 生成的导读,当前显示最近一次生成的版本。',
|
||||
'walkthrough.missing.languageAndModel': '尚无使用该语言和该模型生成的导读,当前显示最近一次生成的版本。',
|
||||
'walkthrough.language.selectorAria': '选择导读语言',
|
||||
'walkthrough.scope.pullRequest': 'PR #{number}',
|
||||
'walkthrough.action.generate': '生成导读',
|
||||
'walkthrough.action.regenerate': '重新生成',
|
||||
'walkthrough.action.cancel': '取消',
|
||||
'walkthrough.action.next': '下一步',
|
||||
'walkthrough.action.previous': '上一步',
|
||||
'walkthrough.action.refresh': '刷新',
|
||||
'walkthrough.action.open': '导读',
|
||||
'walkthrough.stage.collecting': '正在收集改动',
|
||||
'walkthrough.stage.asking': '正在等待模型',
|
||||
'walkthrough.stage.assembling': '正在组装导读',
|
||||
'walkthrough.empty.title': '还没有导读',
|
||||
'walkthrough.empty.description': '为这些改动生成一条引导式阅读路线。该操作会调用小模型并消耗 token,因此只在你主动请求时运行。',
|
||||
'walkthrough.stale.banner': '本次评审之后代码有变动。过时的步骤:{count}',
|
||||
'walkthrough.stop.staleAll': '该步骤描述的代码已全部变动。',
|
||||
'walkthrough.stop.stalePartial': '该步骤描述的部分代码已变动。缺失片段:{count}',
|
||||
'walkthrough.stop.staleShort': '已过时',
|
||||
'walkthrough.stop.noCode': '当前差异中没有该步骤的代码。',
|
||||
'walkthrough.uncovered.title': '评审未涵盖的改动:{count}',
|
||||
'walkthrough.uncovered.description': '评审认为这些改动较常规。展开可自行查看。',
|
||||
'walkthrough.toc.moreFiles': '其他文件:{count}',
|
||||
'walkthrough.toc.uncovered': '未涵盖:{count}',
|
||||
'walkthrough.toc.resize': '调整目录栏宽度',
|
||||
'walkthrough.importance.critical': '关键',
|
||||
'walkthrough.importance.context': '背景',
|
||||
'walkthrough.blocked.noModel.title': '没有可用的小模型',
|
||||
'walkthrough.blocked.noModel.description': '请登录模型提供方后再生成评审。',
|
||||
'walkthrough.blocked.emptyDiff.title': '没有可评审的内容',
|
||||
'walkthrough.blocked.emptyDiff.description': '该范围内暂无改动。',
|
||||
'walkthrough.blocked.contextTooSmall.title': '当前模型无法容纳这份差异',
|
||||
'walkthrough.blocked.contextTooSmall.description': '{model} 大约可容纳 {available} 千字符,而这份差异约需 {required} 千字符。我们不会截断内容,请改选上下文更大的模型。',
|
||||
'walkthrough.blocked.structuredOutput.title': '该模型不支持结构化输出',
|
||||
'walkthrough.blocked.structuredOutput.description': '{model} 不支持导读所需的结构化响应。',
|
||||
'walkthrough.blocked.chooseModel': '选择小模型',
|
||||
'walkthrough.blocked.outputExhausted.title': '模型的回答额度已用尽',
|
||||
'walkthrough.blocked.outputExhausted.description': '{model} 把全部输出额度用在了推理上,没有返回结果。推理模型在大差异上经常如此——可以换一个少推理的模型,或缩小评审范围。',
|
||||
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '小模型把全部输出额度用在了推理上,没有返回结果。推理模型在大差异上经常如此——可以换一个少推理的模型,或缩小评审范围。',
|
||||
'walkthrough.blocked.onlyGenerated.title': '只有生成文件发生了改动',
|
||||
'walkthrough.blocked.onlyGenerated.description': '这里的改动全部是锁文件或其他工具生成的产物,评审会有意跳过它们。',
|
||||
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '小模型大约可容纳 {available} 千字符,而这份差异约需 {required} 千字符。我们不会截断内容,请改选上下文更大的模型。',
|
||||
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '小模型不支持导读所需的结构化响应。',
|
||||
'contextRail.surface.plan.description': '查看当前计划',
|
||||
'contextRail.surface.pr.description': '创建、审查并合并当前分支的拉取请求',
|
||||
'contextRail.surface.notes.description': '项目的笔记、待办和计划',
|
||||
|
||||
@@ -1485,6 +1485,10 @@
|
||||
'settings.openchamber.defaults.smallModel.useDefault': '使用預設小模型',
|
||||
'settings.openchamber.defaults.smallModel.useDefaultAria': '使用預設小模型',
|
||||
'settings.openchamber.defaults.smallModel.overrideModel': '覆寫模型',
|
||||
'settings.openchamber.defaults.walkthroughModel.title': '變更導讀模型',
|
||||
'settings.openchamber.defaults.walkthroughModel.description': 'AI 審閱變更需要結構化輸出與容納整份差異的空間,便宜的小模型常常做不到。目錄標示為不支援結構化輸出的模型不會出現在這份清單中。 未選擇時將使用小模型。',
|
||||
'settings.openchamber.defaults.walkthroughModel.overrideModel': '導讀模型',
|
||||
'settings.openchamber.defaults.walkthroughModel.usesSmallModel': '小模型',
|
||||
'settings.openchamber.defaults.field.showDeletionDialog': '顯示刪除對話方塊',
|
||||
'settings.openchamber.defaults.field.openFilesPreviewAria': '以預覽模式開啟可預覽檔案',
|
||||
'settings.openchamber.defaults.field.openFilesPreview': '以預覽模式開啟可預覽檔案',
|
||||
|
||||
@@ -15,6 +15,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'common.loading': '載入中...',
|
||||
'common.unavailable': '無法使用',
|
||||
'common.language.english': 'English',
|
||||
'common.language.german': '德語',
|
||||
'common.language.french': '法語',
|
||||
'common.language.simplifiedChinese': '簡體中文',
|
||||
'common.language.traditionalChinese': '繁體中文',
|
||||
@@ -91,7 +92,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
'mobile.nav.changes': '變更',
|
||||
'mobile.nav.settings': '設定',
|
||||
'mobile.surface.closeAria': '關閉',
|
||||
'mobile.header.openMenuAria': '開啟選單',
|
||||
'mobile.header.openWorkspaceAria': '開啟工作區面板',
|
||||
'mobile.header.openMetadataAria': '開啟工作階段中繼資料',
|
||||
'mobile.header.metadata.context': '上下文',
|
||||
@@ -847,12 +847,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.operation.abort': '中止',
|
||||
'gitView.operation.continue': '繼續',
|
||||
'gitView.operation.inProgressTitle': '{operation} 進行中',
|
||||
'gitView.operation.inProgressTitleManyConflicts': '{operation} 進行中:{count} 個衝突',
|
||||
'gitView.operation.inProgressTitleOneConflict': '{operation} 進行中:{count} 個衝突',
|
||||
'gitView.operation.merge': '合併',
|
||||
'gitView.operation.mergeInProgressWithHead': '合併進行中({head})',
|
||||
'gitView.operation.mergingMessage': '正在合併:{message}',
|
||||
'gitView.operation.rebase': 'Rebase',
|
||||
'gitView.operation.rebaseInProgress': 'Rebase 進行中',
|
||||
'gitView.operation.rebasingOnto': '正在將 {headName} Rebase 到 {onto}',
|
||||
'gitView.operation.readyToContinueHint': '所有衝突已解決。繼續以完成操作。',
|
||||
'gitView.operation.resolveConflictsHint': '解決衝突,然後繼續操作。',
|
||||
'gitView.operation.resolveWithAi': '使用 AI 解決',
|
||||
'gitView.stash.confirmButton': 'Stash 並 {operation}',
|
||||
@@ -1108,6 +1111,61 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextRail.surface.git.description': '提交、分支與拉取請求',
|
||||
'contextRail.surface.terminal.description': '內建終端機',
|
||||
'contextRail.surface.diff.description': '檢視工作區變更',
|
||||
'contextPanel.mode.walkthrough': '導讀',
|
||||
'contextRail.surface.walkthrough.description': '由 AI 引導的變更導讀',
|
||||
'walkthrough.scope.all': '全部未提交',
|
||||
'walkthrough.scope.group.workingTree': '工作區',
|
||||
'walkthrough.scope.group.committed': '已提交',
|
||||
'walkthrough.scope.staged': '已暫存',
|
||||
'walkthrough.scope.working': '未暫存',
|
||||
'walkthrough.scope.branch': '目前分支',
|
||||
'walkthrough.scope.selectorAria': '選擇審閱範圍',
|
||||
'walkthrough.language.menuLabel': '導讀語言',
|
||||
'walkthrough.missing.language': '尚無{requested}導讀,目前顯示的是{shown}版本。',
|
||||
'walkthrough.missing.model': '尚無由 {model} 產生的導讀,目前顯示最近一次產生的版本。',
|
||||
'walkthrough.missing.languageAndModel': '尚無使用該語言與該模型產生的導讀,目前顯示最近一次產生的版本。',
|
||||
'walkthrough.language.selectorAria': '選擇導讀語言',
|
||||
'walkthrough.scope.pullRequest': 'PR #{number}',
|
||||
'walkthrough.action.generate': '產生導讀',
|
||||
'walkthrough.action.regenerate': '重新產生',
|
||||
'walkthrough.action.cancel': '取消',
|
||||
'walkthrough.action.next': '下一步',
|
||||
'walkthrough.action.previous': '上一步',
|
||||
'walkthrough.action.refresh': '重新整理',
|
||||
'walkthrough.action.open': '導讀',
|
||||
'walkthrough.stage.collecting': '正在收集變更',
|
||||
'walkthrough.stage.asking': '正在等待模型',
|
||||
'walkthrough.stage.assembling': '正在組裝導讀',
|
||||
'walkthrough.empty.title': '尚未有導讀',
|
||||
'walkthrough.empty.description': '為這些變更建立一條引導式閱讀路線。此操作會呼叫小模型並消耗 token,因此只在你主動要求時執行。',
|
||||
'walkthrough.stale.banner': '本次審閱之後程式碼有變動。過時的步驟:{count}',
|
||||
'walkthrough.stop.staleAll': '此步驟描述的程式碼已全部變動。',
|
||||
'walkthrough.stop.stalePartial': '此步驟描述的部分程式碼已變動。缺少的片段:{count}',
|
||||
'walkthrough.stop.staleShort': '已過時',
|
||||
'walkthrough.stop.noCode': '目前差異中沒有此步驟的程式碼。',
|
||||
'walkthrough.uncovered.title': '審閱未涵蓋的變更:{count}',
|
||||
'walkthrough.uncovered.description': '審閱認為這些變更較為例行。展開即可自行檢視。',
|
||||
'walkthrough.toc.moreFiles': '其他檔案:{count}',
|
||||
'walkthrough.toc.uncovered': '未涵蓋:{count}',
|
||||
'walkthrough.toc.resize': '調整目錄欄寬度',
|
||||
'walkthrough.importance.critical': '關鍵',
|
||||
'walkthrough.importance.context': '背景',
|
||||
'walkthrough.blocked.noModel.title': '沒有可用的小模型',
|
||||
'walkthrough.blocked.noModel.description': '請先登入模型供應商再產生審閱。',
|
||||
'walkthrough.blocked.emptyDiff.title': '沒有可審閱的內容',
|
||||
'walkthrough.blocked.emptyDiff.description': '此範圍目前沒有變更。',
|
||||
'walkthrough.blocked.contextTooSmall.title': '目前模型無法容納這份差異',
|
||||
'walkthrough.blocked.contextTooSmall.description': '{model} 大約可容納 {available} 千字元,而這份差異約需 {required} 千字元。我們不會截斷內容,請改選上下文更大的模型。',
|
||||
'walkthrough.blocked.structuredOutput.title': '此模型不支援結構化輸出',
|
||||
'walkthrough.blocked.structuredOutput.description': '{model} 不支援導讀所需的結構化回應。',
|
||||
'walkthrough.blocked.chooseModel': '選擇小模型',
|
||||
'walkthrough.blocked.outputExhausted.title': '模型的回答額度已用完',
|
||||
'walkthrough.blocked.outputExhausted.description': '{model} 把全部輸出額度用在推理上,沒有回傳結果。推理模型在大型差異上經常如此——可以改用較少推理的模型,或縮小審閱範圍。',
|
||||
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '小模型把全部輸出額度用在推理上,沒有回傳結果。推理模型在大型差異上經常如此——可以改用較少推理的模型,或縮小審閱範圍。',
|
||||
'walkthrough.blocked.onlyGenerated.title': '只有產生的檔案有變動',
|
||||
'walkthrough.blocked.onlyGenerated.description': '這裡的變更全部是鎖定檔或其他工具產生的輸出,審閱會刻意略過它們。',
|
||||
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '小模型大約可容納 {available} 千字元,而這份差異約需 {required} 千字元。我們不會截斷內容,請改選上下文更大的模型。',
|
||||
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '小模型不支援導讀所需的結構化回應。',
|
||||
'contextRail.surface.plan.description': '檢視目前計畫',
|
||||
'contextRail.surface.pr.description': '建立、審查並合併目前分支的提取請求',
|
||||
'contextRail.surface.notes.description': '專案的筆記、待辦與計畫',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export type Locale = 'en' | 'fr' | 'zh-CN' | 'zh-TW' | 'uk' | 'es' | 'pt-BR' | 'ko' | 'pl' | 'ja';
|
||||
export type Locale = 'en' | 'de' | 'fr' | 'zh-CN' | 'zh-TW' | 'uk' | 'es' | 'pt-BR' | 'ko' | 'pl' | 'ja';
|
||||
|
||||
export const LOCALES = ['en', 'fr', 'zh-CN', 'zh-TW', 'uk', 'es', 'pt-BR', 'ko', 'pl', 'ja'] as const satisfies readonly Locale[];
|
||||
export const LOCALES = ['en', 'de', 'fr', 'zh-CN', 'zh-TW', 'uk', 'es', 'pt-BR', 'ko', 'pl', 'ja'] as const satisfies readonly Locale[];
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = 'en';
|
||||
|
||||
export const LOCALE_LABEL_KEYS: Record<Locale, 'common.language.english' | 'common.language.french' | 'common.language.simplifiedChinese' | 'common.language.traditionalChinese' | 'common.language.ukrainian' | 'common.language.spanish' | 'common.language.brazilianPortuguese' | 'common.language.korean' | 'common.language.polish' | 'common.language.japanese'> = {
|
||||
export const LOCALE_LABEL_KEYS: Record<Locale, 'common.language.english' | 'common.language.french' | 'common.language.simplifiedChinese' | 'common.language.traditionalChinese' | 'common.language.ukrainian' | 'common.language.spanish' | 'common.language.brazilianPortuguese' | 'common.language.korean' | 'common.language.polish' | 'common.language.german' | 'common.language.japanese'> = {
|
||||
en: 'common.language.english',
|
||||
fr: 'common.language.french',
|
||||
'zh-CN': 'common.language.simplifiedChinese',
|
||||
@@ -14,6 +14,7 @@ export const LOCALE_LABEL_KEYS: Record<Locale, 'common.language.english' | 'comm
|
||||
'pt-BR': 'common.language.brazilianPortuguese',
|
||||
ko: 'common.language.korean',
|
||||
pl: 'common.language.polish',
|
||||
de: 'common.language.german',
|
||||
ja: 'common.language.japanese',
|
||||
};
|
||||
|
||||
@@ -59,6 +60,9 @@ export function normalizeLocale(value: string | undefined | null): Locale {
|
||||
if (normalized === 'ja' || normalized.startsWith('ja-')) {
|
||||
return 'ja';
|
||||
}
|
||||
if (normalized === 'de' || normalized.startsWith('de-')) {
|
||||
return 'de';
|
||||
}
|
||||
if (normalized === 'pl' || normalized.startsWith('pl-')) {
|
||||
return 'pl';
|
||||
}
|
||||
|
||||
@@ -42,9 +42,11 @@ async function loadDictionary(locale: Locale): Promise<I18nDictionary> {
|
||||
? await import('./messages/ko') as { dict: I18nDictionary }
|
||||
: locale === 'pl'
|
||||
? await import('./messages/pl') as { dict: I18nDictionary }
|
||||
: locale === 'ja'
|
||||
? await import('./messages/ja') as { dict: I18nDictionary }
|
||||
: { dict: enDict };
|
||||
: locale === 'de'
|
||||
? await import('./messages/de') as { dict: I18nDictionary }
|
||||
: locale === 'ja'
|
||||
? await import('./messages/ja') as { dict: I18nDictionary }
|
||||
: { dict: enDict };
|
||||
dictionaries.set(locale, mod.dict);
|
||||
return mod.dict;
|
||||
}
|
||||
|
||||
@@ -1169,6 +1169,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (typeof candidate.smallModelOverride === 'string' && candidate.smallModelOverride.length > 0) {
|
||||
result.smallModelOverride = candidate.smallModelOverride;
|
||||
}
|
||||
if (typeof candidate.walkthroughModelOverride === 'string' && candidate.walkthroughModelOverride.length > 0) {
|
||||
result.walkthroughModelOverride = candidate.walkthroughModelOverride;
|
||||
}
|
||||
if (typeof candidate.autoCreateWorktree === 'boolean') {
|
||||
result.autoCreateWorktree = candidate.autoCreateWorktree;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { snapdom } from '@zumer/snapdom';
|
||||
import { getFontEmbedCSS, toJpeg } from 'html-to-image';
|
||||
import { invokeDesktop } from '@/lib/desktop';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
@@ -826,6 +824,9 @@ async function captureIframeSnapdomScreenshot(
|
||||
// Defensive: undo any nested-scroll drift from asset inlining before capture.
|
||||
nestedScroll.reapply();
|
||||
|
||||
// Lazy-load snapDOM: only needed when actually capturing a preview
|
||||
// annotation screenshot, so keep it out of the eager app shell.
|
||||
const { snapdom } = await import('@zumer/snapdom');
|
||||
const snapdomOptions = {
|
||||
backgroundColor: getCaptureBackgroundColor(document),
|
||||
cache: 'disabled' as const,
|
||||
@@ -925,6 +926,8 @@ async function captureIframeDomScreenshot(
|
||||
await document.fonts?.ready.catch(() => undefined);
|
||||
restoreAssets = await inlineIframeCaptureAssets(document, viewportWidth, viewportHeight, { applyLayoutWorkarounds: true });
|
||||
frameWindow.scrollTo(scrollX, scrollY);
|
||||
// Lazy-load html-to-image: only the fallback DOM-capture path needs it.
|
||||
const { getFontEmbedCSS, toJpeg } = await import('html-to-image');
|
||||
const fontEmbedCSS = await getFontEmbedCSS(root).catch(() => '');
|
||||
|
||||
dataUrl = await toJpeg(root, {
|
||||
|
||||
@@ -17,7 +17,7 @@ export type SessionCreateRequest = {
|
||||
type DeleteListener = (request: SessionDeleteRequest) => void;
|
||||
type CreateListener = (request: SessionCreateRequest) => void;
|
||||
type DirectoryListener = () => void;
|
||||
type GitRefreshHint = { directory: string };
|
||||
type GitRefreshHint = { directory: string; paths?: string[] };
|
||||
type GitRefreshListener = (hint: GitRefreshHint) => void;
|
||||
|
||||
const deleteListeners = new Set<DeleteListener>();
|
||||
|
||||
@@ -378,6 +378,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
descriptionKey: 'settings.openchamber.defaults.smallModel.description',
|
||||
keywords: ['small model', 'utility', 'summary', 'recap', 'cheap', 'override'],
|
||||
},
|
||||
{
|
||||
id: 'sessions.walkthrough-model',
|
||||
page: 'sessions',
|
||||
titleKey: 'settings.openchamber.defaults.walkthroughModel.title',
|
||||
descriptionKey: 'settings.openchamber.defaults.walkthroughModel.description',
|
||||
keywords: ['walkthrough', 'diff', 'review', 'changes', 'structured output', 'model', 'override'],
|
||||
},
|
||||
{
|
||||
id: 'sessions.auto-cleanup',
|
||||
page: 'sessions',
|
||||
|
||||
@@ -7,6 +7,7 @@ export type ContextSurfaceId =
|
||||
| 'git'
|
||||
| 'pr'
|
||||
| 'diff'
|
||||
| 'walkthrough'
|
||||
| 'terminal'
|
||||
| 'plan'
|
||||
| 'notes'
|
||||
@@ -61,7 +62,7 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [
|
||||
descriptionKey: 'contextRail.surface.pr.description',
|
||||
defaultWidthFraction: 0.45,
|
||||
mode: 'pr',
|
||||
icon: 'git-pull-request',
|
||||
icon: 'github',
|
||||
labelKey: 'contextPanel.mode.pr',
|
||||
availability: 'always',
|
||||
},
|
||||
@@ -74,12 +75,21 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [
|
||||
labelKey: 'contextPanel.mode.diff',
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
id: 'walkthrough',
|
||||
descriptionKey: 'contextRail.surface.walkthrough.description',
|
||||
defaultWidthFraction: 3 / 5,
|
||||
mode: 'walkthrough',
|
||||
icon: 'route',
|
||||
labelKey: 'contextPanel.mode.walkthrough',
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
id: 'editor',
|
||||
descriptionKey: 'contextRail.surface.editor.description',
|
||||
defaultWidthFraction: 3 / 5,
|
||||
mode: 'file',
|
||||
icon: 'file-code',
|
||||
icon: 'braces',
|
||||
labelKey: 'contextPanel.mode.files',
|
||||
availability: 'always',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { readTabletLayout, type TabletLayout } from './device';
|
||||
|
||||
// No module mocking here on purpose: mock.module is process-global and would
|
||||
// leak into every other test file. Outside a Capacitor shell isIPadApp() is
|
||||
// already false, so a bare viewport stub isolates the geometry rules.
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
const setViewport = (width: number, height: number) => {
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
innerWidth: width,
|
||||
innerHeight: height,
|
||||
// isIPadApp() reaches for the Capacitor markers; a plain web location
|
||||
// keeps it on its `false` path without mocking the module.
|
||||
location: { protocol: 'https:', search: '' },
|
||||
};
|
||||
};
|
||||
|
||||
const withViewport = (width: number, height: number): TabletLayout => {
|
||||
setViewport(width, height);
|
||||
return readTabletLayout();
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as { window?: unknown }).window = originalWindow;
|
||||
});
|
||||
|
||||
describe('readTabletLayout', () => {
|
||||
test('a phone stays a phone in both orientations', () => {
|
||||
expect(withViewport(390, 844).enabled).toBe(false);
|
||||
// The long side alone must never qualify — this is the case a plain
|
||||
// width threshold gets wrong.
|
||||
expect(withViewport(844, 390).enabled).toBe(false);
|
||||
});
|
||||
|
||||
test('a tablet qualifies in both orientations', () => {
|
||||
expect(withViewport(834, 1194).enabled).toBe(true);
|
||||
expect(withViewport(1194, 834).enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('side panels need real width, so a tablet in portrait keeps the drawer', () => {
|
||||
expect(withViewport(834, 1194).roomyForPanels).toBe(false);
|
||||
expect(withViewport(1194, 834).roomyForPanels).toBe(true);
|
||||
});
|
||||
|
||||
test('an unfolded foldable is a tablet but never roomy enough for panels', () => {
|
||||
// Book foldables are near-square: the long side is barely wider than a
|
||||
// tablet's short one, so both orientations keep the portrait layout.
|
||||
expect(withViewport(690, 840)).toEqual({ enabled: true, roomyForPanels: false });
|
||||
expect(withViewport(840, 690)).toEqual({ enabled: true, roomyForPanels: false });
|
||||
});
|
||||
|
||||
test('folding shut drops back to the phone layout', () => {
|
||||
expect(withViewport(370, 900).enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user