diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index fc4a70973f4..6a4ab0311d8 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -150,14 +150,14 @@ export function App() { ### WebShell -| 属性 | 类型 | 说明 | -| ------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------- | -| `onSessionIdChange` | `(sessionId: string \| undefined) => void` | 当前 session id 变化或清空时触发 | -| `onSessionCreated` | `(sessionId: string) => Promise \| void` | 新 session 创建后触发;完成前会阻塞 session 初始化和 prompt 提交,最长等待 30 秒 | -| `theme` | `'dark' \| 'light'` | UI 主题,默认 `dark` | -| `onThemeChange` | `(theme: WebShellTheme) => void` | `/theme` 命令切换主题后触发 | -| `language` | `'en' \| 'zh-CN' \| 'zh' \| 'zh-cn'` | UI 语言 | -| `onLanguageChange` | `(language: WebShellLanguage) => void` | `/language ui` 切换 UI 语言后触发 | +| 属性 | 类型 | 说明 | +| ------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `onSessionIdChange` | `(sessionId: string \| undefined, workspaceId?: string) => void` | 当前 session id 或 workspace id 变化或清空时触发 | +| `onSessionCreated` | `(sessionId: string) => Promise \| void` | 新 session 创建后触发;完成前会阻塞 session 初始化和 prompt 提交,最长等待 30 秒 | +| `theme` | `'dark' \| 'light'` | UI 主题,默认 `dark` | +| `onThemeChange` | `(theme: WebShellTheme) => void` | `/theme` 命令切换主题后触发 | +| `language` | `'en' \| 'zh-CN' \| 'zh' \| 'zh-cn'` | UI 语言 | +| `onLanguageChange` | `(language: WebShellLanguage) => void` | `/language ui` 切换 UI 语言后触发 | ## 可选图表 Renderer diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index 75383d9934b..5bd5dc41ca5 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -169,39 +169,6 @@ display: none; } -/* Hosts can embed WebShell in a narrow side pane while the browser viewport - * remains wide. The public drawer API must still show a usable overlay in - * that container instead of relying exclusively on the viewport media query. */ -.mobileDrawerForced { - display: block; - /* `.app` is the narrow host Webview's positioning context. Absolute keeps - * the drawer inside it; fixed would target the IDE's larger page instead. */ - position: absolute; - inset: 0; - z-index: 50; - pointer-events: auto; - visibility: visible; - padding-top: env(safe-area-inset-top); - padding-right: env(safe-area-inset-right); - padding-bottom: env(safe-area-inset-bottom); - padding-left: env(safe-area-inset-left); -} - -.mobileDrawerForced .mobileBackdrop { - display: block; - position: absolute; - inset: 0; - z-index: 49; - background: rgba(0, 0, 0, 0.5); - opacity: 1; - pointer-events: auto; -} - -.mobileDrawerForced > aside { - position: relative; - z-index: 50; -} - .hamburgerButton { display: none; align-items: center; @@ -307,10 +274,6 @@ scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); } -:where(.app button) { - padding: 0; -} - .app *::-webkit-scrollbar { width: 8px; height: 8px; diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index c79829155d5..0078cf173d7 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -41,6 +41,7 @@ type ChatEditorTestProps = { const { mockConnection, mockSessionActions, + mockWorkspace, mockWorkspaceActions, mockStore, mockFollowup, @@ -67,6 +68,11 @@ const { loadingTranscript: false, catchingUp: false, }; + const workspaceClient = { + workspaceByCwd: vi.fn(() => ({ + workspaceGit: vi.fn().mockResolvedValue({ branch: 'main' }), + })), + }; return { mockConnection: connection, mockSessionActions: { @@ -88,6 +94,12 @@ const { loadArtifacts: vi.fn().mockResolvedValue({ artifacts: [] }), loadSession: vi.fn().mockResolvedValue(undefined), }, + mockWorkspace: { + capabilities: { + workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }], + }, + client: workspaceClient, + }, mockWorkspaceActions: { loadSkillsStatus: vi.fn().mockResolvedValue({ skills: [] }), loadProviders: vi.fn().mockResolvedValue({ current: null }), @@ -153,6 +165,7 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ useStreamingState: () => testState.streamingState, useTranscriptBlocks: () => testState.blocks, useTranscriptStore: () => mockStore, + useWorkspace: () => mockWorkspace, useWorkspaceActions: () => mockWorkspaceActions, useWorkspaceEventSignals: () => ({ artifactsVersion: 0, @@ -401,6 +414,8 @@ mockComponent('./components/WelcomeHeader', 'WelcomeHeader'); mockComponent('./components/dialogs/ApprovalModeDialog', 'ApprovalModeDialog'); mockComponent('./components/dialogs/ResumeDialog', 'ResumeDialog'); mockComponent('./components/dialogs/ToolsDialog', 'ToolsDialog'); +mockComponent('./components/tools/ToolsManagerPage', 'ToolsManagerPage'); +mockComponent('./components/skills/SkillsManagerPage', 'SkillsManagerPage'); mockComponent('./components/dialogs/DaemonStatusDialog', 'DaemonStatusDialog'); mockComponent('./components/SessionOverviewPanel', 'SessionOverviewPanel'); vi.doMock('./components/SplitView', async () => { @@ -1722,142 +1737,6 @@ describe('App session callbacks', () => { expect(panel?.getAttribute('aria-label')).toBe('Session Overview'); }); - it('forces the compact session drawer from the external shell ref', async () => { - const shellRef = createRef(); - const { container } = renderApp({ sidebar: true, shellRef }); - await flush(); - - await act(async () => { - shellRef.current?.openSessionDrawer(); - await Promise.resolve(); - }); - - const drawer = container.querySelector( - '[data-sidebar-shell][role="dialog"]', - ); - expect(drawer).not.toBeNull(); - expect(drawer?.className).toContain('mobileDrawerForced'); - }); - - it('returns a forced compact drawer to viewport control when the user dismisses it', async () => { - const shellRef = createRef(); - const { container } = renderApp({ sidebar: true, shellRef }); - await flush(); - - await act(async () => { - shellRef.current?.openSessionDrawer(); - await Promise.resolve(); - }); - expect( - container.querySelector('[data-sidebar-shell]')?.className, - ).toContain('mobileDrawerForced'); - - await act(async () => { - container - .querySelector( - '[data-sidebar-shell] > div[aria-hidden="true"]', - ) - ?.click(); - await Promise.resolve(); - }); - expect( - container.querySelector('[data-sidebar-shell]')?.className, - ).not.toContain('mobileDrawerForced'); - expect( - container.querySelector('[data-sidebar-shell][role="dialog"]'), - ).toBeNull(); - }); - - it('returns to chat and clears the current page when the external shell opens the compact drawer', async () => { - const shellRef = createRef(); - const { container } = renderApp({ sidebar: true, shellRef }); - await flush(); - - await act(async () => { - shellRef.current?.openSessionOverview(); - await Promise.resolve(); - }); - expect( - container.querySelector('[data-testid="inline-panel"]'), - ).not.toBeNull(); - - await act(async () => { - shellRef.current?.openSessionDrawer(); - await Promise.resolve(); - }); - expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull(); - - await act(async () => { - shellRef.current?.openSplitView(); - await Promise.resolve(); - }); - expect( - container.querySelector('[data-testid="split-view-page"]'), - ).not.toBeNull(); - - await act(async () => { - shellRef.current?.openSessionDrawer(); - await Promise.resolve(); - }); - expect( - container.querySelector('[data-testid="split-view-page"]'), - ).toBeNull(); - expect( - container.querySelector('[data-sidebar-shell][role="dialog"]'), - ).not.toBeNull(); - }); - - it('clears a forced compact drawer after crossing to a wide viewport', async () => { - let mobileChangeHandler: - | ((event: { matches: boolean }) => void) - | undefined; - Object.defineProperty(window, 'matchMedia', { - configurable: true, - value: vi.fn().mockImplementation((query: string) => ({ - matches: query.includes('min-width'), - media: query, - addEventListener: ( - _type: string, - handler: (event: { matches: boolean }) => void, - ) => { - if (query.includes('max-width')) mobileChangeHandler = handler; - }, - removeEventListener: vi.fn(), - })), - }); - const shellRef = createRef(); - const { container } = renderApp({ sidebar: true, shellRef }); - await flush(); - - await act(async () => { - shellRef.current?.openSessionDrawer(); - await Promise.resolve(); - }); - expect( - container.querySelector('[data-sidebar-shell]')?.className, - ).toContain('mobileDrawerForced'); - - await act(async () => { - mobileChangeHandler?.({ matches: false }); - await Promise.resolve(); - }); - expect( - container.querySelector('[data-sidebar-shell]')?.className, - ).not.toContain('mobileDrawerForced'); - expect( - container.querySelector('[data-sidebar-shell][role="dialog"]'), - ).toBeNull(); - }); - - it('lets a host hide the built-in compact sidebar toggle', async () => { - const { container } = renderApp({ - sidebar: { enabled: true, showCompactToggle: false }, - }); - await flush(); - - expect(container.querySelector('[aria-label="Toggle menu"]')).toBeNull(); - }); - it('returns to the Session Overview when leaving the split view', async () => { const { container } = renderApp(); await flush(); @@ -2892,7 +2771,9 @@ describe('App manual-run orchestration (scheduled tasks)', () => { await act(async () => { await expect(run('do the thing', 'session-1')).resolves.toBeUndefined(); }); - expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-1'); + expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-1', { + workspaceCwd: undefined, + }); }); it('supersedes an older pending bound run with a newer one', async () => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index a844c4837c8..0a4d741d07e 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -20,6 +20,7 @@ import { useStreamingState, useTranscriptBlocks, useTranscriptStore, + useWorkspace, useWorkspaceActions, useWorkspaceEventSignals, type DaemonWorkspaceActions, @@ -130,7 +131,6 @@ import { } from './utils/copyCommand'; import { isEditableTarget } from './utils/dom'; import { getModelDisplayName } from './utils/modelDisplay'; -import { hasMultipleWorkspaces, workspaceBasename } from './utils/workspace'; import { isVisibleComposerModel } from './utils/composerModels'; import { filterModelSwitchMessages } from './utils/modelSwitchMessages'; import { decideEscapeIntent } from './utils/escapeIntent'; @@ -194,6 +194,7 @@ import { THEME_SETTING_KEY, LANGUAGE_SETTING_KEY, themeSettingToWebShellTheme, + webShellThemeToSettingValue, type WebShellTheme, } from './themeContext'; import { @@ -412,7 +413,7 @@ export interface WebShellSidebarOptions { defaultCollapsed?: boolean; /** Whether to show WebShell's built-in compact drawer toggle. Defaults to true. */ showCompactToggle?: boolean; - /** Hide or replace the leading New Chat brand mark. */ + /** Hide or replace the complete sidebar branding row. */ branding?: false | WebShellSidebarBranding; /** Hide the footer completely or select the built-in entries it exposes. */ footer?: false | WebShellSidebarFooterOptions; @@ -428,8 +429,6 @@ export interface WebShellApi { openSplitView: () => void; /** Open the Session Overview panel, matching the built-in sidebar button. */ openSessionOverview: () => void; - /** Open the compact session drawer, matching the hamburger control. */ - openSessionDrawer: () => void; } export type WebShellComposerPlaceholderState = ComposerPlaceholderState; @@ -440,7 +439,10 @@ export type WebShellComposerPlaceholders = Readonly< export interface WebShellProps { /** Called whenever the attached daemon session id changes. */ - onSessionIdChange?: (sessionId: string | undefined) => void; + onSessionIdChange?: ( + sessionId: string | undefined, + workspaceId?: string, + ) => void; /** Called after a new session is created. Session setup waits up to 30 seconds. */ onSessionCreated?: (sessionId: string) => Promise | void; /** Visual theme for the embedded shell. */ @@ -584,6 +586,7 @@ export interface WebShellProps { type SessionActionsWithCreate = { createSession: (options?: { workspaceCwd?: string; + approvalMode?: string; }) => Promise<{ sessionId: string }>; attachSession: () => Promise; clearSession: () => Promise; @@ -1014,10 +1017,8 @@ export function App({ string | null >(null); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); - const [forceMobileDrawer, setForceMobileDrawer] = useState(false); const closeMobileDrawer = useCallback(() => { setMobileDrawerOpen(false); - setForceMobileDrawer(false); }, []); // The Session Overview panel (mission control for managing many sessions at // once) is only offered on large screens; below that there is no room for it @@ -1032,11 +1033,11 @@ export function App({ useEffect(() => { const mql = window.matchMedia('(max-width: 760px)'); const handler = (e: MediaQueryListEvent) => { - if (!e.matches) closeMobileDrawer(); + if (!e.matches) setMobileDrawerOpen(false); }; mql.addEventListener('change', handler); return () => mql.removeEventListener('change', handler); - }, [closeMobileDrawer]); + }, []); useEffect(() => { if (!mobileDrawerOpen) return; @@ -1138,6 +1139,11 @@ export function App({ const store = useTranscriptStore(); const blocks = useTranscriptBlocks(); const connection = useConnection(); + const workspace = useWorkspace(); + const workspaces = useMemo( + () => workspace.capabilities?.workspaces ?? [], + [workspace.capabilities?.workspaces], + ); const sessionActions = useActions(); const { notices, dismissNotice } = useSessionNotices(); const workspaceActions = useWorkspaceActions(); @@ -1149,6 +1155,42 @@ export function App({ >(undefined); const selectedWorkspaceCwdRef = useRef(selectedWorkspaceCwd); selectedWorkspaceCwdRef.current = selectedWorkspaceCwd; + const [selectedWorkspaceGitBranch, setSelectedWorkspaceGitBranch] = useState< + string | undefined + >(undefined); + useEffect(() => { + if (connection.sessionId) { + setSelectedWorkspaceGitBranch(undefined); + return; + } + const primaryWorkspaceCwd = workspaces.find((entry) => entry.primary)?.cwd; + const workspaceCwd = selectedWorkspaceCwd ?? primaryWorkspaceCwd; + if (!workspaceCwd) { + setSelectedWorkspaceGitBranch(undefined); + return; + } + let cancelled = false; + setSelectedWorkspaceGitBranch(undefined); + void workspace.client + .workspaceByCwd(workspaceCwd) + .workspaceGit() + .then((git) => { + if (!cancelled) { + setSelectedWorkspaceGitBranch(git.branch ?? undefined); + } + }) + .catch(() => { + if (!cancelled) setSelectedWorkspaceGitBranch(undefined); + }); + return () => { + cancelled = true; + }; + }, [ + connection.sessionId, + selectedWorkspaceCwd, + workspaces, + workspace.client, + ]); const onToastRef = useRef(onToast); onToastRef.current = onToast; const toastIdRef = useRef(0); @@ -1187,6 +1229,7 @@ export function App({ const chatPaneRef = useRef(null); const currentSessionIdRef = useRef(connection.sessionId); const lastNotifiedSessionIdRef = useRef(undefined); + const lastNotifiedWorkspaceIdRef = useRef(undefined); const lastGoalSessionIdRef = useRef(connection.sessionId); const displayMessages = useMemo(() => { const localMessages = [recapMessage].filter( @@ -2054,12 +2097,6 @@ export function App({ () => ({ openSplitView: () => requestOpenSplitView(), openSessionOverview: () => openPanel('sessions'), - openSessionDrawer: () => { - setActivePanel(null); - setMainView('chat'); - setForceMobileDrawer(true); - setMobileDrawerOpen(true); - }, }), [openPanel, requestOpenSplitView], ); @@ -2410,12 +2447,15 @@ export function App({ currentModelRef.current || connectionRef.current.currentModel; const modeId = currentModeRef.current || connectionRef.current.currentMode; + const primaryWorkspaceCwd = workspaces.find( + (entry) => entry.primary, + )?.cwd; await createAndAttachSessionForPrompt({ sessionActions: sessionActions as typeof sessionActions & SessionActionsWithCreate, modelId, modeId, - workspaceCwd: selectedWorkspaceCwdRef.current, + workspaceCwd: selectedWorkspaceCwdRef.current ?? primaryWorkspaceCwd, onSessionCreated: onSessionCreatedRef.current, onSessionAllocated: (sessionId) => { preparingSessionIdRef.current = sessionId; @@ -2436,7 +2476,7 @@ export function App({ }; void promise.then(clearPreparation, clearPreparation); return promise; - }, [sessionActions]); + }, [sessionActions, workspaces]); const onSubmitBeforeRef = useRef(onSubmitBefore); onSubmitBeforeRef.current = onSubmitBefore; const [sessionListReloadToken, setSessionListReloadToken] = useState(0); @@ -3196,12 +3236,34 @@ export function App({ // Keep the dead-session route visible until the user explicitly starts a // new chat; clearing it here would immediately hide the recovery state. lastNotifiedSessionIdRef.current = connection.sessionId; + lastNotifiedWorkspaceIdRef.current = undefined; + return; + } + const activeWorkspace = workspaces.find( + (entry) => entry.cwd === connection.workspaceCwd, + ); + if (connection.sessionId && !workspace.capabilities) return; + const workspaceId = + activeWorkspace && !activeWorkspace.primary + ? activeWorkspace.id + : undefined; + if ( + lastNotifiedSessionIdRef.current === connection.sessionId && + lastNotifiedWorkspaceIdRef.current === workspaceId + ) { return; } - if (lastNotifiedSessionIdRef.current === connection.sessionId) return; lastNotifiedSessionIdRef.current = connection.sessionId; - onSessionIdChange?.(connection.sessionId); - }, [connection.missingSession, connection.sessionId, onSessionIdChange]); + lastNotifiedWorkspaceIdRef.current = workspaceId; + onSessionIdChange?.(connection.sessionId, workspaceId); + }, [ + connection.missingSession, + connection.sessionId, + connection.workspaceCwd, + onSessionIdChange, + workspace.capabilities, + workspaces, + ]); const lastRenameSessionRef = useRef(undefined); const lastRenameNameRef = useRef(undefined); @@ -3388,23 +3450,28 @@ export function App({ branchCurrentSession(); }, [branchCurrentSession]); - const createNewSession = useCallback(async () => { - // Close the drawer before awaiting so a failed createSession() doesn't leave - // it stuck open with the page scroll still locked, matching loadSidebarSession. - closeMobileDrawer(); - // Starting a new chat means the user wants to see it — leave any open - // Settings/Status panel so the fresh chat is visible (no-op when closed). - closePanel(); - try { - await ( - sessionActions as typeof sessionActions & SessionActionsWithCreate - ).clearSession(); - return true; - } catch (error) { - reportError(error, 'Failed to start a new chat'); - return false; - } - }, [closeMobileDrawer, closePanel, reportError, sessionActions]); + const createNewSession = useCallback( + async (workspaceCwd?: string) => { + selectedWorkspaceCwdRef.current = workspaceCwd; + setSelectedWorkspaceCwd(workspaceCwd); + // Close the drawer before awaiting so a failed createSession() doesn't leave + // it stuck open with the page scroll still locked, matching loadSidebarSession. + closeMobileDrawer(); + // Starting a new chat means the user wants to see it — leave any open + // Settings/Status panel so the fresh chat is visible (no-op when closed). + closePanel(); + try { + await ( + sessionActions as typeof sessionActions & SessionActionsWithCreate + ).clearSession(); + return true; + } catch (error) { + reportError(error, 'Failed to start a new chat'); + return false; + } + }, + [closeMobileDrawer, closePanel, reportError, sessionActions], + ); const handleMissingSessionNewSession = useCallback(async () => { if (creatingMissingSessionRef.current) return; creatingMissingSessionRef.current = true; @@ -3422,7 +3489,7 @@ export function App({ }, [createNewSession, onSessionIdChange]); const loadSidebarSession = useCallback( - async (sessionId: string) => { + async (sessionId: string, workspaceCwd?: string) => { setSidebarSwitchingSessionId(sessionId); // Close the drawer before awaiting the load; the transcript clears // immediately and shows its loading skeleton for the selected session. @@ -3431,7 +3498,7 @@ export function App({ // Settings/Status panel (no-op when the panel is closed). closePanel(); try { - await sessionActions.loadSession(sessionId); + await sessionActions.loadSession(sessionId, { workspaceCwd }); } catch (error) { setSidebarSwitchingSessionId((current) => current === sessionId ? null : current, @@ -5547,7 +5614,6 @@ export function App({ className={[ styles.mobileDrawer, mobileDrawerOpen ? styles.mobileDrawerOpen : undefined, - forceMobileDrawer ? styles.mobileDrawerForced : undefined, ] .filter(Boolean) .join(' ')} @@ -5586,13 +5652,22 @@ export function App({ openSplitView(); }} canOpenSplitView={isLargeScreen} - onNewSession={() => { + theme={selectedTheme} + onThemeChange={(theme) => { + handleThemeChange(theme); + void setWorkspaceSetting( + 'workspace', + THEME_SETTING_KEY, + webShellThemeToSettingValue(theme), + ); + }} + onNewSession={(workspaceCwd) => { setMainView('chat'); - return createNewSession(); + return createNewSession(workspaceCwd); }} - onLoadSession={(sessionId) => { + onLoadSession={(sessionId, workspaceCwd) => { setMainView('chat'); - return loadSidebarSession(sessionId); + return loadSidebarSession(sessionId, workspaceCwd); }} onError={reportError} mobileOpen={mobileDrawerOpen} @@ -5631,7 +5706,6 @@ export function App({ .filter(Boolean) .join(' ')} onClick={() => { - setForceMobileDrawer(false); setMobileDrawerOpen((open) => !open); }} aria-label={t('sidebar.toggleMenu')} @@ -6138,14 +6212,11 @@ export function App({ onClearQueuedMessages={clearQueuedPrompts} currentMode={currentMode} currentModel={currentModel} - gitBranch={connection.gitBranch} - workspaceName={ - hasMultipleWorkspaces(connection.capabilities) && - connection.workspaceCwd - ? workspaceBasename(connection.workspaceCwd) - : undefined + gitBranch={ + connection.sessionId + ? connection.gitBranch + : selectedWorkspaceGitBranch } - workspaceTitle={connection.workspaceCwd || undefined} chatWidthMode={chatWidthMode} showChatWidthToggle={!isChatEmptyState} chatWidthToggleMin={chatWidthToggleMin} @@ -6153,6 +6224,43 @@ export function App({ availableModels={availableModels} onSelectMode={handleSetMode} onSelectModel={handleModelSelect} + workspaces={ + workspaces.length > 1 + ? workspaces.map((entry) => ({ + id: entry.id, + cwd: entry.cwd, + label: + entry.cwd + .split(/[\\/]+/) + .filter(Boolean) + .at(-1) ?? entry.cwd, + primary: entry.primary, + })) + : undefined + } + selectedWorkspaceCwd={ + connection.sessionId + ? workspaces.find( + (entry) => + entry.cwd === connection.workspaceCwd, + )?.primary + ? undefined + : connection.workspaceCwd + : selectedWorkspaceCwd + } + workspaceSelectionDisabled={Boolean( + connection.sessionId, + )} + atWorkspaceCwd={ + connection.sessionId + ? connection.workspaceCwd + : (selectedWorkspaceCwd ?? + workspaces.find((entry) => entry.primary)?.cwd) + } + onSelectWorkspace={(cwd) => { + selectedWorkspaceCwdRef.current = cwd; + setSelectedWorkspaceCwd(cwd); + }} onChatWidthModeChange={handleChatWidthModeChange} sessionName={sessionDisplayName} dialogOpen={ diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css index 1393a92cf83..d53ab85ef5b 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -998,32 +998,50 @@ } @container (max-width: 699px) { - /* Keep the leading controls on a single row on narrow screens. The branch - chip yields space first — truncating via its ellipsis, down to just the - icon if needed — instead of pushing the mode/model buttons onto a second - line and making the composer taller (#6753). */ .toolbarLeft { flex-wrap: nowrap; + gap: 2px; } .toolbarLeft .dropdownWrapper { - flex: 0 0 auto; + width: 28px; + flex: 0 0 28px; } .gitBranchChip { - max-width: 140px; - flex-shrink: 1; + width: 28px; + max-width: 28px; + flex: 0 0 28px; + gap: 0; + padding: 0 6px; + } + + .gitBranchText, + .workspaceChipText, + .modeToolBtn .toolBtnText, + .modelToolBtn .toolBtnText, + .modeToolBtn .toolBtnArrow, + .modelToolBtn .toolBtnArrow { + display: none; } - /* Keep the workspace name legible in a narrow split pane — it is the pane's - identity, so unlike the action buttons it never collapses to an icon; it - only tightens and truncates (the full cwd stays in the tooltip). It yields - space the same way the branch chip does so the row never wraps. */ - .toolbarLeft .workspaceChip { - max-width: 108px; - flex-shrink: 1; + .toolbarLeft .workspaceChip, + .workspaceSelectTooltipTrigger, + .workspaceSelectTrigger, + .modeToolBtn, + .modelToolBtn { + width: 28px; + max-width: 28px; + flex: 0 0 28px; + gap: 0; padding: 0 6px; - gap: 4px; + justify-content: center; + box-sizing: border-box; + } + + .toolbarLeft .workspaceSelectTrigger [data-slot='select-value'], + .workspaceSelectTrigger > svg:last-child { + display: none; } } @@ -1213,6 +1231,41 @@ color: var(--chat-editor-text-primary); } +.workspaceSelectTrigger { + min-width: 0; + max-width: 176px; + align-items: center; + box-shadow: none; +} + +.workspaceSelectTrigger[data-disabled] { + cursor: default; + opacity: 1; +} + +.workspaceSelectTrigger > svg { + display: block; + flex-shrink: 0; + align-self: center; +} + +.workspaceSelectTooltipTrigger { + display: inline-flex; + min-width: 0; + max-width: 176px; +} + +.workspaceSelectTrigger [data-slot='select-value'] { + display: block; + min-width: 0; + flex: 1 1 auto; + align-self: center; + line-height: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .toolBtn[data-tooltip]:hover::after, .toolBtn[data-tooltip]:focus-visible::after { position: absolute; diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index bbe5126d4d3..e1f59384387 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -17,6 +17,7 @@ import type { UseDaemonFollowupSuggestionReturn } from '@qwen-code/webui/daemon- import type { CommandDisplayCategoryOrder } from '../utils/commandDisplay'; import type { SkillInfo } from '../completions/slashCompletion'; import { useI18n } from '../i18n'; +import { useWebShellPortalRoot } from '../portalRoot'; import { useWebShellCustomization, type WebShellComposerInput, @@ -44,6 +45,21 @@ import { getModelDisplayName } from '../utils/modelDisplay'; import { VoiceButton } from '../voice/VoiceButton'; import { GitBranchIndicator } from './GitBranchIndicator'; import { WorkspaceIndicator } from './WorkspaceIndicator'; +import { FolderClosedIcon } from 'lucide-react'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from './ui/select'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from './ui/tooltip'; import { filterToolbarDropdownItems, getToolbarDropdownGeometry, @@ -112,6 +128,16 @@ interface ChatEditorProps { availableModels?: Array<{ id: string; label?: string }>; onSelectMode?: (mode: string) => void; onSelectModel?: (model: string) => void; + workspaces?: Array<{ + id: string; + cwd: string; + label: string; + primary: boolean; + }>; + selectedWorkspaceCwd?: string; + workspaceSelectionDisabled?: boolean; + onSelectWorkspace?: (workspaceCwd: string | undefined) => void; + atWorkspaceCwd?: string; onChatWidthModeChange?: (mode: '1000' | 'wide') => void; onFocusFooter?: () => boolean; dialogOpen?: boolean; @@ -729,6 +755,7 @@ function SlashCommandPanel({ onSelect: (index: number) => boolean; onAccept: (index?: number) => boolean; }) { + const portalRoot = useWebShellPortalRoot(); const itemRefs = useRef>([]); const [anchorRect, setAnchorRect] = useState<{ left: number; @@ -954,7 +981,7 @@ function SlashCommandPanel({ )} , - document.body, + portalRoot ?? document.body, ); } @@ -1037,6 +1064,11 @@ export const ChatEditor = memo( availableModels = [], onSelectMode, onSelectModel, + workspaces, + selectedWorkspaceCwd, + workspaceSelectionDisabled = false, + onSelectWorkspace, + atWorkspaceCwd, onChatWidthModeChange, onFocusFooter, dialogOpen = false, @@ -1082,6 +1114,7 @@ export const ChatEditor = memo( composerInputVersion, builtinAtProviders, atProviders, + atWorkspaceCwd, composerTagIcons, renderComposerTag, renderComposerTagTooltip, @@ -1096,6 +1129,7 @@ export const ChatEditor = memo( const [modeDropdownOpen, setModeDropdownOpen] = useState(false); const [modelDropdownOpen, setModelDropdownOpen] = useState(false); const [quickActionsOpen, setQuickActionsOpen] = useState(false); + const [workspaceTooltipOpen, setWorkspaceTooltipOpen] = useState(false); const [showQuickActions, setShowQuickActions] = useState(isTouchLikeDevice); const containerRef = useRef(null); const slashPanelRef = useRef(null); @@ -1109,6 +1143,9 @@ export const ChatEditor = memo( const modelExpandedMeasureRef = useRef(null); const modeBtnRef = useRef(null); const modelBtnRef = useRef(null); + const workspaceSelectTriggerRef = useRef(null); + const suppressWorkspaceTooltipRef = useRef(false); + const workspaceSelectPointerInsideRef = useRef(false); const [widthToggleFits, setWidthToggleFits] = useState(false); const [toolbarLabelVisibility, setToolbarLabelVisibility] = useState({ showModelLabel: false, @@ -1463,6 +1500,14 @@ export const ChatEditor = memo( currentModelLabel, lastConfirmedModelLabel, }); + const selectedWorkspace = workspaces?.find((entry) => + selectedWorkspaceCwd ? entry.cwd === selectedWorkspaceCwd : entry.primary, + ); + const selectedWorkspaceLabel = selectedWorkspace + ? `${selectedWorkspace.label}${ + selectedWorkspace.primary ? ` · ${t('sidebar.workspacePrimary')}` : '' + }` + : ''; useLayoutEffect(() => { if (currentModelLabel && currentModelLabel !== lastConfirmedModelLabel) { @@ -1781,6 +1826,85 @@ export const ChatEditor = memo( )}
+ {workspaces && workspaces.length > 1 && onSelectWorkspace && ( + + )} {workspaceName && showToolbarAction('workspace') && ( { const indicator = container.querySelector(`[aria-label="${ariaLabel}"]`); if (!indicator) throw new Error('branch indicator was not rendered'); expect(indicator.tagName).toBe('OUTPUT'); - expect(indicator.getAttribute('title')).toBe(branch); expect(indicator.textContent).toContain(branch); expect(container.querySelector('button')).toBeNull(); diff --git a/packages/web-shell/client/components/GitBranchIndicator.tsx b/packages/web-shell/client/components/GitBranchIndicator.tsx index 2e5e2ab5be7..7bb110f6ccf 100644 --- a/packages/web-shell/client/components/GitBranchIndicator.tsx +++ b/packages/web-shell/client/components/GitBranchIndicator.tsx @@ -5,6 +5,12 @@ */ import styles from './ChatEditor.module.css'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from './ui/tooltip'; function GitBranchIcon() { return ( @@ -30,16 +36,22 @@ export function GitBranchIndicator({ ariaLabel: string; }) { return ( - - - - - {branch} - + + + + + + + + {branch} + + + {branch} + + ); } diff --git a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx index 89839a58f78..b2f8636f35b 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx @@ -459,8 +459,8 @@ describe('SessionOverviewPanel', () => { expect(cardLabels()).toContain('Beta'); // …tagged with its workspace basename… expect(container!.textContent).toContain('wsB'); - // …while the primary card carries the localized "primary" badge. - expect(container!.textContent).toContain('primary'); + // …while the primary card carries the localized "Primary" badge. + expect(container!.textContent).toContain('Primary'); }); it('does not query other workspaces on a single-workspace daemon', async () => { diff --git a/packages/web-shell/client/components/WorkspaceSessionProvider.tsx b/packages/web-shell/client/components/WorkspaceSessionProvider.tsx new file mode 100644 index 00000000000..578cf7962af --- /dev/null +++ b/packages/web-shell/client/components/WorkspaceSessionProvider.tsx @@ -0,0 +1,92 @@ +import { useEffect, useMemo, useState } from 'react'; +import { WifiOffIcon } from 'lucide-react'; +import { + DaemonSessionProvider, + useWorkspace, +} from '@qwen-code/webui/daemon-react-sdk'; +import { App, type WebShellProps } from '../App'; +import { getTranslator, normalizeLanguage } from '../i18n'; +import { Spinner } from './ui/spinner'; +import { WorkspaceUnavailableState } from './WorkspaceUnavailableState'; + +interface WorkspaceSessionProviderProps { + sessionId?: string; + workspaceId?: string; + clientId?: string; + webShellProps: WebShellProps; +} + +export function WorkspaceSessionProvider({ + sessionId, + workspaceId, + clientId, + webShellProps, +}: WorkspaceSessionProviderProps) { + const workspace = useWorkspace(); + const [usePrimaryNewSession, setUsePrimaryNewSession] = useState(false); + useEffect(() => setUsePrimaryNewSession(false), [sessionId, workspaceId]); + const effectiveSessionId = usePrimaryNewSession ? undefined : sessionId; + const effectiveWorkspaceId = usePrimaryNewSession ? undefined : workspaceId; + const targetWorkspace = workspace.capabilities?.workspaces?.find( + (entry) => entry.id === effectiveWorkspaceId, + ); + const t = useMemo( + () => getTranslator(normalizeLanguage(webShellProps.language)), + [webShellProps.language], + ); + + if (effectiveWorkspaceId && workspace.status === 'error') { + return ( + } + onAction={() => { + void workspace.refreshCapabilities?.().catch(() => {}); + }} + /> + ); + } + if (effectiveWorkspaceId && !workspace.capabilities) { + return ( +
+ + {t('common.loading')} +
+ ); + } + if (effectiveWorkspaceId && !targetWorkspace) { + return ( + { + setUsePrimaryNewSession(true); + webShellProps.onSessionIdChange?.(undefined, undefined); + }} + /> + ); + } + + return ( + + + + ); +} diff --git a/packages/web-shell/client/components/WorkspaceUnavailableState.tsx b/packages/web-shell/client/components/WorkspaceUnavailableState.tsx new file mode 100644 index 00000000000..cbfb050448e --- /dev/null +++ b/packages/web-shell/client/components/WorkspaceUnavailableState.tsx @@ -0,0 +1,48 @@ +import { FolderXIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { Button } from './ui/button'; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from './ui/empty'; + +interface WorkspaceUnavailableStateProps { + title: string; + description: string; + actionLabel: string; + onAction: () => void; + theme?: 'dark' | 'light'; + icon?: ReactNode; +} + +export function WorkspaceUnavailableState({ + title, + description, + actionLabel, + onAction, + theme, + icon, +}: WorkspaceUnavailableStateProps) { + return ( +
+ + + {icon ?? } + {title} + {description} + + + + + +
+ ); +} diff --git a/packages/web-shell/client/components/dialogs/AddWorkspaceDialog.test.tsx b/packages/web-shell/client/components/dialogs/AddWorkspaceDialog.test.tsx index cf76da7e8ce..3fd42de9fc2 100644 --- a/packages/web-shell/client/components/dialogs/AddWorkspaceDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/AddWorkspaceDialog.test.tsx @@ -96,7 +96,30 @@ describe('AddWorkspaceDialog', () => { await Promise.resolve(); }); - expect(onAdd).toHaveBeenCalledWith('/abs/project'); + expect(onAdd).toHaveBeenCalledWith('/abs/project', true); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('submits with persist=false when the switch is toggled off', async () => { + const onAdd = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); + mount(); + + // Toggle the persist switch off (Radix renders it as a button[role="switch"]). + const sw = document.querySelector( + '#add-workspace-persist', + )!; + act(() => { + sw.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + type('/abs/project'); + submit(); + await act(async () => { + await Promise.resolve(); + }); + + expect(onAdd).toHaveBeenCalledWith('/abs/project', false); expect(onClose).toHaveBeenCalledTimes(1); }); @@ -126,7 +149,7 @@ describe('AddWorkspaceDialog', () => { await Promise.resolve(); }); - expect(onAdd).toHaveBeenCalledWith('C:\\Users\\me\\project'); + expect(onAdd).toHaveBeenCalledWith('C:\\Users\\me\\project', true); expect(onClose).toHaveBeenCalledTimes(1); expect(alert()).toBeNull(); }); diff --git a/packages/web-shell/client/components/dialogs/AddWorkspaceDialog.tsx b/packages/web-shell/client/components/dialogs/AddWorkspaceDialog.tsx index c09386b1c3e..c577991253b 100644 --- a/packages/web-shell/client/components/dialogs/AddWorkspaceDialog.tsx +++ b/packages/web-shell/client/components/dialogs/AddWorkspaceDialog.tsx @@ -1,12 +1,21 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { useI18n } from '../../i18n'; -import { dp } from './dialogStyles'; import { DialogShell } from './DialogShell'; -import styles from './AddWorkspaceDialog.module.css'; +import { Button } from '../ui/button'; +import { + Field, + FieldContent, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, +} from '../ui/field'; +import { Input } from '../ui/input'; +import { Switch } from '../ui/switch'; interface AddWorkspaceDialogProps { onClose: () => void; - onAdd: (cwd: string) => Promise; + onAdd: (cwd: string, persist: boolean) => Promise; } const HINT_ID = 'add-workspace-hint'; @@ -20,6 +29,7 @@ export function AddWorkspaceDialog({ const [path, setPath] = useState(''); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); + const [persist, setPersist] = useState(true); const inputRef = useRef(null); useEffect(() => { @@ -38,7 +48,7 @@ export function AddWorkspaceDialog({ setError(null); setSubmitting(true); try { - await onAdd(trimmed); + await onAdd(trimmed, persist); onClose(); } catch (err) { setError( @@ -48,7 +58,7 @@ export function AddWorkspaceDialog({ setSubmitting(false); } }, - [path, onAdd, onClose, t], + [path, persist, onAdd, onClose, t], ); return ( @@ -57,56 +67,66 @@ export function AddWorkspaceDialog({ size="md" onClose={onClose} > -
-
- - { - setPath(e.target.value); - if (error) setError(null); - }} - disabled={submitting} - autoCapitalize="off" - autoCorrect="off" - autoComplete="off" - spellCheck={false} - aria-describedby={error ? `${ERROR_ID} ${HINT_ID}` : HINT_ID} - aria-invalid={error ? true : undefined} - /> - - {t('sidebar.addWorkspaceHint')} - - {error && ( - - {error} - - )} -
-
- - + +
diff --git a/packages/web-shell/client/components/dialogs/DialogShell.test.tsx b/packages/web-shell/client/components/dialogs/DialogShell.test.tsx index 7351620955e..0f9720edea5 100644 --- a/packages/web-shell/client/components/dialogs/DialogShell.test.tsx +++ b/packages/web-shell/client/components/dialogs/DialogShell.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest'; -import { act, useEffect, useRef } from 'react'; +import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { I18nProvider } from '../../i18n'; import { ThemeProvider } from '../../themeContext'; @@ -11,25 +11,23 @@ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); let container: HTMLDivElement | null = null; let root: Root | null = null; -function mount(node: React.ReactNode) { - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - act(() => { - root!.render( - - {node} - , - ); - }); -} - -function press(key: string, options: KeyboardEventInit = {}) { - act(() => { - document.dispatchEvent( - new KeyboardEvent('keydown', { key, cancelable: true, ...options }), - ); - }); +function render(showBottom: boolean, onTopClose = vi.fn()) { + root!.render( + + + {showBottom && ( + + + + )} + + + + + , + ); } afterEach(() => { @@ -39,444 +37,84 @@ afterEach(() => { container = null; }); -function AutofocusChild() { - const inputRef = useRef(null); - - useEffect(() => { - inputRef.current?.focus(); - }, []); - - return ; -} - describe('DialogShell', () => { - it('hides the fullscreen toggle by default', () => { - mount( - {}}> -
content
-
, - ); - // DialogShell portals into document.body, so query there, not the root. - expect(document.querySelector('button[aria-pressed]')).toBeNull(); - }); - - it('shows a fullscreen toggle when allowFullscreen and toggles its state', () => { - mount( - {}}> -
content
-
, - ); - const toggle = document.querySelector( - 'button[aria-pressed]', - ) as HTMLElement | null; - expect(toggle).toBeTruthy(); - expect(toggle!.getAttribute('aria-pressed')).toBe('false'); - act(() => { - toggle!.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - expect(toggle!.getAttribute('aria-pressed')).toBe('true'); - }); - - it('closes on Escape', () => { - const onClose = vi.fn(); - mount( - - - , - ); - - press('Escape'); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('ignores Escape that belongs to an IME composition', () => { - const onClose = vi.fn(); - mount( - - - , - ); - - // Chrome/Firefox: Escape cancelling a composition reports isComposing. - press('Escape', { isComposing: true }); - expect(onClose).not.toHaveBeenCalled(); - - // WebKit: compositionend fires first, so only keyCode 229 marks the key. - act(() => { - const imeEscape = new KeyboardEvent('keydown', { - key: 'Escape', - cancelable: true, - }); - Object.defineProperty(imeEscape, 'keyCode', { value: 229 }); - document.dispatchEvent(imeEscape); - }); - expect(onClose).not.toHaveBeenCalled(); - - // A genuine Escape still closes. - press('Escape'); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('keeps focus on the panel when Tab is pressed with nothing focusable inside', () => { - mount( - - - , - ); - - const panel = document.querySelector('[role="dialog"]')!; - // Simulate content whose focusables all went away (e.g. everything became - // disabled/hidden while an action runs). - document.querySelector('[data-dialog-close]')!.remove(); - document.querySelector('[data-testid="inner"]')!.remove(); - - panel.focus(); - act(() => { - document.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Tab', - bubbles: true, - cancelable: true, - }), - ); - }); - // Tab must not escape to the page behind; focus stays parked on the panel. - expect(document.activeElement).toBe(panel); - }); - - it('lets a dialog control consume Escape instead of closing', () => { - const onClose = vi.fn(); - mount( - - { - // e.g. an inline editor cancelling its edit on Escape. - if (event.key === 'Escape') event.preventDefault(); - }} - /> - , - ); - - const input = document.querySelector('input')!; - input.focus(); - act(() => { - input.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Escape', - bubbles: true, - cancelable: true, - }), - ); - }); - expect(onClose).not.toHaveBeenCalled(); - }); - - it('only the topmost of stacked shells handles Escape', () => { - const onCloseBottom = vi.fn(); - const onCloseTop = vi.fn(); - mount( - <> - - - - - - - , - ); - - // One Escape peels off one layer — the top one — not both at once. - press('Escape'); - expect(onCloseTop).toHaveBeenCalledTimes(1); - expect(onCloseBottom).not.toHaveBeenCalled(); - }); - - it('keeps focus inside the top shell if a lower shell unmounts first', () => { - const opener = document.createElement('button'); - document.body.appendChild(opener); - opener.focus(); - - function Harness({ showBottom }: { showBottom: boolean }) { - return ( - <> - {showBottom ? ( - - - - ) : null} - - - - - ); - } - - mount(); - const topButton = document.querySelector( - '[data-testid="top-focus"]', - )!; - expect(document.activeElement).toBe(topButton); - - act(() => { - root!.render( - - - - - , - ); - }); - - // Focus must stay inside the remaining top shell, not jump back behind it. - expect(document.activeElement).toBe(topButton); - opener.remove(); - }); + it('restores focus to the remaining top shell when a lower shell unmounts', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); - it('restores focus to the remaining top shell when the lower shell unmounts after focus moved', () => { - const opener = document.createElement('button'); - document.body.appendChild(opener); - opener.focus(); - - function Harness({ showBottom }: { showBottom: boolean }) { - return ( - <> - {showBottom ? ( - - - - ) : null} - - - - - ); - } - - mount(); + act(() => render(true)); const topButton = document.querySelector( '[data-testid="top-focus"]', )!; document.querySelector('button:not([data-testid])')!.focus(); - act(() => { - root!.render( - - - - - , - ); - }); + act(() => render(false)); expect(document.activeElement).toBe(topButton); - opener.remove(); }); - it('closes when the backdrop is clicked but not when the panel is clicked', () => { + it('leaves an IME Escape event unhandled', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); const onClose = vi.fn(); - mount( - - - , - ); - - const backdrop = document.querySelector( - '[data-keyboard-scope]', - ); - const panel = document.querySelector('[role="dialog"]'); - expect(backdrop).toBeTruthy(); - act(() => { - panel!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); - panel!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + act(() => render(false, onClose)); + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + isComposing: true, + key: 'Escape', }); - expect(onClose).not.toHaveBeenCalled(); + const target = document.querySelector( + '[data-testid="top-focus"]', + )!; + act(() => target.dispatchEvent(event)); - act(() => { - backdrop!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); - backdrop!.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); - backdrop!.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - expect(onClose).toHaveBeenCalledTimes(1); + expect(event.defaultPrevented).toBe(false); + expect(event.key).toBe('Escape'); + expect(onClose).not.toHaveBeenCalled(); }); - it('does not close when a drag starts in the panel and ends on the backdrop', () => { + it('closes once when the backdrop is clicked', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); const onClose = vi.fn(); - mount( - - - , - ); + act(() => render(false, onClose)); const backdrop = document.querySelector( - '[data-keyboard-scope]', + '[data-slot="dialog-overlay"]', )!; - const panel = document.querySelector('[role="dialog"]')!; - act(() => { - panel.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + backdrop.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + backdrop.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); backdrop.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); backdrop.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); - expect(onClose).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); }); - it('does not close when a press starts on the backdrop and ends on the panel', () => { + it('stays open when a drag starts in the panel and ends on the backdrop', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); const onClose = vi.fn(); - mount( - - - , - ); + act(() => render(false, onClose)); const backdrop = document.querySelector( - '[data-keyboard-scope]', + '[data-slot="dialog-overlay"]', )!; const panel = document.querySelector('[role="dialog"]')!; - act(() => { - backdrop.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); - panel.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); - // Browsers synthesize click on the nearest common ancestor for mismatched - // press/release targets; here that's effectively the backdrop. + panel.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + panel.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + backdrop.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); backdrop.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(onClose).not.toHaveBeenCalled(); }); - - it('moves focus into the dialog on open', () => { - mount( - - - - , - ); - - const first = document.querySelector('[data-testid="first"]'); - expect(document.activeElement).toBe(first); - }); - - it('restores focus to the opener on close', () => { - const opener = document.createElement('button'); - document.body.appendChild(opener); - opener.focus(); - expect(document.activeElement).toBe(opener); - - mount( - - - , - ); - // Focus moved into the dialog. - expect(document.activeElement).not.toBe(opener); - - act(() => root?.unmount()); - root = null; - expect(document.activeElement).toBe(opener); - opener.remove(); - }); - - it('restores focus to the opener even if a child autofocuses first', () => { - const opener = document.createElement('button'); - document.body.appendChild(opener); - opener.focus(); - expect(document.activeElement).toBe(opener); - - mount( - - - , - ); - - const input = document.querySelector('input'); - expect(document.activeElement).toBe(input); - - act(() => root?.unmount()); - root = null; - expect(document.activeElement).toBe(opener); - opener.remove(); - }); - - it('traps Tab within the dialog, wrapping at both ends', () => { - mount( - - - , - ); - - // Focusables in DOM order: [close button, inner button]. - const close = document.querySelector('[data-dialog-close]')!; - const last = document.querySelector('[data-testid="last"]')!; - - // Tab from the last focusable wraps to the first (the close button). - last.focus(); - act(() => { - document.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), - ); - }); - expect(document.activeElement).toBe(close); - - // Shift+Tab from the first focusable wraps to the last. - close.focus(); - act(() => { - document.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Tab', - shiftKey: true, - bubbles: true, - }), - ); - }); - expect(document.activeElement).toBe(last); - }); - - it('pulls focus into the dialog when Tab is pressed while the panel holds focus', () => { - mount( - - - , - ); - - const panel = document.querySelector('[role="dialog"]')!; - const close = document.querySelector('[data-dialog-close]')!; - const last = document.querySelector('[data-testid="last"]')!; - - // Focus sits on the panel itself (roving-list fallback). Tab pulls it in. - panel.focus(); - act(() => { - document.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), - ); - }); - expect(document.activeElement).toBe(close); - - // Shift+Tab from the panel pulls in from the end instead. - panel.focus(); - act(() => { - document.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Tab', - shiftKey: true, - bubbles: true, - }), - ); - }); - expect(document.activeElement).toBe(last); - }); }); diff --git a/packages/web-shell/client/components/dialogs/DialogShell.tsx b/packages/web-shell/client/components/dialogs/DialogShell.tsx index 2071fd33309..092a395b90e 100644 --- a/packages/web-shell/client/components/dialogs/DialogShell.tsx +++ b/packages/web-shell/client/components/dialogs/DialogShell.tsx @@ -3,11 +3,21 @@ import { useEffect, useRef, useState, + type MouseEvent as ReactMouseEvent, type ReactNode, } from 'react'; -import { createPortal } from 'react-dom'; +import { Maximize2Icon, Minimize2Icon, XIcon } from 'lucide-react'; import { useI18n } from '../../i18n'; import { useTheme, WebShellThemeId } from '../../themeContext'; +import { Button } from '../ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '../ui/dialog'; import styles from './DialogShell.module.css'; type DialogSize = 'sm' | 'md' | 'lg' | 'xl'; @@ -16,18 +26,16 @@ interface DialogShellProps { title: string; subtitle?: string; size?: DialogSize; - /** Show a header toggle that expands the panel to (near) the full viewport. - * For content-heavy dialogs like Daemon Status; off by default. */ allowFullscreen?: boolean; onClose: () => void; children: ReactNode; } const sizeClass: Record = { - sm: styles.sizeSm, - md: styles.sizeMd, - lg: styles.sizeLg, - xl: styles.sizeXl, + sm: 'sm:max-w-[420px]', + md: 'sm:max-w-[560px]', + lg: 'sm:max-w-[720px]', + xl: 'sm:max-w-[900px]', }; const FOCUSABLE_SELECTOR = [ @@ -46,19 +54,11 @@ function getFocusable(container: HTMLElement | null): HTMLElement[] { ); } -// Mounted shells, bottom → top. Every shell listens on `document`, and -// `stopPropagation` cannot silence sibling listeners on the same node — so -// without this, one Escape would close every stacked dialog at once (and the -// bottom one would win any race, since it registered first). Only the topmost -// shell handles keys; stacked dialogs peel off one layer per Escape. const shellStack: object[] = []; export const DialogShellIdContext = createContext(null); export function isTopDialogShellId(shellId: object | null): boolean { - // Most production callers live inside DialogShell and get a shell id. Tests or - // any future standalone consumer may not; in that case, preserve the original - // single-dialog behavior and allow the hook to handle keys normally. if (shellId === null) return true; return shellStack[shellStack.length - 1] === shellId; } @@ -72,183 +72,155 @@ export function DialogShell({ children, }: DialogShellProps) { const { t } = useI18n(); - const [fullscreen, setFullscreen] = useState(false); const theme = useTheme(); - const themeClass = - theme === WebShellThemeId.Light ? styles.themeLight : styles.themeDark; - const panelRef = useRef(null); - // `onClose` may change identity across renders; keep the latest for the - // once-bound key listener. + const [fullscreen, setFullscreen] = useState(false); + const panelRef = useRef(null); const onCloseRef = useRef(onClose); onCloseRef.current = onClose; - // Capture the opener during the dialog's first render, before any child - // effects can move focus into an autofocused search field. const [previouslyFocused] = useState(() => typeof document !== 'undefined' ? (document.activeElement as HTMLElement | null) : null, ); - // A completed backdrop click should close, but any drag that crosses the - // panel boundary in either direction must not. Record whether the press both - // started and ended on the backdrop itself, then let the synthesized click - // close only when both are true. const backdropPressStartedRef = useRef(false); const backdropPressEndedRef = useRef(false); - // Identity token for this shell instance in the module-level stack. const shellIdRef = useRef(null); if (shellIdRef.current === null) shellIdRef.current = {}; - // Move focus into the dialog on open, restore it to the opener on close, and - // trap Tab within the panel. Escape closes. useEffect(() => { - const panel = panelRef.current; const shellId = shellIdRef.current!; shellStack.push(shellId); - - // Autofocus: respect a child that already claimed focus (e.g. a search - // input's own effect); otherwise focus the first content focusable (skipping - // the header close button), else the panel itself. Falling back to the panel - // rather than the close button avoids a stray focus ring when a list dialog's - // options are managed via a roving highlight (tabIndex=-1) instead of focus. - if (panel && !panel.contains(document.activeElement)) { - const focusables = getFocusable(panel); - const preferred = focusables.find( - (el) => !el.hasAttribute('data-dialog-close'), - ); - (preferred ?? panel).focus(); - } - - const handleKeyDown = (event: KeyboardEvent) => { - // With stacked dialogs, only the topmost shell may handle Escape/Tab — - // a lower shell closing or trapping focus would act "through" the one - // covering it. - if (shellStack[shellStack.length - 1] !== shellId) return; - // A control inside the dialog may consume the key first (e.g. Escape to - // cancel an inline edit) — honor that instead of dismissing the dialog. - if (event.defaultPrevented) return; - // Escape mid-IME-composition cancels the composition, not the dialog. - // keyCode 229 covers WebKit, which fires compositionend before the - // committing key's keydown (see useListboxKeyboard for the same guard). - if (event.isComposing || event.keyCode === 229) return; - if (event.key === 'Escape') { - event.preventDefault(); - event.stopPropagation(); - onCloseRef.current(); + const preserveImeEscape = (event: KeyboardEvent) => { + if ( + event.key !== 'Escape' || + (!event.isComposing && event.keyCode !== 229) || + !isTopDialogShellId(shellId) + ) { return; } - if (event.key === 'Tab') { - const focusables = getFocusable(panelRef.current); - if (focusables.length === 0) { - // Nothing focusable inside — keep focus on the panel itself. - event.preventDefault(); - panelRef.current?.focus(); - return; - } - const first = focusables[0]; - const last = focusables[focusables.length - 1]; - const activeEl = document.activeElement; - const insideList = focusables.includes(activeEl as HTMLElement); - if (!insideList) { - // Focus is on the panel itself (e.g. a roving-highlight list where the - // options are tabIndex=-1) — pull it into the dialog so Tab can't - // escape to the page behind. - event.preventDefault(); - (event.shiftKey ? last : first).focus(); - } else if (event.shiftKey && activeEl === first) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && activeEl === last) { - event.preventDefault(); - first.focus(); - } - } + // Radix handles Escape on document capture and otherwise prevents the + // native IME cancellation. Mask it only for Radix, then restore it before + // the event continues to the focused input. + Object.defineProperty(event, 'key', { + configurable: true, + value: 'Process', + }); + document.addEventListener( + 'keydown', + (currentEvent) => { + if (currentEvent === event) Reflect.deleteProperty(event, 'key'); + }, + { capture: true, once: true }, + ); }; + window.addEventListener('keydown', preserveImeEscape, { capture: true }); - // Bubble phase on `document`, deliberately positioned in the middle of the - // propagation chain: controls inside the dialog run first (and can consume - // Escape via preventDefault, honored above), while `window`-level listeners - // — the app's global shortcuts and useListboxKeyboard — run after, so the - // stopPropagation on Escape still shields them. Moving this listener to the - // capture phase would steal Escape from the dialog's own controls; moving - // it to `window` would lose the race with the app-level handlers. - document.addEventListener('keydown', handleKeyDown); return () => { - document.removeEventListener('keydown', handleKeyDown); - const idx = shellStack.indexOf(shellId); - if (idx >= 0) shellStack.splice(idx, 1); + window.removeEventListener('keydown', preserveImeEscape, { + capture: true, + }); + const index = shellStack.indexOf(shellId); + if (index >= 0) shellStack.splice(index, 1); if (shellStack.length === 0) { previouslyFocused?.focus?.(); return; } - // Another modal is still stacked above the page — keep focus inside the - // remaining top shell instead of restoring it behind the modal layer. const scopes = Array.from( document.querySelectorAll('[data-keyboard-scope]'), ); - const topPanel = - scopes[scopes.length - 1]?.querySelector( - '[role="dialog"]', - ); - const topFocusables = getFocusable(topPanel); - const preferred = topFocusables.find( - (el) => !el.hasAttribute('data-dialog-close'), + const topPanel = scopes[scopes.length - 1]; + const preferred = getFocusable(topPanel).find( + (element) => !element.hasAttribute('data-dialog-close'), ); (preferred ?? topPanel)?.focus(); }; }, [previouslyFocused]); - const handleBackdropMouseDown = (event: React.MouseEvent) => { + const handleBackdropMouseDown = (event: ReactMouseEvent) => { backdropPressStartedRef.current = event.target === event.currentTarget; backdropPressEndedRef.current = false; }; - const handleBackdropMouseUp = (event: React.MouseEvent) => { + const handleBackdropMouseUp = (event: ReactMouseEvent) => { backdropPressEndedRef.current = event.target === event.currentTarget; }; - const handleBackdropClick = (event: React.MouseEvent) => { + const handleBackdropClick = (event: ReactMouseEvent) => { const shouldClose = backdropPressStartedRef.current && backdropPressEndedRef.current && event.target === event.currentTarget; backdropPressStartedRef.current = false; backdropPressEndedRef.current = false; - if (shouldClose) { - onClose(); - } + if (shouldClose) onClose(); }; - const content = ( -
{ + if (!open) onClose(); + }} > -
event.preventDefault()} + onEscapeKeyDown={(event) => { + if (event.defaultPrevented) return; + if (event.isComposing || event.keyCode === 229) { + return; + } + if (!isTopDialogShellId(shellIdRef.current)) { + return; + } + event.preventDefault(); + onCloseRef.current(); + }} + onOpenAutoFocus={(event) => { + event.preventDefault(); + const preferred = getFocusable(panelRef.current).find( + (element) => !element.hasAttribute('data-dialog-close'), + ); + (preferred ?? panelRef.current)?.focus(); + }} + onCloseAutoFocus={(event) => event.preventDefault()} > -
-
-
{title}
- {subtitle &&
{subtitle}
} + +
+ {title} + {subtitle && ( + + {subtitle} + + )}
{allowFullscreen && ( - + {fullscreen ? : } + )} -
+ + + +
{children}
-
+
-
+ ); - - if (typeof document === 'undefined') return content; - return createPortal(content, document.body); } diff --git a/packages/web-shell/client/components/sidebar/SessionGroupSection.tsx b/packages/web-shell/client/components/sidebar/SessionGroupSection.tsx new file mode 100644 index 00000000000..019823d8010 --- /dev/null +++ b/packages/web-shell/client/components/sidebar/SessionGroupSection.tsx @@ -0,0 +1,101 @@ +import type { CSSProperties, ReactNode } from 'react'; +import type { DaemonSessionGroupColor } from '@qwen-code/sdk/daemon'; +import { + ChevronDownIcon, + ChevronRightIcon, + PencilIcon, + Trash2Icon, +} from 'lucide-react'; +import styles from './WebShellSidebar.module.css'; + +export interface SessionGroupSectionProps { + id: string; + label: string; + count: number; + expanded: boolean; + color?: DaemonSessionGroupColor; + children: ReactNode; + onToggle: () => void; + onRename?: () => void; + onDelete?: () => void; + renameLabel?: string; + deleteLabel?: string; + actionsDisabled?: boolean; +} + +export function SessionGroupSection({ + label, + count, + expanded, + color, + children, + onToggle, + onRename, + onDelete, + renameLabel, + deleteLabel, + actionsDisabled, +}: SessionGroupSectionProps) { + const colorClass = color?.startsWith('#') + ? styles.groupColorCustom + : color + ? styles[ + `groupColor${color[0]!.toUpperCase()}${color.slice(1)}` as keyof typeof styles + ] + : styles.sessionGroupDotMuted; + const dotStyle: CSSProperties | undefined = color?.startsWith('#') + ? ({ '--session-group-custom-color': color } as CSSProperties) + : undefined; + return ( +
+
+ + {(onRename || onDelete) && ( +
+ {onRename && ( + + )} + {onDelete && ( + + )} +
+ )} +
+ {expanded &&
{children}
} +
+ ); +} diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css index faf297406f8..94186086844 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css @@ -6,7 +6,6 @@ min-height: 0; display: flex; flex-direction: column; - gap: 12px; padding: 12px; overflow: hidden; background: var(--sidebar-background); @@ -29,6 +28,7 @@ } .newChatButton, +.pluginButton, .footerButton, .collapseButton, .projectRow, @@ -50,23 +50,40 @@ } .newChatButton, +.pluginButton, .footerButton, .projectRow { width: 100%; - height: 40px; display: flex; align-items: center; gap: 5px; - padding: 0 8px; + padding: 0 4px; border-radius: 8px; color: var(--sidebar-foreground); cursor: pointer; } +.newChatButton, +.pluginButton { + height: 32px; +} + .newChatButton { - background: var(--sidebar-accent); + background: transparent; color: var(--sidebar-accent-foreground); - padding: 8px 4px; +} + +.pluginButton { + flex: 0 0 auto; +} + +.primaryNav { + display: flex; + width: 100%; + flex: 0 0 auto; + flex-direction: column; + gap: 2px; + padding: 12px 0; } .newChatButton:disabled { @@ -75,6 +92,7 @@ } .newChatButton:hover, +.pluginButton:hover, .footerButton:hover, .projectRow:hover, .sessionRow:hover, @@ -94,6 +112,7 @@ } .newChatButton:focus-visible, +.pluginButton:focus-visible, .footerButton:focus-visible, .projectRow:focus-visible, .sessionRow:focus-visible, @@ -120,24 +139,17 @@ .navIcon svg, .iconButton svg, .collapseButton svg { - width: 18px; - height: 18px; display: block; fill: none; stroke: currentColor; - stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } -.settingsIcon svg { - width: 16px; - height: 16px; - stroke-width: 1.6; -} - -.projectFolderIcon svg { - stroke-width: 1.5; +.iconButton svg { + width: 18px; + height: 18px; + stroke-width: 1.8; } .topRow { @@ -145,6 +157,8 @@ display: flex; align-items: center; gap: 8px; + padding: 0 4px 12px; + border-bottom: 1px solid var(--sidebar-border); } .topRow .newChatButton { @@ -169,6 +183,17 @@ display: block; } +.brandName { + min-width: 0; + overflow: hidden; + color: var(--sidebar-foreground); + font-size: 18px; + font-weight: 500; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + .collapsed .topRow { width: 100%; justify-content: center; @@ -183,7 +208,6 @@ display: flex; flex-direction: column; gap: 4px; - padding: 8px 8px 0; } .workspacePickerLabel { @@ -226,6 +250,110 @@ gap: 2px; } +.workspaceSessionBody { + margin-bottom: 12px; +} + +.projectsHeader { + display: flex; + flex: 0 0 auto; + align-items: center; + height: 32px; + gap: 4px; + padding-left: 8px; + color: var(--muted-foreground); +} + +.pinnedSessionList { + display: flex; + flex: 0 0 auto; + flex-direction: column; + gap: 2px; + padding-bottom: 12px; +} + +.projectsHeaderToggle, +.projectsHeaderAction { + appearance: none; + border: 0; + background: transparent; + color: inherit; + font: inherit; +} + +.projectsHeaderActions { + display: flex; + margin-left: auto; + align-items: center; + gap: 2px; +} + +.projectsHeaderActions .projectsHeaderAction { + margin-left: 0; +} + +.projectsHeaderToggle { + display: inline-flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 4px; + padding: 0; + font-size: 13px; + font-weight: 500; + cursor: pointer; +} + +.projectsHeaderToggle svg, +.projectsHeaderAction svg { + width: 15px; + height: 15px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.projectsHeaderAction { + display: inline-flex; + width: 24px; + height: 24px; + margin-left: auto; + align-items: center; + justify-content: center; + border-radius: 6px; + cursor: pointer; +} + +.projectsHeaderAction:hover, +.projectsHeaderAction:focus-visible { + background: var(--sidebar-accent); + color: var(--sidebar-accent-foreground); +} + +.projectSearch { + position: relative; + flex: 0 0 auto; +} + +.projectSearch > svg { + position: absolute; + z-index: 1; + top: 50%; + left: 10px; + width: 15px; + height: 15px; + color: var(--muted-foreground); + pointer-events: none; + transform: translateY(-50%); +} + +.projectSearch input { + width: 100%; + padding-left: 32px; +} + .addWorkspaceButton { display: flex; align-items: center; @@ -252,6 +380,7 @@ display: flex; flex-direction: column; gap: 8px; + margin-right: -12px; overflow: hidden; } @@ -270,25 +399,71 @@ line-height: 22px; } +.projectRowExpanded { +} + .projectIconButton { + width: 16px; + height: 16px; + flex: 0 0 16px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 6px; + color: var(--muted-foreground); + cursor: pointer; +} + +.projectIconButton svg { + width: 14px; + height: 14px; + display: block; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.workspaceHeaderActions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 2px; + margin-right: 8px; +} + +.workspaceHeaderAction { + appearance: none; width: 24px; height: 24px; flex: 0 0 24px; display: inline-flex; align-items: center; justify-content: center; + padding: 0; + border: 0; border-radius: 6px; + background: transparent; color: var(--muted-foreground); cursor: pointer; } -.projectIconButton svg { - width: 15px; - height: 15px; +.workspaceHeaderAction:hover, +.workspaceHeaderAction:focus-visible { + background: var(--sidebar-accent); + color: var(--sidebar-accent-foreground); +} + +.workspaceHeaderAction:focus-visible { + outline: 2px solid var(--sidebar-ring); + outline-offset: 2px; +} + +.workspaceHeaderAction svg { display: block; fill: none; stroke: currentColor; - stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } @@ -309,14 +484,28 @@ margin-left: auto; } +.projectHoverAction { + opacity: 0; +} + +.projectRow:hover .projectHoverAction, +.projectRow:focus-within .projectHoverAction { + opacity: 1; +} + .sessionList { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: 4px; - overflow: auto; - padding-bottom: 48px; + padding-right: 12px; + padding-bottom: 44px; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + touch-action: pan-y; } .sessionGroupSection { @@ -330,6 +519,18 @@ display: flex; align-items: center; gap: 2px; + border-radius: 8px; +} + +.sessionGroupHeaderRow:hover, +.sessionGroupHeaderRow:focus-within { + background: var(--sidebar-accent); + color: var(--sidebar-accent-foreground); +} + +.sessionGroupHeaderRow .sessionGroupHeader:hover, +.sessionGroupHeaderRow .sessionGroupHeader:focus-visible { + background: transparent; } .sessionGroupHeader { @@ -356,6 +557,10 @@ border-radius: 999px; } +.sessionGroupDotMuted { + background: var(--muted-foreground); +} + .sessionGroupTitle { min-width: 0; overflow: hidden; @@ -378,8 +583,7 @@ } .sessionGroupChevron svg, -.sessionGroupActionButton svg, -.sessionPinnedIndicator svg { +.sessionGroupActionButton svg { width: 13px; height: 13px; display: block; @@ -432,9 +636,9 @@ display: flex; align-items: center; gap: 6px; - padding: 4px 8px; - margin-left: 26px; + padding-left: 26px; border-radius: 8px; + color: color-mix(in srgb, var(--sidebar-foreground) 68%, transparent); cursor: pointer; } @@ -455,10 +659,6 @@ color: var(--sidebar-accent-foreground); } -.pinnedSession:not(.currentSession) { - color: color-mix(in srgb, var(--foreground) 92%, var(--agent-blue-500)); -} - .busySession { opacity: 0.72; pointer-events: none; @@ -467,8 +667,8 @@ .floatingTooltip { position: fixed; z-index: 1000; - max-width: calc(100vw - 16px); - min-width: 0; + max-width: 320px; + min-width: 220px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; @@ -494,11 +694,6 @@ content: ''; } -.floatingTooltipLeft::before { - right: -12px; - left: auto; -} - .tooltipContent { display: flex; flex-direction: column; @@ -551,7 +746,7 @@ .sessionText { flex: 1 1 auto; - margin-right: 0; + margin-right: 18px; color: currentColor; font-size: 14px; line-height: 22px; @@ -559,12 +754,14 @@ .sessionStatusSlot { position: absolute; - left: -15px; - width: 8px; - height: 8px; + top: 50%; + left: 7px; + width: 18px; + height: 18px; display: inline-flex; align-items: center; justify-content: center; + transform: translateY(-50%); } .sessionStatusDot { @@ -577,25 +774,31 @@ animation: sidebarPulse 1.6s ease-in-out infinite; } -.sessionPinMarker { - width: 12px; - height: 12px; - display: inline-flex; - color: var(--muted-foreground); -} - -.sessionPinnedIndicator { - width: 20px; - height: 20px; +.sessionTitlePin { + width: 18px; + height: 18px; + flex: 0 0 18px; display: inline-flex; align-items: center; justify-content: center; + border-radius: 4px; color: var(--muted-foreground); + cursor: pointer; } -.sessionPinMarker svg { - width: 12px; - height: 12px; +.sessionTitlePin:hover { + background: var(--accent); + color: var(--foreground); +} + +.sessionTitlePin:disabled { + cursor: default; + opacity: 0.5; +} + +.sessionTitlePin svg { + width: 13px; + height: 13px; fill: none; stroke: currentColor; stroke-width: 2; @@ -605,24 +808,29 @@ .sessionMetaSlot { position: relative; - width: auto; - min-width: 0; height: 30px; - flex: 0 0 auto; - margin-left: 8px; + margin-left: auto; display: flex; align-items: center; justify-content: flex-end; } +.sessionRow:hover .sessionMetaSlot, +.sessionMetaSlot:has(.sessionActionButton:focus-visible), +.sessionMetaSlot:has([data-state='open']) { + min-width: 86px; +} + .sessionTime { color: var(--muted-foreground); font-size: 12px; line-height: 18px; white-space: nowrap; + padding-right: 10px; } .sessionLoading { + margin-right: 10px; width: 14px; height: 14px; display: inline-flex; @@ -635,41 +843,32 @@ .sessionRow:hover:not(.runningSession) .sessionTime, .sessionRow:focus-within:not(.runningSession) .sessionTime, .sessionMetaSlot:hover .sessionTime, -.sessionMetaSlot:focus-within .sessionTime, .sessionMetaSlot:hover .sessionLoading, -.sessionMetaSlot:focus-within .sessionLoading { +.sessionMetaSlot:has(.sessionActionButton:focus-visible) .sessionTime, +.sessionMetaSlot:has([data-state='open']) .sessionTime, +.sessionMetaSlot:has(.sessionActionButton:focus-visible) .sessionLoading, +.sessionMetaSlot:has([data-state='open']) .sessionLoading { opacity: 0; } .sessionActions { position: absolute; - top: 0; - right: 0; + inset: 0; display: inline-flex; align-items: center; + justify-content: flex-end; gap: 2px; - padding-left: 6px; - background: linear-gradient( - 90deg, - transparent, - var(--sidebar-background) 8px - ); opacity: 0; } .sessionRow:hover:not(.runningSession) .sessionActions, .sessionRow:focus-within:not(.runningSession) .sessionActions, .sessionMetaSlot:hover .sessionActions, -.sessionMetaSlot:focus-within .sessionActions { +.sessionActions:has(.sessionActionButton:focus-visible), +.sessionActions:has([data-state='open']) { opacity: 1; } -.currentSession .sessionActions, -.sessionRow:hover .sessionActions, -.sessionRow:focus-within .sessionActions { - background: linear-gradient(90deg, transparent, var(--sidebar-accent) 8px); -} - @keyframes sidebarSpin { to { transform: rotate(360deg); @@ -1048,29 +1247,40 @@ } .footer { + position: absolute; + z-index: 5; + right: 12px; + bottom: 4px; + left: 12px; flex: 0 0 auto; display: flex; align-items: center; gap: 8px; - padding-top: 12px; + padding-top: 4px; border-top: 1px solid var(--sidebar-border); + background: var(--sidebar-background); +} + +.footerButton { + min-width: 0; + overflow: hidden; } -.footerLeading, -.footerTrailing { +.footerPrimary, +.footerActions { display: flex; + min-width: 0; align-items: center; gap: 8px; } -.footerTrailing { - margin-left: auto; +.footerPrimary { + flex: 0 1 auto; } -.footerButton { - flex: 0 1 auto; - min-width: 0; - overflow: hidden; +.footerActions { + flex: 0 0 auto; + margin-left: auto; } .footerButtonLabel { @@ -1080,41 +1290,34 @@ text-overflow: ellipsis; } -.footerCompact { +.footerCompact, +.footerTight { gap: 4px; } -.footerCompact .footerLeading, -.footerCompact .footerTrailing { +.footerCompact .footerPrimary, +.footerCompact .footerActions, +.footerTight .footerPrimary, +.footerTight .footerActions { gap: 4px; } -.footerCompact .footerButton { - width: auto; - min-width: 0; +.footerCompact .footerButton, +.footerTight .footerButton { + width: 26px; height: 28px; - flex: 0 0 auto; + flex: 0 0 26px; justify-content: center; padding: 0; } -.footerCompact .footerButtonLabel { +.footerCompact .footerButtonLabel, +.footerTight .footerButtonLabel { display: none; } -.footerCompact .footerButtonWithLabel { - width: auto; - height: 28px; - flex: 0 1 auto; - justify-content: flex-start; - padding: 0 8px; -} - -.footerCompact .footerButtonWithLabel .footerButtonLabel { - display: block; -} - -.footerCompact .collapseButton { +.footerCompact .collapseButton, +.footerTight .collapseButton { width: 26px; height: 28px; } @@ -1133,58 +1336,8 @@ user-select: text; } -.footerMenu { - position: fixed; - z-index: 1100; - min-width: 168px; - padding: 4px; - display: flex; - flex-direction: column; - gap: 1px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--popover); - color: var(--popover-foreground); - box-shadow: - 0 2px 4px -2px rgb(0 0 0 / 10%), - 0 8px 16px -4px rgb(0 0 0 / 18%); -} - -.footerMenuItem, -.footerMenuVersion { - min-width: 0; - height: 32px; - display: flex; - align-items: center; - gap: 8px; - padding: 0 8px; - border: 0; - border-radius: 6px; - background: transparent; - color: inherit; - font: inherit; - font-size: 13px; - line-height: 20px; - text-align: left; -} - -.footerMenuItem { - width: 100%; - cursor: pointer; -} - -.footerMenuItem:hover, -.footerMenuItem:focus-visible { - background: var(--accent); - color: var(--accent-foreground); - outline: none; -} - -.footerMenuVersion { - color: var(--muted-foreground); -} - .collapsed .newChatButton, +.collapsed .pluginButton, .collapsed .footerButton, .collapsed .projectRow { width: 32px; @@ -1195,19 +1348,23 @@ .collapsed .body { align-items: center; width: 100%; + margin-right: 0; } .collapsed .sessionList { align-items: center; width: 100%; + margin-right: 0; + padding-right: 0; + padding-bottom: 220px; } .collapsed .footer { flex-direction: column; } -.collapsed .footerLeading, -.collapsed .footerTrailing { +.collapsed .footerPrimary, +.collapsed .footerActions { flex-direction: column; margin-left: 0; } @@ -1338,7 +1495,6 @@ .archivedSection { display: flex; flex-direction: column; - margin-top: 6px; padding-top: 4px; border-top: 1px solid var(--sidebar-border); } diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx deleted file mode 100644 index 03fa6d26879..00000000000 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx +++ /dev/null @@ -1,2395 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import type { DaemonWorkspaceCapability } from '@qwen-code/sdk/daemon'; -import type { - WebShellSidebarBranding, - WebShellSidebarFooterItem, -} from './WebShellSidebar'; - -const { - mockConnection, - mockUseSessions, - mockActive, - mockArchived, - renameSessionSpy, - mockExportSession, - mockWorkspaceActions, - mockWorkspace, -} = vi.hoisted(() => { - const makeStore = () => ({ - sessions: [] as MockSession[], - loading: false, - error: null as unknown, - reload: vi.fn(), - deleteSession: vi.fn().mockResolvedValue(true), - archiveSession: vi.fn().mockResolvedValue(true), - unarchiveSession: vi.fn().mockResolvedValue(true), - }); - const mockActive = makeStore(); - const mockArchived = makeStore(); - const mockExportSession = vi.fn(); - const mockUseSessions = vi.fn( - (options?: { archiveState?: 'active' | 'archived' }) => - options?.archiveState === 'archived' - ? mockArchived - : { ...mockActive, exportSession: mockExportSession }, - ); - return { - mockConnection: { - status: 'connected', - sessionId: null as string | null, - workspaceCwd: '/tmp/project', - capabilities: { qwenCodeVersion: '1.2.3', features: [] as string[] } as - | { - qwenCodeVersion?: string; - features?: string[]; - workspaces?: DaemonWorkspaceCapability[]; - } - | undefined, - }, - mockUseSessions, - mockActive, - mockArchived, - renameSessionSpy: vi.fn(), - mockExportSession, - mockWorkspaceActions: { - listSessionGroups: vi.fn().mockResolvedValue({ - groups: [], - colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], - }), - createSessionGroup: vi.fn(), - updateSessionGroup: vi.fn(), - deleteSessionGroup: vi.fn(), - updateSessionOrganization: vi.fn(), - addWorkspace: vi.fn().mockResolvedValue({ persisted: true }), - }, - mockWorkspace: { - client: { - listWorkspaceSessions: vi.fn().mockResolvedValue([]), - }, - capabilities: { - qwenCodeVersion: '1.2.3', - features: [] as string[], - } as - | { - qwenCodeVersion?: string; - features?: string[]; - workspaces?: DaemonWorkspaceCapability[]; - } - | undefined, - getCapabilities: vi.fn(), - }, - }; -}); - -type MockSession = { - sessionId: string; - workspaceCwd: string; - displayName?: string; - createdAt?: string; - updatedAt?: string; - clientCount?: number; - hasActivePrompt?: boolean; - isArchived?: boolean; - isPinned?: boolean; - groupId?: string | null; - color?: 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | null; -}; - -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ - useConnection: () => mockConnection, - useActions: () => ({ renameSession: renameSessionSpy }), - useWorkspaceActions: () => mockWorkspaceActions, - useWorkspace: () => mockWorkspace, - useSessions: (options?: { archiveState?: 'active' | 'archived' }) => - mockUseSessions(options), -})); - -function makeSession( - sessionId: string, - over: Partial = {}, -): MockSession { - return { - sessionId, - workspaceCwd: '/tmp/project', - displayName: `Session ${sessionId}`, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - clientCount: 0, - hasActivePrompt: false, - ...over, - }; -} - -const { I18nProvider } = await import('../../i18n'); -const { WebShellSidebar, getSidebarTooltipPosition } = await import( - './WebShellSidebar' -); - -( - globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } -).IS_REACT_ACT_ENVIRONMENT = true; - -const mounted: Array<{ root: Root; container: HTMLElement }> = []; - -const noop = () => {}; -const SIDEBAR_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-sidebar-width'; - -function setStoredSidebarWidth(width: number): void { - window.localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(width)); -} - -function pointerEvent(type: string, clientX: number): MouseEvent { - return new MouseEvent(type, { bubbles: true, clientX }); -} - -function renderSidebar( - collapsed: boolean, - overrides: Partial<{ - onOpenSettings: () => void; - onOpenDaemonStatus: () => void; - onOpenScheduledTasks: () => void; - onOpenSessions: () => void; - canOpenSessionsOverview: boolean; - onOpenSplitView: () => void; - canOpenSplitView: boolean; - onCollapsedChange: (collapsed: boolean) => void; - onNewSession: () => Promise | boolean; - onLoadSession: (sessionId: string) => Promise | void; - onError: (error: unknown, message: string) => void; - sessionListReloadToken: number; - selectedWorkspaceCwd: string; - onSelectWorkspace: (workspaceCwd: string | undefined) => void; - mobileOpen: boolean; - branding: false | WebShellSidebarBranding; - footer: false | { items: readonly WebShellSidebarFooterItem[] }; - }> = {}, -): { - container: HTMLElement; - rerender: (props: typeof overrides, nextCollapsed?: boolean) => void; -} { - const container = document.createElement('div'); - document.body.appendChild(container); - const root = createRoot(container); - const doRender = (props: typeof overrides, nextCollapsed = collapsed) => { - act(() => { - root.render( - - false} - onLoadSession={noop} - onError={noop} - {...props} - /> - , - ); - }); - }; - doRender(overrides); - mounted.push({ root, container }); - return { container, rerender: doRender }; -} - -beforeEach(() => { - mockUseSessions.mockClear(); - window.localStorage.clear(); - mockConnection.sessionId = null; - mockConnection.capabilities = { qwenCodeVersion: '1.2.3', features: [] }; - mockWorkspace.capabilities = { qwenCodeVersion: '1.2.3', features: [] }; - mockWorkspace.client.listWorkspaceSessions.mockReset(); - mockWorkspace.client.listWorkspaceSessions.mockResolvedValue([]); - mockWorkspace.getCapabilities.mockReset(); - for (const store of [mockActive, mockArchived]) { - store.sessions = []; - store.loading = false; - store.error = null; - store.reload.mockReset(); - store.deleteSession.mockReset(); - store.archiveSession.mockReset(); - store.unarchiveSession.mockReset(); - store.deleteSession.mockResolvedValue(true); - store.archiveSession.mockResolvedValue(true); - store.unarchiveSession.mockResolvedValue(true); - } - renameSessionSpy.mockClear(); - mockExportSession.mockReset(); - mockExportSession.mockResolvedValue({ - content: 'export', - filename: 'session.html', - mimeType: 'text/html', - format: 'html', - }); - mockWorkspaceActions.listSessionGroups.mockReset(); - mockWorkspaceActions.listSessionGroups.mockResolvedValue({ - groups: [], - colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], - }); - mockWorkspaceActions.createSessionGroup.mockReset(); - mockWorkspaceActions.updateSessionGroup.mockReset(); - mockWorkspaceActions.deleteSessionGroup.mockReset(); - mockWorkspaceActions.updateSessionOrganization.mockReset(); - mockWorkspaceActions.addWorkspace.mockReset(); - mockWorkspaceActions.addWorkspace.mockResolvedValue({ persisted: true }); -}); - -afterEach(() => { - for (const { root, container } of mounted.splice(0)) { - act(() => root.unmount()); - container.remove(); - } - vi.useRealTimers(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -describe('WebShellSidebar — workspace picker', () => { - const multiWorkspaceCaps = { - qwenCodeVersion: '1.2.3', - features: ['multi_workspace_sessions'], - workspaces: [ - { id: 'ws-primary', cwd: '/tmp/project', primary: true, trusted: true }, - { id: 'ws-second', cwd: '/tmp/other', primary: false, trusted: true }, - { - id: 'ws-untrusted', - cwd: '/tmp/danger', - primary: false, - trusted: false, - }, - ], - }; - - // Each registered workspace renders as a WorkspaceSection header - + + @@ -3100,182 +2653,370 @@ export function WebShellSidebar({ )} -
- {shouldRenderBrand && ( - - )} + {shouldRenderBrand && ( +
+ {branding?.render ? ( + branding.render() + ) : ( + <> + + {!collapsed && ( + Qwen Code + )} + + )} +
+ )} +
-
- {!collapsed && workspaces.length > 1 && ( -
-
- - {t('sidebar.workspaceSelectLabel')} - - -
-
- {workspaces.map((ws) => ( - formatRelativeTime(iso, t)} - onSelectWorkspace={(cwd) => onSelectWorkspace?.(cwd)} - renderSession={(session) => - renderSessionRow(session, { readOnly: !ws.primary }) - } - /> - ))} -
-
- )} - {!collapsed && workspaces.length <= 1 && ( - - )} - -
- {!collapsed && workspaces.length <= 1 && ( -
- - - - - {projectName} + {footerItems.has('scheduledTasks') && ( + - -
- )} - {searchOpen && !collapsed && ( - setSearchQuery(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Escape') { - setSearchQuery(''); - setSearchOpen(false); - } - }} - /> + {!collapsed && {t('sidebar.scheduledTasks')}} + )} +
+
- {workspaces.length <= 1 && body} + {!collapsed && pinnedSessions.length > 0 && ( + <> +
+ +
+ {pinnedExpanded && ( +
+ {pinnedSessions.map((session) => renderSessionRow(session))} +
+ )} + + )} + {!collapsed && ( +
+ +
+ + +
+
+ )} + {searchOpen && !collapsed && ( +
+
+ )} + {(collapsed || projectsExpanded) && ( + <> + {!collapsed && ( +
+
+ {displayedWorkspaces.map((ws) => ( + + 1 + ? t('sidebar.workspacePrimary') + : '' + } + untrustedLabel={t('sidebar.workspaceUntrusted')} + readOnlyLabel={t('sidebar.workspaceReadOnly')} + trustToOpenLabel={t('sidebar.workspaceTrustToOpen')} + noSessionsLabel={t('sidebar.noSessions')} + loadErrorLabel={t('sidebar.loadFailed')} + organizationEnabled={organizationEnabled} + ungroupedLabel={t('sidebar.groupUngrouped')} + onRenameGroup={handleRenameGroup} + onDeleteGroup={handleDeleteGroup} + renameGroupLabel={t('sidebar.groupRename')} + deleteGroupLabel={t('sidebar.groupDelete')} + groupActionsDisabled={groupBusy} + excludePinned + formatTime={(iso) => formatRelativeTime(iso, t)} + searchQuery={searchQuery} + expanded={ws.primary ? projectExpanded : undefined} + autoExpandKey={ + autoExpandWorkspace?.id === ws.id + ? autoExpandWorkspace?.key + : undefined + } + onExpandedChange={ + ws.primary ? setProjectExpanded : undefined + } + renderSessions={!ws.primary} + renderSession={(session, options) => + renderSessionRow( + { + ...session, + workspaceCwd: ws.cwd, + }, + options, + ) + } + headerActions={(visible) => ( +
+ + +
+ )} + /> + {ws.primary && + (projectExpanded || searchQuery.trim()) ? ( +
+ {body} +
+ ) : null} +
+ ))} +
+
+ )} + + )} {archivedSection}
- {footerActions.length > 0 && ( + {footer !== false && (
- {footerLeadingActions.length > 0 && ( -
- {footerLeadingActions.map(renderFooterAction)} -
+ className={cx( + styles.footer, + footerCompact && styles.footerCompact, + footerTight && styles.footerTight, )} - {(footerTrailingActions.length > 0 || - footerOverflowActions.length > 0) && ( -
- {footerTrailingActions.map(renderFooterAction)} - {footerOverflowActions.length > 0 && ( + > +
+ {footerItems.has('settings') && ( + + )} + {!collapsed && + !footerTight && + versionLabel && + footerItems.has('version') && ( + + {versionLabel} + + )} +
+
+ {footerItems.has('theme') && ( + + )} + {canOpenSessionsOverview && + footerItems.has('sessionsOverview') && ( )} -
- )} - {footerMenuAnchor && footerOverflowActions.length > 0 && ( - setFooterMenuAnchor(null)} - /> - )} + {canOpenSplitView && footerItems.has('splitView') && ( + + )} + {footerItems.has('daemonStatus') && ( + + )} + {!mobileOpen && footerItems.has('collapse') && ( + + )} +
)}
): string { @@ -20,30 +30,15 @@ function getSessionLabel(session: DaemonSessionSummary): string { return displayName || session.sessionId.slice(0, 8); } -function FolderIcon({ open }: { open: boolean }) { +function WorkspaceFolderIcon({ open }: { open: boolean }) { + const Icon = open ? FolderOpenIcon : FolderClosedIcon; return ( - + /> ); } @@ -57,13 +52,32 @@ interface WorkspaceSectionProps { readOnlyLabel: string; trustToOpenLabel: string; noSessionsLabel: string; + loadErrorLabel: string; + organizationEnabled: boolean; + ungroupedLabel: string; formatTime: (iso: string) => string; - onSelectWorkspace: (cwd: string | undefined) => void; + searchQuery?: string; + expanded?: boolean; + autoExpandKey?: string; + onExpandedChange?: (expanded: boolean) => void; + renderSessions?: boolean; /** - * Render a trusted session row with the sidebar's shared interactions and - * styling. Untrusted rows stay non-interactive in this component. + * Render one session row. The sidebar passes its shared `renderSessionRow` + * so per-workspace sessions match the single-workspace list exactly — same + * type scale, hover actions (pin, archive, export, more…), and states — + * instead of a bespoke, feature-poor row. */ - renderSession: (session: DaemonSessionSummary) => ReactNode; + renderSession: ( + session: DaemonSessionSummary, + options?: { grouped?: boolean }, + ) => ReactNode; + headerActions?: (visible: boolean) => ReactNode; + onRenameGroup?: (group: DaemonSessionGroup, workspaceCwd: string) => void; + onDeleteGroup?: (group: DaemonSessionGroup, workspaceCwd: string) => void; + renameGroupLabel?: string; + deleteGroupLabel?: string; + groupActionsDisabled?: boolean; + excludePinned?: boolean; } export function WorkspaceSection({ @@ -76,98 +90,278 @@ export function WorkspaceSection({ readOnlyLabel, trustToOpenLabel, noSessionsLabel, + loadErrorLabel, + organizationEnabled, + ungroupedLabel, formatTime, - onSelectWorkspace, + searchQuery = '', + expanded: controlledExpanded, + autoExpandKey, + onExpandedChange, + renderSessions = true, renderSession, + headerActions, + onRenameGroup, + onDeleteGroup, + renameGroupLabel, + deleteGroupLabel, + groupActionsDisabled, + excludePinned = false, }: WorkspaceSectionProps) { const [sessions, setSessions] = useState([]); - const [expanded, setExpanded] = useState(workspace.primary); + const [groups, setGroups] = useState([]); + const [loadError, setLoadError] = useState(false); + const [internalExpanded, setInternalExpanded] = useState(false); + const [collapsedGroupIds, setCollapsedGroupIds] = useState>( + () => new Set(), + ); + const [actionsVisible, setActionsVisible] = useState(false); + const expanded = controlledExpanded ?? internalExpanded; const readOnly = !workspace.primary && !workspace.trusted; const disabled = workspace.primary && !workspace.trusted; - // Sync if the primary flag changes after mount (e.g. capabilities refresh). + // A workspace always starts collapsed, including the primary workspace. + useEffect(() => { + if (controlledExpanded === undefined) setInternalExpanded(false); + }, [controlledExpanded, workspace.id]); + useEffect(() => { - setExpanded(workspace.primary); - }, [workspace.primary]); + if (controlledExpanded === undefined && autoExpandKey) { + setInternalExpanded(true); + } + }, [autoExpandKey, controlledExpanded]); const loadSessions = useCallback(async () => { if (disabled) return; try { - const result = await client.listWorkspaceSessions(workspace.cwd, { - archiveState: 'active', - }); + const result = await client + .workspaceByCwd(workspace.cwd) + .listWorkspaceSessions({ + pageSize: SESSION_LIST_PAGE_SIZE, + archiveState: 'active', + ...(organizationEnabled + ? { view: 'organized' as const, group: 'all' } + : {}), + }); setSessions(result); + setLoadError(false); } catch (err) { // Surface connectivity failures so users can distinguish a broken // daemon from genuinely zero sessions. console.warn('[WorkspaceSection] session poll failed:', err); - setSessions([]); + setLoadError(true); + } + }, [client, disabled, organizationEnabled, workspace.cwd]); + + useEffect(() => { + if (!renderSessions || disabled || !organizationEnabled) { + setGroups([]); + return; } - }, [client, disabled, workspace.cwd]); + let cancelled = false; + void client + .workspaceByCwd(workspace.cwd) + .listSessionGroups() + .then((catalog) => { + if (!cancelled) setGroups(catalog.groups); + }) + .catch((err: unknown) => { + console.warn('[WorkspaceSection] group catalog load failed:', err); + }); + return () => { + cancelled = true; + }; + }, [ + client, + disabled, + organizationEnabled, + reloadToken, + renderSessions, + workspace.cwd, + ]); useEffect(() => { - if (!expanded) return; + if (!renderSessions) return; + if (!expanded && !searchQuery.trim()) return; void loadSessions(); if (readOnly) return; const timer = setInterval(() => void loadSessions(), 10_000); return () => clearInterval(timer); - }, [expanded, loadSessions, readOnly, reloadToken]); + }, [ + expanded, + loadSessions, + readOnly, + reloadToken, + renderSessions, + searchQuery, + ]); + + const visibleSessions = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + return sessions.filter((session) => { + if (excludePinned && session.isPinned) return false; + if (!query) return true; + const label = (session.displayName || '').toLowerCase(); + return ( + label.includes(query) || session.sessionId.toLowerCase().includes(query) + ); + }); + }, [excludePinned, searchQuery, sessions]); + + const groupedSessions = useMemo(() => { + if (!organizationEnabled || groups.length === 0) return null; + const assigned = new Set(); + const sections = groups.map((group) => { + const items = visibleSessions.filter( + (session) => session.groupId === group.id, + ); + items.forEach((session) => assigned.add(session.sessionId)); + return { group, sessions: items }; + }); + return { + sections, + ungrouped: visibleSessions.filter( + (session) => !assigned.has(session.sessionId), + ), + }; + }, [groups, organizationEnabled, visibleSessions]); return (
- - {expanded && !disabled && ( -
- {sessions.length === 0 ? ( -
{noSessionsLabel}
- ) : ( - sessions.map((session) => { - if (!readOnly) return renderSession(session); - const label = getSessionLabel(session); - const time = session.createdAt - ? formatTime(session.createdAt) - : ''; - return ( -
- {label} - {time && {time}} -
- ); - }) +
- )} + {readOnly && {readOnlyLabel}} + + {!readOnly && !disabled && headerActions?.(actionsVisible)} +
+ {renderSessions && + (expanded || Boolean(searchQuery.trim())) && + !disabled && ( +
+ {loadError ? ( +
+ {loadErrorLabel} +
+ ) : visibleSessions.length === 0 ? ( +
{noSessionsLabel}
+ ) : groupedSessions ? ( + <> + {groupedSessions.sections.map(({ group, sessions }) => ( + { + setCollapsedGroupIds((current) => { + const next = new Set(current); + if (next.has(group.id)) next.delete(group.id); + else next.add(group.id); + return next; + }); + }} + onRename={ + onRenameGroup + ? () => onRenameGroup(group, workspace.cwd) + : undefined + } + onDelete={ + onDeleteGroup + ? () => onDeleteGroup(group, workspace.cwd) + : undefined + } + renameLabel={renameGroupLabel} + deleteLabel={deleteGroupLabel} + actionsDisabled={groupActionsDisabled} + > + {sessions.map((session) => + renderSession(session, { grouped: true }), + )} + + ))} + {groupedSessions.ungrouped.length > 0 && ( + { + setCollapsedGroupIds((current) => { + const next = new Set(current); + if (next.has('ungrouped')) next.delete('ungrouped'); + else next.add('ungrouped'); + return next; + }); + }} + > + {groupedSessions.ungrouped.map((session) => + renderSession(session, { grouped: true }), + )} + + )} + + ) : ( + visibleSessions.map((session) => { + if (!readOnly) return renderSession(session); + const label = getSessionLabel(session); + const time = session.createdAt + ? formatTime(session.createdAt) + : ''; + return ( +
+ {label} + {time && {time}} +
+ ); + }) + )} +
+ )}
); } diff --git a/packages/web-shell/client/hooks/useAtMentionMenu.ts b/packages/web-shell/client/hooks/useAtMentionMenu.ts index 3743bd055d6..ed78dfb9792 100644 --- a/packages/web-shell/client/hooks/useAtMentionMenu.ts +++ b/packages/web-shell/client/hooks/useAtMentionMenu.ts @@ -164,6 +164,7 @@ export interface UseAtMentionMenuOptions { disabledRef: RefObject; shellModeRef: RefObject; workspaceActionsRef: RefObject; + workspaceKey?: string; builtinProviders?: WebShellBuiltinAtProvidersConfig; providers?: readonly WebShellAtProvider[]; createInlineTagEffect?: (range: { @@ -835,6 +836,7 @@ export function useAtMentionMenu({ disabledRef, shellModeRef, workspaceActionsRef, + workspaceKey, builtinProviders, providers = EMPTY_PROVIDERS, createInlineTagEffect, @@ -921,6 +923,19 @@ export function useAtMentionMenu({ [], ); + useEffect(() => { + abortRef.current?.abort(); + abortRef.current = null; + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + searchTimerRef.current = null; + } + fileDirectoryRef.current = '.'; + builtinCacheRef.current = createBuiltinProviderCache(); + stateRef.current = null; + setState(null); + }, [workspaceKey]); + const clearPendingLoad = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; diff --git a/packages/web-shell/client/hooks/useComposerCore.ts b/packages/web-shell/client/hooks/useComposerCore.ts index ed135291748..2e1f61041a8 100644 --- a/packages/web-shell/client/hooks/useComposerCore.ts +++ b/packages/web-shell/client/hooks/useComposerCore.ts @@ -52,7 +52,11 @@ import { type CommandDisplayCategoryOrder, } from '../utils/commandDisplay'; import { useInputHistory } from '../hooks/useInputHistory'; -import { useAtMentionMenu, type AtMentionMenuState } from './useAtMentionMenu'; +import { + useAtMentionMenu, + type AtMentionMenuState, + type AtMentionWorkspaceActions, +} from './useAtMentionMenu'; import { useI18n } from '../i18n'; import { inputHighlight, @@ -1052,6 +1056,7 @@ export interface UseComposerCoreOptions { composerInputVersion?: number; builtinAtProviders?: WebShellBuiltinAtProvidersConfig; atProviders?: readonly WebShellAtProvider[]; + atWorkspaceCwd?: string; composerTagIcons?: WebShellComposerTagIconMap; renderComposerTag?: ComposerTagRenderer; renderComposerTagTooltip?: ComposerTagRenderer; @@ -1199,6 +1204,7 @@ export function useComposerCore( composerInputVersion, builtinAtProviders, atProviders, + atWorkspaceCwd, composerTagIcons, renderComposerTag, renderComposerTagTooltip, @@ -1240,8 +1246,52 @@ export function useComposerCore( onFocusFooterRef.current = onFocusFooter; const languageRef = useRef(language); languageRef.current = language; - const workspaceActionsRef = useRef(workspace?.actions); - workspaceActionsRef.current = workspace?.actions; + const workspaceActionsRef = useRef( + undefined, + ); + if (workspace && atWorkspaceCwd) { + const client = workspace.client.workspaceByCwd(atWorkspaceCwd); + workspaceActionsRef.current = { + ...workspace.actions, + async globWorkspace(pattern, options) { + if (options?.signal?.aborted) return { matches: [] }; + const result = (await client.glob(pattern)) as { matches?: unknown[] }; + if (options?.signal?.aborted) return { matches: [] }; + const matches = Array.isArray(result.matches) + ? result.matches.filter( + (match): match is string => typeof match === 'string', + ) + : []; + return { + matches: + options?.maxResults === undefined + ? matches + : matches.slice(0, options.maxResults), + }; + }, + async listDirectory(dirPath, options) { + if (options?.signal?.aborted) { + return { kind: 'list', path: dirPath, entries: [], truncated: false }; + } + const result = (await client.dirList(dirPath)) as { + kind: 'list'; + path: string; + entries: Array<{ + name: string; + kind: 'file' | 'directory' | 'symlink' | 'other'; + ignored: boolean; + }>; + truncated: boolean; + }; + if (options?.signal?.aborted) { + return { kind: 'list', path: dirPath, entries: [], truncated: false }; + } + return result; + }, + }; + } else { + workspaceActionsRef.current = workspace?.actions; + } const composerTagIconsRef = useRef(composerTagIcons); composerTagIconsRef.current = composerTagIcons; const renderComposerTagRef = useRef(renderComposerTag); @@ -1293,6 +1343,7 @@ export function useComposerCore( disabledRef, shellModeRef, workspaceActionsRef, + workspaceKey: atWorkspaceCwd, builtinProviders: builtinAtProviders, providers: atProviders, createInlineTagEffect: (range) => diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index e1a7b2e486c..e2457a6961f 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -122,6 +122,7 @@ const EN: Messages = { 'agent.editColorTitle': (v) => `Edit Color: ${v?.name ?? ''}`, 'agent.editTitle': (v) => `Edit ${v?.name ?? ''}`, 'agent.empty': 'No subagents found.', + 'agent.noMatches': 'No matching subagents found.', 'agent.footer.back': 'Esc to go back', 'agent.footer.cliBack': 'Enter to select, ↑↓ to navigate, Esc to go back', 'agent.footer.cliSelect': 'Enter to select, ↑↓ to navigate, Esc to close', @@ -158,6 +159,9 @@ const EN: Messages = { 'agent.step': (v) => `Step ${v?.n ?? ''}`, 'agent.tools': 'Tools', 'agent.toolsLabel': 'Tools: ', + 'agent.detail.overview': 'Basic Information', + 'agent.detail.tools': 'Tools', + 'agent.detail.systemPrompt': 'System Prompt', 'agent.toolsUpdated': (v) => `Updated tools for ${v?.name ?? ''}`, 'agent.usingCount': (v) => `Using: ${v?.count ?? 0} agents`, 'agent.view': 'View', @@ -241,6 +245,7 @@ const EN: Messages = { 'at.category.mcpResources.description': 'Reference MCP server resources', 'at.menu': 'Reference menu', 'common.back': 'back', + 'common.all': 'All', 'common.cancel': 'cancel', 'common.close': 'close', 'common.fullscreen': 'Fullscreen', @@ -252,6 +257,7 @@ const EN: Messages = { 'common.enterSelect': 'Enter to select', 'common.invalid': 'invalid', 'common.loading': 'Loading...', + 'common.retry': 'Try again', 'common.save': 'save', 'common.navigate': '↑↓ to navigate', 'common.next': 'next', @@ -675,6 +681,12 @@ const EN: Messages = { 'quickActions.setGoal': 'Set goal', 'session.missing': 'Current session does not exist', 'session.new': 'New session', + 'workspace.loadFailed': 'Failed to load workspace', + 'workspace.notFound': 'Workspace not found', + 'workspace.notFoundDescription': + 'This workspace may have been removed or the link is no longer valid.', + 'workspace.loadFailedDescription': + 'The workspace service could not be reached. Check the daemon and try again.', // Scheduled tasks page 'scheduledTasks.title': 'Scheduled Tasks', 'scheduledTasks.subtitle': @@ -791,9 +803,13 @@ const EN: Messages = { 'sidebar.label': 'Workspace sidebar', 'sidebar.toggleMenu': 'Toggle menu', 'sidebar.newChat': 'New chat', + 'sidebar.newTask': 'New task', + 'sidebar.plugins': 'Plugins', 'sidebar.project': 'Project', + 'sidebar.pinnedSessions': 'Pinned', 'sidebar.workspaceSelectLabel': 'Workspace', - 'sidebar.workspacePrimary': 'primary', + 'sidebar.details': 'Details', + 'sidebar.workspacePrimary': 'Primary', 'sidebar.workspaceUntrusted': 'untrusted', 'sidebar.workspaceReadOnly': 'read-only', 'sidebar.workspaceTrustToOpen': 'Trust this workspace to open the session.', @@ -807,6 +823,9 @@ const EN: Messages = { 'The daemon did not confirm persistent workspace registration', 'sidebar.addWorkspaceAbsError': 'Path must be absolute', 'sidebar.addWorkspaceHint': 'Enter the absolute path to a project directory.', + 'sidebar.addWorkspacePersist': 'Keep after daemon restart', + 'sidebar.addWorkspacePersistHint': + 'Persist this workspace registration in the daemon configuration.', 'sidebar.addWorkspaceAdding': 'Adding…', 'sidebar.noSessions': 'No sessions.', 'sidebar.projectFallback': 'Project', @@ -815,6 +834,8 @@ const EN: Messages = { 'sidebar.settings': 'Settings', 'sidebar.daemonStatus': 'Daemon Status', 'sidebar.scheduledTasks': 'Scheduled Tasks', + 'sidebar.themeLight': 'Switch to light theme', + 'sidebar.themeDark': 'Switch to dark theme', 'sidebar.collapse': 'Collapse', 'sidebar.expand': 'Expand', 'sidebar.collapseProject': 'Collapse project', @@ -830,7 +851,6 @@ const EN: Messages = { 'sidebar.archive': 'Archive', 'sidebar.unarchive': 'Restore', 'sidebar.moreActions': 'More actions', - 'sidebar.currentVersion': (v) => `Current version: ${v?.version ?? ''}`, 'sidebar.archiveCurrentDisabled': 'The current session cannot be archived', 'sidebar.archivedTitle': 'Archived', 'sidebar.archivedEmpty': 'No archived sessions.', @@ -1153,6 +1173,7 @@ const EN: Messages = { v?.source ? ` from "${v.source}"` : '' }: ${v?.error ?? 'Unknown error'}`, 'extensions.manage.agents': 'Agents:', + 'extensions.manage.actions': 'Extension actions', 'extensions.manage.checkingUpdates': 'Checking for updates...', 'extensions.manage.commands': 'Commands:', 'extensions.manage.contextFiles': 'Context files:', @@ -1162,6 +1183,13 @@ const EN: Messages = { 'extensions.manage.disabled': (v) => `Extension "${v?.name ?? 'extension'}" disabled.`, 'extensions.manage.empty': 'No extensions installed.', + 'extensions.manage.emptyAgents': 'This extension has no agents.', + 'extensions.manage.emptyCommands': 'This extension has no commands.', + 'extensions.manage.emptyContextFiles': 'This extension has no context files.', + 'extensions.manage.emptyDescription': + 'Install an extension to add commands, skills, agents, or MCP servers.', + 'extensions.manage.emptyMcpServers': 'This extension has no MCP servers.', + 'extensions.manage.emptySkills': 'This extension has no skills.', 'extensions.manage.enable': 'Enable Extension', 'extensions.manage.enabled': (v) => `Extension "${v?.name ?? 'extension'}" enabled.`, @@ -1172,14 +1200,21 @@ const EN: Messages = { 'extensions.manage.footer.select': '↑↓ to navigate · Enter select · Esc back', 'extensions.manage.loading': 'Loading extensions...', 'extensions.manage.mcpServers': 'MCP servers:', + 'extensions.manage.installType': 'Install type:', 'extensions.manage.name': 'Name:', 'extensions.manage.notUpdatable': 'not updatable', + 'extensions.manage.noDescription': 'No description', + 'extensions.manage.noMatches': 'No matching extensions.', + 'extensions.manage.origin': 'Origin:', + 'extensions.manage.overview': 'Overview', 'extensions.manage.path': 'Path:', 'extensions.manage.queued': (v) => `Extension action queued for "${v?.name ?? 'extension'}".`, 'extensions.manage.refreshed': (v) => `Extensions refreshed in ${v?.refreshed ?? 0} session(s), ${v?.failed ?? 0} failed.`, + 'extensions.manage.restartRequired': 'updated; restart required', 'extensions.manage.settings': 'Settings:', + 'extensions.manage.search': 'Search extensions…', 'extensions.manage.skills': 'Skills:', 'extensions.manage.source': 'Source:', 'extensions.manage.status': 'Status:', @@ -1196,6 +1231,9 @@ const EN: Messages = { 'extensions.manage.update': 'Update Extension', 'extensions.manage.updateAvailable': 'update available', 'extensions.manage.updateError': 'update check failed', + 'extensions.manage.updateComplete': 'updated', + 'extensions.manage.updateStatus': 'Update status:', + 'extensions.manage.updating': 'updating…', 'extensions.manage.updated': (v) => `Extension "${v?.name ?? 'extension'}" updated.`, 'extensions.manage.updatedWithVersion': (v) => @@ -1262,6 +1300,7 @@ const EN: Messages = { 'mcp.loadingTools': 'Loading tools...', 'mcp.name': 'Name', 'mcp.noDescription': 'No description', + 'mcp.noMatches': 'No matching MCP servers.', 'mcp.noSchema': 'No input schema.', 'mcp.serverTool': 'Server tool', 'mcp.servers': (v) => `${v?.count ?? 0} servers`, @@ -1274,6 +1313,7 @@ const EN: Messages = { 'Tools must have both name and description to be used by the LLM.', 'mcp.invalidToolWarning': 'Warning: This tool cannot be called by the LLM', 'mcp.manageServers': 'Manage MCP servers', + 'mcp.viewDetails': 'View details', 'mcp.parameters': 'Parameters:', 'mcp.required': ' required', 'mcp.resources': 'Resources', @@ -1484,10 +1524,41 @@ const EN: Messages = { ? `Use /skills ${v.name} to invoke · r to refresh · Esc to close` : 'r to refresh · Esc to close', 'skills.invocable': (v) => `${v?.enabled ?? 0}/${v?.total ?? 0} invocable`, + 'skills.details': 'Skill details', + 'skills.extension': 'Extension', + 'skills.filter.all': 'All', + 'skills.filter.bundled': 'Built-in', + 'skills.filter.extension': 'Extensions', + 'skills.filter.label': 'Filter skills by source', + 'skills.filter.project': 'Workspace settings', + 'skills.filter.user': 'User settings', + 'skills.hint': 'Hint', + 'skills.invocation': 'Invocation', + 'skills.level': 'Scope', + 'skills.level.bundled': 'Bundled', + 'skills.level.extension': 'Extension', + 'skills.level.project': 'Project', + 'skills.level.user': 'User', 'skills.loading': 'Loading skills...', + 'skills.model': 'Model', + 'skills.modelAccess': 'Model access', + 'skills.modelAccess.disabled': 'Not model-invocable', + 'skills.modelAccess.enabled': 'Model-invocable', + 'skills.modelInvocable': 'Model', + 'skills.noDescription': 'No description', + 'skills.noMatches': 'No matching skills.', 'skills.run': 'Run skill', + 'skills.search': 'Search skills…', + 'skills.status': 'Status', 'skills.status.disabled': 'disabled', 'skills.title': 'Skills', + 'plugins.extensions': 'Extensions', + 'plugins.agents': 'Agents', + 'plugins.mcp': 'MCP', + 'plugins.sections': 'Plugin sections', + 'plugins.skills': 'Skills', + 'plugins.title': 'Plugins', + 'plugins.tools': 'Tools', 'stats.accepted': 'Accepted:', 'stats.agreementRate': 'Overall Agreement Rate:', 'stats.api': 'API', @@ -1661,7 +1732,14 @@ const EN: Messages = { : 'r to refresh · Esc to close', 'tools.details.hide': 'Hide details', 'tools.details.show': 'Show details', + 'tools.description': 'Description', + 'tools.details': 'Tool details', 'tools.loading': 'Loading tools...', + 'tools.name': 'Tool name', + 'tools.noDescription': 'No description', + 'tools.noMatches': 'No matching tools.', + 'tools.search': 'Search tools…', + 'tools.status': 'Status', 'tools.status.disabled': 'disabled', 'tools.status.enabled': 'enabled', 'tools.summary': (v) => `${v?.enabled ?? 0}/${v?.total ?? 0} enabled`, @@ -1929,6 +2007,7 @@ const ZH: Messages = { 'agent.editColorTitle': (v) => `编辑颜色:${v?.name ?? ''}`, 'agent.editTitle': (v) => `编辑 ${v?.name ?? ''}`, 'agent.empty': '未找到智能体。', + 'agent.noMatches': '未找到匹配的智能体。', 'agent.footer.back': 'Esc 返回', 'agent.footer.cliBack': 'Enter 选择 · ↑↓ 导航 · Esc 返回', 'agent.footer.cliSelect': 'Enter 选择 · ↑↓ 导航 · Esc 关闭', @@ -1962,6 +2041,9 @@ const ZH: Messages = { 'agent.step': (v) => `步骤 ${v?.n ?? ''}`, 'agent.tools': '工具', 'agent.toolsLabel': '工具:', + 'agent.detail.overview': '基本信息', + 'agent.detail.tools': '工具', + 'agent.detail.systemPrompt': '系统提示词', 'agent.toolsUpdated': (v) => `已更新 ${v?.name ?? ''} 的工具`, 'agent.usingCount': (v) => `使用中:${v?.count ?? 0} 个智能体`, 'agent.view': '查看', @@ -2043,6 +2125,7 @@ const ZH: Messages = { 'at.category.mcpResources.description': '引用 MCP server 资源', 'at.menu': '引用菜单', 'common.back': '返回', + 'common.all': '全部', 'common.cancel': '取消', 'common.close': '关闭', 'common.fullscreen': '全屏', @@ -2054,6 +2137,7 @@ const ZH: Messages = { 'common.enterSelect': '回车选择', 'common.invalid': '无效', 'common.loading': '加载中...', + 'common.retry': '重试', 'common.save': '保存', 'common.navigate': '↑↓ 导航', 'common.next': '下一步', @@ -2446,6 +2530,11 @@ const ZH: Messages = { 'quickActions.setGoal': '设置目标', 'session.missing': '当前会话不存在', 'session.new': '新建会话', + 'workspace.loadFailed': '工作区加载失败', + 'workspace.notFound': '工作区不存在', + 'workspace.notFoundDescription': '此工作区可能已被移除,或链接已经失效。', + 'workspace.loadFailedDescription': + '无法连接工作区服务,请检查守护进程后重试。', // 定时任务页面 'scheduledTasks.title': '定时任务', 'scheduledTasks.subtitle': '按计划自动执行任务,也可随时手动触发。', @@ -2556,9 +2645,13 @@ const ZH: Messages = { 'sidebar.label': '工作区侧边栏', 'sidebar.toggleMenu': '切换菜单', 'sidebar.newChat': '新对话', + 'sidebar.newTask': '新建任务', + 'sidebar.plugins': '插件', 'sidebar.project': '项目', + 'sidebar.pinnedSessions': '置顶', 'sidebar.workspaceSelectLabel': '工作区', - 'sidebar.workspacePrimary': '主', + 'sidebar.details': '详情', + 'sidebar.workspacePrimary': '主工作区', 'sidebar.workspaceUntrusted': '未信任', 'sidebar.workspaceReadOnly': '只读', 'sidebar.workspaceTrustToOpen': '信任此工作区后才能打开会话。', @@ -2571,6 +2664,8 @@ const ZH: Messages = { 'sidebar.addWorkspacePersistenceError': '守护进程未确认工作区已持久化注册', 'sidebar.addWorkspaceAbsError': '路径必须是绝对路径', 'sidebar.addWorkspaceHint': '请输入项目目录的绝对路径。', + 'sidebar.addWorkspacePersist': '服务重启后保留', + 'sidebar.addWorkspacePersistHint': '将此工作区注册持久化到守护进程配置中。', 'sidebar.addWorkspaceAdding': '添加中…', 'sidebar.noSessions': '暂无会话', 'sidebar.projectFallback': '项目', @@ -2579,6 +2674,8 @@ const ZH: Messages = { 'sidebar.settings': '设置', 'sidebar.daemonStatus': 'Daemon 状态', 'sidebar.scheduledTasks': '定时任务', + 'sidebar.themeLight': '切换到浅色主题', + 'sidebar.themeDark': '切换到深色主题', 'sidebar.collapse': '收起', 'sidebar.expand': '展开', 'sidebar.collapseProject': '收起项目', @@ -2594,7 +2691,6 @@ const ZH: Messages = { 'sidebar.archive': '归档', 'sidebar.unarchive': '恢复', 'sidebar.moreActions': '更多操作', - 'sidebar.currentVersion': (v) => `当前版本:${v?.version ?? ''}`, 'sidebar.archiveCurrentDisabled': '不能归档当前会话', 'sidebar.archivedTitle': '已归档', 'sidebar.archivedEmpty': '没有已归档的会话。', @@ -2899,6 +2995,7 @@ const ZH: Messages = { v?.error ?? '未知错误' }`, 'extensions.manage.agents': '智能体:', + 'extensions.manage.actions': '扩展操作', 'extensions.manage.checkingUpdates': '正在检查更新...', 'extensions.manage.commands': '命令:', 'extensions.manage.contextFiles': '上下文文件:', @@ -2907,6 +3004,13 @@ const ZH: Messages = { 'extensions.manage.disable': '禁用扩展', 'extensions.manage.disabled': (v) => `扩展 "${v?.name ?? '扩展'}" 已禁用。`, 'extensions.manage.empty': '未安装扩展。', + 'extensions.manage.emptyAgents': '此扩展没有智能体。', + 'extensions.manage.emptyCommands': '此扩展没有命令。', + 'extensions.manage.emptyContextFiles': '此扩展没有上下文文件。', + 'extensions.manage.emptyDescription': + '安装扩展以添加命令、Skills、智能体或 MCP servers。', + 'extensions.manage.emptyMcpServers': '此扩展没有 MCP servers。', + 'extensions.manage.emptySkills': '此扩展没有 Skills。', 'extensions.manage.enable': '启用扩展', 'extensions.manage.enabled': (v) => `扩展 "${v?.name ?? '扩展'}" 已启用。`, 'extensions.manage.footer.back': 'Esc 返回', @@ -2915,14 +3019,21 @@ const ZH: Messages = { 'extensions.manage.footer.select': '↑↓ 导航 · Enter 选择 · Esc 返回', 'extensions.manage.loading': '正在加载扩展...', 'extensions.manage.mcpServers': 'MCP servers:', + 'extensions.manage.installType': '安装类型:', 'extensions.manage.name': '名称:', 'extensions.manage.notUpdatable': '不可更新', + 'extensions.manage.noDescription': '暂无描述', + 'extensions.manage.noMatches': '没有匹配的扩展。', + 'extensions.manage.origin': '来源平台:', + 'extensions.manage.overview': '基本信息', 'extensions.manage.path': '路径:', 'extensions.manage.queued': (v) => `扩展 "${v?.name ?? '扩展'}" 的操作已提交。`, 'extensions.manage.refreshed': (v) => `已刷新 ${v?.refreshed ?? 0} 个 session,${v?.failed ?? 0} 个失败。`, + 'extensions.manage.restartRequired': '已更新,需要重启', 'extensions.manage.settings': '设置:', + 'extensions.manage.search': '搜索扩展…', 'extensions.manage.skills': 'Skills:', 'extensions.manage.source': '来源:', 'extensions.manage.status': '状态:', @@ -2939,6 +3050,9 @@ const ZH: Messages = { 'extensions.manage.update': '更新扩展', 'extensions.manage.updateAvailable': '有可用更新', 'extensions.manage.updateError': '检查更新失败', + 'extensions.manage.updateComplete': '已更新', + 'extensions.manage.updateStatus': '更新状态:', + 'extensions.manage.updating': '正在更新…', 'extensions.manage.updated': (v) => `扩展 "${v?.name ?? '扩展'}" 已更新。`, 'extensions.manage.updatedWithVersion': (v) => `扩展 "${v?.name ?? '扩展'}" 已更新到 v${v?.version ?? ''}。`, @@ -2996,6 +3110,7 @@ const ZH: Messages = { 'mcp.loadingTools': '正在加载工具...', 'mcp.name': '名称', 'mcp.noDescription': '没有描述', + 'mcp.noMatches': '没有匹配的 MCP 服务器。', 'mcp.noSchema': '没有输入 schema。', 'mcp.serverTool': '服务器工具', 'mcp.servers': (v) => `${v?.count ?? 0} 个服务器`, @@ -3008,6 +3123,7 @@ const ZH: Messages = { '工具必须同时包含 name 和 description 才能被 LLM 使用。', 'mcp.invalidToolWarning': '警告:这个工具不能被 LLM 调用', 'mcp.manageServers': '管理 MCP servers', + 'mcp.viewDetails': '查看详情', 'mcp.parameters': '参数:', 'mcp.required': ' 必填', 'mcp.resources': '资源', @@ -3207,10 +3323,41 @@ const ZH: Messages = { ? `使用 /skills ${v.name} 调用 · r 刷新 · Esc 关闭` : 'r 刷新 · Esc 关闭', 'skills.invocable': (v) => `${v?.enabled ?? 0}/${v?.total ?? 0} 可调用`, + 'skills.details': 'Skill 详情', + 'skills.extension': '所属扩展', + 'skills.filter.all': '全部', + 'skills.filter.bundled': '内置', + 'skills.filter.extension': '扩展', + 'skills.filter.label': '按来源筛选 Skills', + 'skills.filter.project': '工作区设置', + 'skills.filter.user': '用户设置', + 'skills.hint': '提示', + 'skills.invocation': '调用方式', + 'skills.level': '作用域', + 'skills.level.bundled': '内置', + 'skills.level.extension': '扩展', + 'skills.level.project': '项目', + 'skills.level.user': '用户', 'skills.loading': '正在加载 skills...', + 'skills.model': '模型', + 'skills.modelAccess': '模型调用', + 'skills.modelAccess.disabled': '不可由模型调用', + 'skills.modelAccess.enabled': '可由模型调用', + 'skills.modelInvocable': '模型可用', + 'skills.noDescription': '暂无描述', + 'skills.noMatches': '没有匹配的 Skill。', 'skills.run': '运行 skill', + 'skills.search': '搜索 Skills…', + 'skills.status': '状态', 'skills.status.disabled': '已禁用', 'skills.title': 'Skills', + 'plugins.extensions': '扩展', + 'plugins.agents': '智能体', + 'plugins.mcp': 'MCP', + 'plugins.sections': '插件分类', + 'plugins.skills': 'Skills', + 'plugins.title': '插件', + 'plugins.tools': '工具', 'stats.accepted': '已接受:', 'stats.agreementRate': '总体同意率:', 'stats.api': 'API', @@ -3373,7 +3520,14 @@ const ZH: Messages = { : 'r 刷新 · Esc 关闭', 'tools.details.hide': '收起详情', 'tools.details.show': '展开详情', + 'tools.description': '描述', + 'tools.details': '工具详情', 'tools.loading': '正在加载工具...', + 'tools.name': '工具名称', + 'tools.noDescription': '暂无描述', + 'tools.noMatches': '没有匹配的工具。', + 'tools.search': '搜索工具…', + 'tools.status': '状态', 'tools.status.disabled': '已禁用', 'tools.status.enabled': '已启用', 'tools.summary': (v) => `${v?.enabled ?? 0}/${v?.total ?? 0} 已启用`, diff --git a/packages/web-shell/client/index.test.tsx b/packages/web-shell/client/index.test.tsx index 09b642bb4bf..7f0339ad3e8 100644 --- a/packages/web-shell/client/index.test.tsx +++ b/packages/web-shell/client/index.test.tsx @@ -24,6 +24,11 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', async () => { sessionProviderProps.push(props); return React.createElement(React.Fragment, null, children); }, + useWorkspace: () => ({ + capabilities: { + workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }], + }, + }), }; }); vi.mock('./App', async () => { @@ -33,9 +38,8 @@ vi.mock('./App', async () => { }; }); -// Bare './index' resolves to the sibling index.ts barrel, which doesn't export -// WebShellWithProviders (see the dual-entry note in the PR). A variable -// specifier loads index.tsx at runtime without tripping tsc's ts-extension rule. +// A variable specifier loads the TSX library entry without requiring +// allowImportingTsExtensions in this test configuration. const indexEntry = './index.tsx'; const { WebShellWithProviders } = await import(indexEntry); diff --git a/packages/web-shell/client/index.ts b/packages/web-shell/client/index.ts deleted file mode 100644 index 38d307a2c18..00000000000 --- a/packages/web-shell/client/index.ts +++ /dev/null @@ -1,96 +0,0 @@ -export { App as WebShell } from './App'; -export type { - WebShellApi, - WebShellComposerPlaceholders, - WebShellComposerPlaceholderState, - WebShellProps, - WebShellSidebarOptions, - BugReportInfo, - SessionChangeEvent, -} from './App'; -export type { - WebShellSidebarBranding, - WebShellSidebarFooterItem, - WebShellSidebarFooterOptions, -} from './components/sidebar/WebShellSidebar'; -export type { - TurnOutputKind, - TurnOutputOpenRequest, -} from './components/artifacts/TurnOutputs'; -export type { ComposerToolbarAction } from './components/ChatEditor'; -export type { ToastTone } from './components/ToastHost'; -export type { WebShellLanguage } from './i18n'; -export type { WebShellTheme } from './themeContext'; -export type { - CodeBlockRenderer, - MarkdownContentSource, - MarkdownRenderContext, - MarkdownTableMode, - ToolHeaderExtraRenderer, - ToolHeaderExtraRenderInfo, - ToolHeaderKind, - ComposerTagClickHandler, - ComposerTagRenderer, - UserMessageContentRenderer, - UserMessageContentRenderInfo, - UserMessageContentParser, - AssistantTurnFooterRenderer, - ComposerToolbarStartRenderer, - ComposerHeaderRenderer, - ComposerToolbarRightRenderer, - WelcomeFooterRenderer, - WebShellAtItemRenderInfo, - WebShellAtItemRenderer, - WebShellComposerApi, - WebShellBuiltinComposerTagKind, - WebShellBuiltinAtProviderId, - WebShellBuiltinAtProvidersConfig, - WebShellComposerInput, - WebShellComposerTag, - WebShellComposerTagIconMap, - WebShellComposerTagKind, - WebShellComposerTagOptions, - WebShellComposerTagPlacement, - WebShellComposerToolbarRenderInfo, - WebShellComposerToolbarStartRenderInfo, - WebShellComposerToolbarRightRenderInfo, - WebShellComposerTextOptions, - WelcomeHeaderRenderer, - WebShellMarkdownCustomization, - WebShellFooterRenderInfo, - FooterRenderer, - LoadingPhrasesResolver, - WebShellAtProviderTab, - WebShellAtItem, - WebShellAtProvider, - WebShellBottomStatusItem, - WebShellAssistantMessageInfo, - WebShellAssistantTurnFooterRenderInfo, - WebShellCodeBlockRenderInfo, - WebShellIconSource, - WebShellTaskInfo, - WebShellUserMessagePart, - WebShellAgentTask, - WebShellShellTask, - WebShellMonitorTask, - WebShellModelInfo, - WebShellSkillInfo, -} from './customization'; -export type { WelcomeHeaderProps } from './components/WelcomeHeader'; -export { - ECHARTS_FULLDATA_LANGUAGE, - EchartsFullDataBlock, - createEchartsFullDataRenderer, -} from './components/messages/EchartsFullDataBlock'; -export type { - DatasetCell, - EchartsFullDataBlockProps, - EchartsFullDataOption, - EchartsFullDataRefMeta, - EchartsFullDataRefResolver, - EchartsFullDataResolvedDataset, - EchartsFullDataRendererOptions, - EchartsInstance, - EchartsRuntime, - EchartsRuntimeLoader, -} from './components/messages/EchartsFullDataBlock'; diff --git a/packages/web-shell/client/index.tsx b/packages/web-shell/client/index.tsx index 39563227f4b..55db27cf468 100644 --- a/packages/web-shell/client/index.tsx +++ b/packages/web-shell/client/index.tsx @@ -1,11 +1,9 @@ import { type ReactNode } from 'react'; -import { - DaemonSessionProvider, - DaemonWorkspaceProvider, -} from '@qwen-code/webui/daemon-react-sdk'; +import { DaemonWorkspaceProvider } from '@qwen-code/webui/daemon-react-sdk'; import { App, type WebShellProps } from './App'; import { ErrorBoundary } from './components/ErrorBoundary'; import { RootErrorFallback } from './components/RootErrorFallback'; +import { WorkspaceSessionProvider } from './components/WorkspaceSessionProvider'; import { normalizeLanguage, type WebShellLanguage } from './i18n'; export interface WebShellWithProvidersProps extends WebShellProps { @@ -15,6 +13,8 @@ export interface WebShellWithProvidersProps extends WebShellProps { token?: string; /** Session id to load. Undefined starts on an empty page. */ sessionId?: string; + /** Registered daemon workspace id for the session. Undefined uses primary. */ + workspaceId?: string; /** Client identity to reuse when attaching to an externally created session. */ clientId?: string; } @@ -72,7 +72,8 @@ export function WebShell(props: WebShellProps) { * are available without extra setup. */ export function WebShellWithProviders(props: WebShellWithProvidersProps) { - const { baseUrl, token, sessionId, clientId, ...webShellProps } = props; + const { baseUrl, token, sessionId, workspaceId, clientId, ...webShellProps } = + props; const resolvedBaseUrl = resolveBaseUrl(baseUrl); return ( @@ -84,13 +85,12 @@ export function WebShellWithProviders(props: WebShellWithProvidersProps) { } > - - - + webShellProps={webShellProps} + /> ); @@ -105,9 +105,17 @@ export type { WebShellComposerPlaceholderState, WebShellProps, WebShellSidebarOptions, + BugReportInfo, + SessionChangeEvent, } from './App'; export type { ToastTone } from './components/ToastHost'; +export type { + WebShellSidebarBranding, + WebShellSidebarFooterItem, + WebShellSidebarFooterOptions, +} from './components/sidebar/WebShellSidebar'; export type { WebShellLanguage } from './i18n'; +export type { WebShellTheme } from './themeContext'; export type { CommandDisplayCategory, CommandDisplayCategoryOrder, @@ -121,22 +129,52 @@ export type { ToolHeaderExtraRenderer, ToolHeaderExtraRenderInfo, ToolHeaderKind, + ComposerTagClickHandler, + ComposerTagRenderer, AssistantTurnFooterRenderer, UserMessageContentRenderer, UserMessageContentRenderInfo, + UserMessageContentParser, ComposerHeaderRenderer, ComposerToolbarStartRenderer, ComposerToolbarRightRenderer, + WebShellAtItemRenderInfo, + WebShellAtItemRenderer, + WebShellComposerApi, + WebShellBuiltinComposerTagKind, + WebShellBuiltinAtProviderId, + WebShellBuiltinAtProvidersConfig, + WebShellComposerInput, + WebShellComposerTag, + WebShellComposerTagIconMap, + WebShellComposerTagKind, + WebShellComposerTagOptions, + WebShellComposerTagPlacement, WebShellComposerToolbarRenderInfo, WebShellComposerToolbarStartRenderInfo, WebShellComposerToolbarRightRenderInfo, + WebShellComposerTextOptions, WelcomeFooterRenderer, WelcomeHeaderRenderer, + WebShellFooterRenderInfo, + FooterRenderer, + LoadingPhrasesResolver, + WebShellAtProviderTab, + WebShellAtItem, + WebShellAtProvider, WebShellBottomStatusItem, WebShellCodeBlockRenderInfo, WebShellMarkdownCustomization, WebShellAssistantMessageInfo, WebShellAssistantTurnFooterRenderInfo, + WebShellIconSource, + WebShellTaskInfo, + WebShellUserMessagePart, + WebShellAgentTask, + WebShellShellTask, + WebShellMonitorTask, + WebShellModelInfo, + WebShellSkillInfo, } from './customization'; export type { WelcomeHeaderProps } from './components/WelcomeHeader'; export type { diff --git a/packages/web-shell/client/main.tsx b/packages/web-shell/client/main.tsx index eed19aa7754..3683d705e70 100644 --- a/packages/web-shell/client/main.tsx +++ b/packages/web-shell/client/main.tsx @@ -1,13 +1,10 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { useCallback, useEffect, useState } from 'react'; -import { - DaemonWorkspaceProvider, - DaemonSessionProvider, -} from '@qwen-code/webui/daemon-react-sdk'; -import { App } from './App'; +import { DaemonWorkspaceProvider } from '@qwen-code/webui/daemon-react-sdk'; import { ErrorBoundary } from './components/ErrorBoundary'; import { RootErrorFallback } from './components/RootErrorFallback'; +import { WorkspaceSessionProvider } from './components/WorkspaceSessionProvider'; import { getDaemonBaseUrl, getDaemonToken, @@ -90,9 +87,23 @@ function getSessionIdFromUrl(): string | undefined { } } -function replaceStandaloneSessionUrl(sessionId: string | undefined): void { +function getWorkspaceIdFromUrl(): string | undefined { + return ( + new URLSearchParams(window.location.search).get('workspace') || undefined + ); +} + +function replaceStandaloneSessionUrl( + sessionId: string | undefined, + workspaceId?: string, +): void { const url = new URL(window.location.href); url.pathname = sessionId ? `/session/${encodeURIComponent(sessionId)}` : '/'; + if (sessionId && workspaceId) { + url.searchParams.set('workspace', workspaceId); + } else { + url.searchParams.delete('workspace'); + } // Strip one-shot query params so bookmarked / shared URLs do not // permanently override stored preferences on every page load. url.searchParams.delete('theme'); @@ -111,14 +122,18 @@ function StandaloneApp({ daemonToken }: { daemonToken?: string }) { getInitialLanguage(), ); const [sessionId] = useState(() => getSessionIdFromUrl()); + const [workspaceId] = useState(() => + getWorkspaceIdFromUrl(), + ); const baseUrl = DAEMON_BASE_URL || window.location.origin; // Keep the theme class and in sync with // the React theme so mobile status bars / overscroll backgrounds stay // consistent when the user toggles or when ?theme= lands via URL. useEffect(() => { const root = document.documentElement; - root.classList.remove('theme-dark', 'theme-light'); + root.classList.remove('theme-dark', 'theme-light', 'dark'); root.classList.add(`theme-${theme}`); + root.classList.toggle('dark', theme === WebShellThemeId.Dark); const meta = document.querySelector('meta[name="theme-color"]'); if (meta) { meta.setAttribute('content', theme === 'light' ? '#ffffff' : '#0d0d0d'); @@ -132,9 +147,12 @@ function StandaloneApp({ daemonToken }: { daemonToken?: string }) { setLanguage(nextLanguage); storeLanguage(nextLanguage); }, []); - const handleSessionIdChange = useCallback((nextSessionId?: string) => { - replaceStandaloneSessionUrl(nextSessionId); - }, []); + const handleSessionIdChange = useCallback( + (nextSessionId?: string, nextWorkspaceId?: string) => { + replaceStandaloneSessionUrl(nextSessionId, nextWorkspaceId); + }, + [], + ); return ( - - - + workspaceId={workspaceId} + webShellProps={{ + theme, + onThemeChange: handleThemeChange, + language, + onLanguageChange: handleLanguageChange, + onSessionIdChange: handleSessionIdChange, + sidebar: true, + compactThinking: true, + }} + /> ); diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 3e4a8e5a027..c15245748f1 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -288,6 +288,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const [restoreSessionId, setRestoreSessionId] = useState( initialRestoreSessionId, ); + const [restoreWorkspaceCwd, setRestoreWorkspaceCwd] = useState< + string | undefined + >(undefined); const [restoreMode, setRestoreMode] = useState<'load' | 'resume'>('load'); const [restoreSessionNonce, setRestoreSessionNonce] = useState(0); const [attachSessionNonce, setAttachSessionNonce] = useState(0); @@ -443,7 +446,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { Array.isArray(caps.features) && caps.features.includes('client_heartbeat'); const effectWorkspaceCwd = - resolvedWorkspaceCwdRef.current ?? caps.workspaceCwd; + restoreWorkspaceCwd ?? + resolvedWorkspaceCwdRef.current ?? + caps.workspaceCwd; activeWorkspaceCwdRef.current = effectWorkspaceCwd; if ( (shouldDeferInitialSessionCreation || @@ -1543,6 +1548,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { maxQueued, store, restoreSessionId, + restoreWorkspaceCwd, restoreMode, restoreSessionNonce, attachSessionNonce, @@ -1747,6 +1753,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { setConnection, setPromptStatus, setRestoreSessionId, + setRestoreWorkspaceCwd, setRestoreMode, setRestoreSessionNonce, setAttachSessionNonce, diff --git a/packages/webui/src/daemon/session/actions.test.ts b/packages/webui/src/daemon/session/actions.test.ts index 2b241f09c29..ff45a5c34d4 100644 --- a/packages/webui/src/daemon/session/actions.test.ts +++ b/packages/webui/src/daemon/session/actions.test.ts @@ -272,6 +272,35 @@ describe('createDaemonSessionActions', () => { expect(pendingSessionLoadRef.current?.sessionId).toBe('session-b'); }); + it('keeps the active workspace when a session load omits one', () => { + const setRestoreWorkspaceCwd = vi.fn(); + const { actions } = createActionsHarness({ + connection: { + status: 'connected', + workspaceCwd: '/workspace/secondary', + }, + setRestoreWorkspaceCwd, + }); + + void actions.loadSession('session-b').catch(() => undefined); + + expect(setRestoreWorkspaceCwd).toHaveBeenCalledWith('/workspace/secondary'); + }); + + it('forwards the workspace when resuming a session', () => { + const setRestoreWorkspaceCwd = vi.fn(); + const { actions } = createActionsHarness({ + connection: { status: 'connected', workspaceCwd: '/workspace/primary' }, + setRestoreWorkspaceCwd, + }); + + void actions + .resumeSession('session-b', { workspaceCwd: '/workspace/secondary' }) + .catch(() => undefined); + + expect(setRestoreWorkspaceCwd).toHaveBeenCalledWith('/workspace/secondary'); + }); + it('clears transcript loading when a session switch fails', async () => { vi.useFakeTimers(); try { @@ -418,6 +447,7 @@ function createActionsHarness( pendingSessionLoadRef?: { current: PendingSessionLoad | undefined }; session?: ReturnType; setAttachSessionNonce?: ReturnType; + setRestoreWorkspaceCwd?: ReturnType; } = {}, ) { let connection: DaemonConnectionState = opts.connection ?? { @@ -467,6 +497,7 @@ function createActionsHarness( }, setPromptStatus: vi.fn(), setRestoreSessionId: vi.fn(), + setRestoreWorkspaceCwd: opts.setRestoreWorkspaceCwd ?? vi.fn(), setRestoreMode: vi.fn(), setRestoreSessionNonce: vi.fn(), setAttachSessionNonce: opts.setAttachSessionNonce ?? vi.fn(), diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index 351dfb5b639..e2248f432bb 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -69,6 +69,7 @@ export interface CreateDaemonSessionActionsArgs { setConnection: Dispatch>; setPromptStatus: Dispatch>; setRestoreSessionId: Dispatch>; + setRestoreWorkspaceCwd: Dispatch>; setRestoreMode: Dispatch>; setRestoreSessionNonce: Dispatch>; setAttachSessionNonce: Dispatch>; @@ -131,6 +132,7 @@ export function createDaemonSessionActions({ setConnection, setPromptStatus, setRestoreSessionId, + setRestoreWorkspaceCwd, setRestoreMode, setRestoreSessionNonce, setAttachSessionNonce, @@ -159,6 +161,7 @@ export function createDaemonSessionActions({ } store.reset(); setRestoreSessionId(undefined); + setRestoreWorkspaceCwd(undefined); } function startPendingSessionLoad( @@ -205,6 +208,7 @@ export function createDaemonSessionActions({ function startSessionSwitch( sessionId: string, mode: 'load' | 'resume', + workspaceCwd?: string, ): Promise { manualSessionClearRef.current = false; const loadPromise = startPendingSessionLoad(sessionId, mode); @@ -247,6 +251,7 @@ export function createDaemonSessionActions({ store.reset(); setRestoreMode(mode); setRestoreSessionId(sessionId); + setRestoreWorkspaceCwd(workspaceCwd ?? getConnection().workspaceCwd); setRestoreSessionNonce((nonce) => nonce + 1); return loadPromise.catch((error: unknown) => { if (!isAbortError(error)) { @@ -596,12 +601,12 @@ export function createDaemonSessionActions({ } }, - async loadSession(sessionId) { - return startSessionSwitch(sessionId, 'load'); + async loadSession(sessionId, options) { + return startSessionSwitch(sessionId, 'load', options?.workspaceCwd); }, - async resumeSession(sessionId) { - return startSessionSwitch(sessionId, 'resume'); + async resumeSession(sessionId, options) { + return startSessionSwitch(sessionId, 'resume', options?.workspaceCwd); }, async createSession(options?: { diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index adf995bb7f6..3fefe851af3 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -329,8 +329,14 @@ export interface DaemonSessionActions { listSessions(options?: { pageSize?: number; }): Promise; - loadSession(sessionId: string): Promise; - resumeSession(sessionId: string): Promise; + loadSession( + sessionId: string, + options?: { workspaceCwd?: string }, + ): Promise; + resumeSession( + sessionId: string, + options?: { workspaceCwd?: string }, + ): Promise; /** * Create a daemon session and update local session state. Callers that need * transcript/event streaming must follow with `attachSession()`.