From 293ad2d8f6cdb38808eb1c7653f26326428a15da Mon Sep 17 00:00:00 2001 From: ytahdn Date: Wed, 22 Jul 2026 11:50:20 +0800 Subject: [PATCH 1/3] fix(web-shell): polish embedded shell interactions --- packages/web-shell/client/App.tsx | 14 +- .../client/components/ChatEditor.module.css | 39 +--- .../client/components/ChatEditor.tsx | 48 ++--- .../client/components/WorkspaceSelector.tsx | 170 ++++++++++++------ .../client/components/ui/tooltip.tsx | 5 +- .../client/hooks/useSessionArtifacts.ts | 13 +- packages/web-shell/client/main.tsx | 1 + packages/webui/src/daemon/session/actions.ts | 22 +-- 8 files changed, 165 insertions(+), 147 deletions(-) diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index e335c09c60b..a8398ffd20c 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -570,6 +570,8 @@ export interface WebShellProps { renderToolHeaderExtra?: ToolHeaderExtraRenderer; /** Custom renderer for the welcome header. Receives version, cwd, model, and mode. */ renderWelcomeHeader?: WelcomeHeaderRenderer; + /** Show the worktree-isolation action in the empty welcome state. Defaults to false. */ + showWorktreeToggle?: boolean; /** Custom renderer shown below the chat composer in the empty welcome state. */ renderWelcomeFooter?: WelcomeFooterRenderer; /** @@ -1058,6 +1060,7 @@ export function App({ composerTagIcons, renderToolHeaderExtra, renderWelcomeHeader, + showWorktreeToggle = false, renderWelcomeFooter, mobileWelcomeFooterMiddle = false, parseUserMessageContent, @@ -6369,7 +6372,8 @@ export function App({ // session would land in is trusted and is a git repository — the daemon // rejects worktree creation otherwise. Mirrors the sidebar entry's gating. const worktreeToggleEligible = Boolean( - workspaces.find((entry) => entry.cwd === activeWorkspaceCwd)?.trusted && + showWorktreeToggle && + workspaces.find((entry) => entry.cwd === activeWorkspaceCwd)?.trusted && selectedWorkspaceGitStatus?.branch, ); const worktreeToggleRef = useRef(null); @@ -6385,6 +6389,11 @@ export function App({ setWorktreePending(false); worktreeFocusTarget.current = 'toggle'; }, []); + useEffect(() => { + if (showWorktreeToggle) return; + pendingWorktreeRef.current = undefined; + setWorktreePending(false); + }, [showWorktreeToggle]); useEffect(() => { if (!worktreeFocusTarget.current) return; const target = worktreeFocusTarget.current; @@ -6403,7 +6412,7 @@ export function App({ ) : ( )} - {worktreePending ? ( + {showWorktreeToggle && worktreePending ? (
@@ -6454,6 +6463,7 @@ export function App({ ), [ renderWelcomeHeader, + showWorktreeToggle, welcomeHeaderProps, worktreePending, worktreeToggleEligible, diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css index 60cbde407b8..387a510afee 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -190,14 +190,8 @@ } :global([data-web-shell-slash-menu]) { - width: min( - max( - 240px, - calc( - var(--slash-command-col) + var(--slash-desc-col) + - var(--slash-column-gap) + 20px - ) - ), + width: 620px; + max-width: min( calc(var(--radix-popover-trigger-width) - 32px), var(--radix-popover-content-available-width), calc(100vw - 24px) @@ -208,11 +202,7 @@ :global([data-web-shell-slash-detail]) { z-index: calc(var(--web-shell-popover-z-index, 1000) + 1); - width: min( - 320px, - var(--radix-popover-content-available-width), - calc(100vw - 24px) - ); + width: min(320px, calc(100vw - 24px)); max-height: min(200px, var(--radix-popover-content-available-height)); overflow: hidden; } @@ -303,8 +293,8 @@ width: 100%; min-width: 0; grid-template-columns: - minmax(0, 2fr) - minmax(0, 3fr); + minmax(0, 220px) + minmax(0, 1fr); column-gap: 8px; align-items: baseline; padding: 5px 8px; @@ -379,6 +369,7 @@ font-size: 12px; font-weight: 400; line-height: 20px; + text-align: right; text-overflow: ellipsis; white-space: nowrap; } @@ -695,18 +686,6 @@ font-size: 12px; } -@container (max-width: 699px) { - .slashList { - width: 100%; - } - - .slashItem { - width: 100%; - grid-template-columns: minmax(0, 1fr); - row-gap: 2px; - } -} - .attachments { display: flex; min-height: 0; @@ -958,14 +937,12 @@ border: 0; background: transparent; cursor: pointer; - font: inherit; - color: inherit; - padding: 0; margin: 0; } .gitBranchChipButton:hover { - background: var(--subtle-bg); + background: var(--chat-editor-bg-tertiary); + color: var(--chat-editor-text-primary); } .gitBranchChipButton:focus-visible { diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index c026be4132e..6c5a9f27914 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -9,7 +9,7 @@ import { useRef, useState, } from 'react'; -import type { CSSProperties, ReactNode, RefObject } from 'react'; +import type { ReactNode, RefObject } from 'react'; import { Tooltip as TooltipPrimitive } from 'radix-ui'; import { DAEMON_APPROVAL_MODES } from '@qwen-code/webui/daemon-react-sdk'; import type { CommandInfo } from '../adapters/types'; @@ -838,6 +838,7 @@ function SlashCommandPanel({ const [hoverDetail, setHoverDetail] = useState<{ label: string; detail: string; + side: 'top' | 'right' | 'bottom' | 'left'; } | null>(null); useEffect(() => { @@ -885,25 +886,6 @@ function SlashCommandPanel({ }, []); const rowPlans = planSlashSectionRows(menu.items, menu.kind); - const maxLabelLength = Math.max( - ...menu.items.map((item) => Array.from(item.label).length), - 0, - ); - const maxDetailLength = Math.max( - ...menu.items.map((item) => Array.from(item.detail ?? '').length), - 0, - ); - const hasDetailColumn = maxDetailLength > 0; - const panelStyle = { - '--slash-command-col': `${Math.min( - Math.max(maxLabelLength + 1, 10), - 24, - )}ch`, - '--slash-desc-col': hasDetailColumn - ? `${Math.min(Math.max(maxDetailLength + 1, 18), 36)}ch` - : '0px', - '--slash-column-gap': hasDetailColumn ? '2ch' : '0px', - } as CSSProperties; return ( <> @@ -924,11 +906,12 @@ function SlashCommandPanel({ align="start" alignOffset={16} sideOffset={8} + avoidCollisions={false} collisionPadding={12} collisionBoundary={collisionBoundary ?? undefined} + className="duration-0 data-open:animate-none data-closed:animate-none" role="listbox" data-web-shell-slash-menu - style={panelStyle} onOpenAutoFocus={(event) => event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()} onInteractOutside={(event) => { @@ -1001,9 +984,29 @@ function SlashCommandPanel({ return; } hoverAnchorRef.current = event.currentTarget; + const rowRect = + event.currentTarget.getBoundingClientRect(); + const boundaryRect = + collisionBoundary?.getBoundingClientRect(); + const left = boundaryRect?.left ?? 0; + const right = + boundaryRect?.right ?? window.innerWidth; + const top = boundaryRect?.top ?? 0; + const bottom = + boundaryRect?.bottom ?? window.innerHeight; + const detailWidth = Math.min(320, right - left - 24); + const side = + right - rowRect.right >= detailWidth + 8 + ? 'right' + : rowRect.left - left >= detailWidth + 8 + ? 'left' + : rowRect.top - top >= bottom - rowRect.bottom + ? 'top' + : 'bottom'; setHoverDetail({ label: item.label, detail: item.detail, + side, }); }} onMouseDown={(event) => { @@ -1043,11 +1046,12 @@ function SlashCommandPanel({ {hoverDetail && ( event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()} diff --git a/packages/web-shell/client/components/WorkspaceSelector.tsx b/packages/web-shell/client/components/WorkspaceSelector.tsx index e2a531bb361..598c43f2f9a 100644 --- a/packages/web-shell/client/components/WorkspaceSelector.tsx +++ b/packages/web-shell/client/components/WorkspaceSelector.tsx @@ -1,3 +1,4 @@ +import { useRef, useState } from 'react'; import { FolderClosedIcon, FolderPlusIcon, LockIcon } from 'lucide-react'; import { useI18n } from '../i18n'; import { @@ -12,6 +13,12 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from './ui/dropdown-menu'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from './ui/tooltip'; export interface WorkspaceSelectorOption { id: string; @@ -51,6 +58,10 @@ export function WorkspaceSelector({ onOpenExistingFolder, }: WorkspaceSelectorProps) { const { t } = useI18n(); + const [menuOpen, setMenuOpen] = useState(false); + const [tooltipOpen, setTooltipOpen] = useState(false); + const menuOpenRef = useRef(false); + const suppressTooltipRef = useRef(false); const selected = workspaces.find((workspace) => selectedWorkspaceCwd ? workspace.cwd === selectedWorkspaceCwd @@ -60,68 +71,109 @@ export function WorkspaceSelector({ if (workspaces.length <= 1 && !canCreate) return null; return ( - - - - - - { - const next = workspaces.find((workspace) => workspace.id === id); - if (!next?.trusted) return; - onSelectWorkspace(next.primary ? undefined : next.cwd); + + { + menuOpenRef.current = open; + setMenuOpen(open); + if (open) { + suppressTooltipRef.current = true; + setTooltipOpen(false); + } + }} + > + { + if (open && (menuOpen || suppressTooltipRef.current)) { + return; + } + setTooltipOpen(open); }} > - {workspaces.map((workspace) => ( - - {workspace.label} - {!workspace.trusted && ( - - - {t('sidebar.workspaceUntrusted')} + + + + + + {selected?.label} + + + { + const next = workspaces.find((workspace) => workspace.id === id); + if (!next?.trusted) return; + onSelectWorkspace(next.primary ? undefined : next.cwd); + }} + > + {workspaces.map((workspace) => ( + + + {workspace.label} - )} - - ))} - - {canCreate && ( - <> - - - - - {t('sidebar.newWorkspace')} - - - {scratchSupported && ( - - {t('sidebar.startFromScratch')} - - )} - {existingFolderSupported && ( - - {t('sidebar.useExistingFolder')} - + {!workspace.trusted && ( + + + {t('sidebar.workspaceUntrusted')} + )} - - - - )} - - + + ))} + + {canCreate && ( + <> + + + + + {t('sidebar.newWorkspace')} + + + {scratchSupported && ( + + {t('sidebar.startFromScratch')} + + )} + {existingFolderSupported && ( + + {t('sidebar.useExistingFolder')} + + )} + + + + )} + + + ); } diff --git a/packages/web-shell/client/components/ui/tooltip.tsx b/packages/web-shell/client/components/ui/tooltip.tsx index fc8b6e485df..8b1e6dd6d15 100644 --- a/packages/web-shell/client/components/ui/tooltip.tsx +++ b/packages/web-shell/client/components/ui/tooltip.tsx @@ -31,7 +31,7 @@ function TooltipTrigger({ function TooltipContent({ className, - sideOffset = 0, + sideOffset = 8, children, ...props }: React.ComponentProps) { @@ -42,13 +42,12 @@ function TooltipContent({ data-slot="tooltip-content" sideOffset={sideOffset} className={cn( - 'z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95', + "relative z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground before:pointer-events-none before:absolute before:size-2.5 before:rotate-45 before:rounded-[2px] before:bg-popover before:border-border before:content-[''] has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:before:-top-[5px] data-[side=bottom]:before:left-1/2 data-[side=bottom]:before:-translate-x-1/2 data-[side=bottom]:before:border-t data-[side=bottom]:before:border-l data-[side=left]:before:top-1/2 data-[side=left]:before:-right-[5px] data-[side=left]:before:-translate-y-1/2 data-[side=left]:before:border-t data-[side=left]:before:border-r data-[side=right]:before:top-1/2 data-[side=right]:before:-left-[5px] data-[side=right]:before:-translate-y-1/2 data-[side=right]:before:border-b data-[side=right]:before:border-l data-[side=top]:before:-bottom-[6px] data-[side=top]:before:left-1/2 data-[side=top]:before:-translate-x-1/2 data-[side=top]:before:border-r data-[side=top]:before:border-b data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-[state=instant-open]:animate-in data-[state=instant-open]:fade-in-0 data-[state=instant-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className, )} {...props} > {children} - ); diff --git a/packages/web-shell/client/hooks/useSessionArtifacts.ts b/packages/web-shell/client/hooks/useSessionArtifacts.ts index 8676a24c080..5f834b90c9f 100644 --- a/packages/web-shell/client/hooks/useSessionArtifacts.ts +++ b/packages/web-shell/client/hooks/useSessionArtifacts.ts @@ -73,13 +73,9 @@ export function useSessionArtifacts(): SessionArtifactsState { loadedSessionIdRef.current = sessionId; setArtifacts(result.artifacts); setError(null); - } catch (err) { + } catch { if (requestIdRef.current !== requestId) return; - if (isSessionDisconnectedError(err)) { - setError(null); - return; - } - setError(err instanceof Error ? err.message : String(err)); + setError(null); } finally { if (requestIdRef.current === requestId) { setLoading(false); @@ -120,8 +116,3 @@ export function useSessionArtifacts(): SessionArtifactsState { return { artifacts, artifactById, loading, error, refresh }; } - -function isSessionDisconnectedError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return message.includes('session is not connected'); -} diff --git a/packages/web-shell/client/main.tsx b/packages/web-shell/client/main.tsx index 2273e3a8525..d047f723c2a 100644 --- a/packages/web-shell/client/main.tsx +++ b/packages/web-shell/client/main.tsx @@ -172,6 +172,7 @@ function StandaloneApp({ daemonToken }: { daemonToken?: string }) { onLanguageChange: handleLanguageChange, onSessionIdChange: handleSessionIdChange, sidebar: true, + showWorktreeToggle: true, compactThinking: true, markdownTableMode: 'advanced', }} diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index 31943d82701..b72ec0549ef 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -1207,25 +1207,9 @@ export function createDaemonSessionActions({ }, async loadArtifacts(): Promise { - const session = requireSessionForAction( - addNotice, - sessionRef.current, - 'Load artifacts failed', - 'load_artifacts', - ); - try { - return await withActionTimeout( - session.artifacts(), - 'Load artifacts timed out', - ); - } catch (error) { - throw dispatchActionError( - addNotice, - 'Load artifacts failed', - error, - 'load_artifacts', - ); - } + const session = sessionRef.current; + if (!session) throw new Error('Daemon session is not connected'); + return withActionTimeout(session.artifacts(), 'Load artifacts timed out'); }, async respondToGlobalPermission( From 809b82c1130246d628cdfdc19b0f2a18912313ec Mon Sep 17 00:00:00 2001 From: ytahdn Date: Wed, 22 Jul 2026 11:57:11 +0800 Subject: [PATCH 2/3] fix(web-shell): complete file preview integration --- packages/web-shell/client/App.tsx | 1 + packages/web-shell/client/components/ChatPane.tsx | 4 ++++ .../web-shell/client/components/artifacts/ArtifactPanel.tsx | 4 +++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index a8398ffd20c..29f8d2a17c8 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -8042,6 +8042,7 @@ export function App({ error={artifactsError} onSelectTab={setActiveArtifactPanelTabId} onCloseTab={closeArtifactPanelTab} + onOpenFilePreview={openFilePreview} onClose={closeArtifactPanel} variant="drawer" /> diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index 68c98b3a6e7..7df9f693575 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -376,6 +376,10 @@ export function ChatPane({ const handleRightPanelOpen = useCallback( (request: TurnOutputOpenRequest) => { if (!onRightPanelOpen) return; + if (request.kind === 'subagent') { + onRightPanelOpen(request); + return; + } onRightPanelOpen({ ...request, workspaceActions }); }, [onRightPanelOpen, workspaceActions], diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx index 1d3eb754de0..0435af2f5a6 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx @@ -138,7 +138,9 @@ export function ArtifactPanel({ const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0]; const defaultWorkspaceActions = useWorkspaceActions(); const activeWorkspaceActions = - activeTab?.workspaceActions ?? defaultWorkspaceActions; + activeTab && 'workspaceActions' in activeTab + ? (activeTab.workspaceActions ?? defaultWorkspaceActions) + : defaultWorkspaceActions; return (