From c8827c7450abaf2d6438a0fa0dd629cdc103aa9e Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 21 Jul 2026 17:18:23 +0800 Subject: [PATCH 1/7] feat(workspace): rework folder, branch and command placement in the desktop chrome - Move the folder selector into the conversation header: alias-aware, iconless, sized to the title, themed while drafting a new conversation and muted for an existing one - Drop the folder and branch pickers below the composer - Host the git branch selector and command launcher in the bottom status bar as split controls that share a hover surface, with a quick pull-code button beside the branch name and cloud pull/push icons in the branch menu - Show folder aliases in the folder picker list and match them when searching - Hide the actions bar atop the session-details tab on desktop and remove the old static branch label - Rename the folderless label to "Chat mode" across all locales - Give the active conversation composer a little more bottom breathing room --- src/components/chat/chat-input.tsx | 6 +- .../chat/conversation-context-bar.test.tsx | 60 +- .../chat/conversation-context-bar.tsx | 228 ++++++- src/components/chat/message-input.tsx | 18 +- .../conversation-detail-header.test.tsx | 8 +- .../conversation-detail-header.tsx | 38 +- .../conversation-detail-panel-layout.test.ts | 25 +- .../conversation-detail-panel.tsx | 2 - .../aux-panel-session-details-tab.test.tsx | 44 +- .../layout/aux-panel-session-details-tab.tsx | 22 +- src/components/layout/branch-dropdown.tsx | 574 ++++++++++-------- src/components/layout/command-dropdown.tsx | 46 +- .../layout/status-bar-session-info.tsx | 43 -- src/components/layout/status-bar.tsx | 13 +- src/i18n/messages/ar.json | 2 +- src/i18n/messages/de.json | 2 +- src/i18n/messages/en.json | 2 +- src/i18n/messages/es.json | 2 +- src/i18n/messages/fr.json | 2 +- src/i18n/messages/ja.json | 2 +- src/i18n/messages/ko.json | 2 +- src/i18n/messages/pt.json | 2 +- src/i18n/messages/zh-CN.json | 2 +- src/i18n/messages/zh-TW.json | 2 +- 24 files changed, 715 insertions(+), 432 deletions(-) delete mode 100644 src/components/layout/status-bar-session-info.tsx diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index cfcbdacad..9daa9c3f1 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -124,9 +124,13 @@ export const ChatInput = memo(function ChatInput({ const isPrompting = status === "prompting" const isConnecting = status === "connecting" + // Active/historical conversations dock the composer at the very bottom of the + // message list, so it gets a bit more bottom breathing room (pb-4) than the + // compact default. The welcome/draft composer (`flush`) keeps its tighter pb-1 + // — it sits in a roomy empty state and supplies its own px-4 gutter. return (
event.stopPropagation()} > {queue && diff --git a/src/components/chat/conversation-context-bar.test.tsx b/src/components/chat/conversation-context-bar.test.tsx index dfc7bac2e..c932035c7 100644 --- a/src/components/chat/conversation-context-bar.test.tsx +++ b/src/components/chat/conversation-context-bar.test.tsx @@ -2,7 +2,10 @@ import { render, screen, cleanup } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { ConversationFolderBranchPicker } from "./conversation-context-bar" +import { + ConversationFolderBranchPicker, + ConversationHeaderFolderPicker, +} from "./conversation-context-bar" import type { FolderDetail } from "@/lib/types" import { resetAppWorkspaceStore, @@ -165,3 +168,58 @@ describe("ConversationFolderBranchPicker — branch checkout", () => { expect(gitCheckout).not.toHaveBeenCalled() }) }) + +// The desktop conversation header renders this folder-only picker in place of +// the old folder-name breadcrumb. `next-intl` is mocked to echo keys, so a +// translated label like the chat-mode item reads back as its key. +describe("ConversationHeaderFolderPicker", () => { + it("switches folders for a draft via openNewConversationTab", async () => { + const other = mkFolder({ id: 2, name: "other-repo", path: "/repo/other" }) + useAppWorkspaceStore.setState({ + folders: [repo, other], + allFolders: [repo, other], + }) + tabs = [{ id: "tab-draft", folderId: 1, conversationId: null }] + activeTabId = "tab-draft" + + const user = userEvent.setup() + render() + // Draft → editable trigger showing the current folder name. + await user.click(screen.getByRole("button", { name: /repo/ })) + await user.click(await screen.findByText("other-repo")) + + expect(openNewConversationTab).toHaveBeenCalledWith(2, "/repo/other", { + inheritFromActive: true, + }) + }) + + it("renders a static (non-switchable) chip for an existing conversation", async () => { + const other = mkFolder({ id: 2, name: "other-repo", path: "/repo/other" }) + useAppWorkspaceStore.setState({ + folders: [repo, other], + allFolders: [repo, other], + }) + tabs = [{ id: "tab-1", folderId: 1, conversationId: 42 }] + activeTabId = "tab-1" + + const user = userEvent.setup() + render() + await user.click(screen.getByRole("button", { name: /repo/ })) + // Non-editable: clicking opens no folder list, so the other repo is + // unreachable and no switch fires. + expect(screen.queryByText("other-repo")).toBeNull() + expect(openNewConversationTab).not.toHaveBeenCalled() + }) + + it("shows the chat-mode label for a folderless chat tab", () => { + tabs = [ + { id: "tab-chat", folderId: 999, conversationId: null, isChat: true }, + ] + activeTabId = "tab-chat" + + render() + expect( + screen.getByRole("button", { name: /chatModeLabel/ }) + ).toBeTruthy() + }) +}) diff --git a/src/components/chat/conversation-context-bar.tsx b/src/components/chat/conversation-context-bar.tsx index 02ffb55b6..e3948413b 100644 --- a/src/components/chat/conversation-context-bar.tsx +++ b/src/components/chat/conversation-context-bar.tsx @@ -53,10 +53,13 @@ import { cn } from "@/lib/utils" import { excludeChatFolders, filterTopLevelFolders, + formatFolderLabelWithAlias, resolveFolderDisplayName, resolvePickerSelectedFolderId, } from "@/lib/folder-display" import { useSwitchToBranch } from "@/hooks/use-switch-to-branch" +import { useIsMobile } from "@/hooks/use-mobile" +import { FolderAliasLabel } from "@/components/conversations/folder-alias-label" interface ConversationContextBarProps { extraContent?: React.ReactNode @@ -123,9 +126,133 @@ export const ConversationContextBar = memo(function ConversationContextBar({ ConversationContextBar.displayName = "ConversationContextBar" +// ============================================================================ +// ConversationHeaderFolderPicker — the folder selector on its own. Rendered in +// the desktop conversation header (replacing the old folder-name breadcrumb). +// Self-contained: resolves its own tab/folder from `tabId` (or the active tab) +// so the mount site only passes `tabId`. The branch selector no longer sits +// beside it here — on desktop it lives in the bottom status bar. +// ============================================================================ + +interface ConversationHeaderFolderPickerProps { + tabId?: string | null +} + +export const ConversationHeaderFolderPicker = memo( + function ConversationHeaderFolderPicker({ + tabId, + }: ConversationHeaderFolderPickerProps) { + const t = useTranslations("Folder.conversationContextBar") + const tabs = useTabStore((s) => s.tabs) + const activeTabId = useTabStore((s) => s.activeTabId) + const { openNewConversationTab, openChatModeTab } = useTabActions() + const folders = useAppWorkspaceStore((s) => s.folders) + const allFolders = useAppWorkspaceStore((s) => s.allFolders) + + const ownTab = useMemo(() => { + const lookupId = tabId ?? activeTabId + return tabs.find((x) => x.id === lookupId) ?? null + }, [tabs, tabId, activeTabId]) + + const ownFolder = useMemo( + () => + ownTab + ? (allFolders.find((f) => f.id === ownTab.folderId) ?? null) + : null, + [ownTab, allFolders] + ) + + // Only top-level repos are switchable here; worktree folders are reached + // via the branch picker, and hidden chat folders are a per-conversation + // implementation detail, so both are excluded from the list. + const topLevelFolders = useMemo( + () => excludeChatFolders(filterTopLevelFolders(folders)), + [folders] + ) + + if (!ownTab) return null + // Chat mode: a draft flagged `isChat` (no folder yet) or a conversation + // bound to a hidden chat folder. The chip still shows so the user can + // switch back to a real folder while drafting. + const isChatMode = ownTab.isChat === true || ownFolder?.kind === "chat" + if (!ownFolder && !isChatMode) return null + + const isNewConversation = ownTab.conversationId == null + // Worktree folders surface their parent (root repo) row; resolve that same + // folder so the header shows the repo's `alias [ name ]` (matching the + // sidebar) rather than the worktree dir's own name. Git/path ops still use + // `ownFolder` (the worktree) unchanged. + const pickerSelectedId = + isChatMode || !ownFolder ? -1 : resolvePickerSelectedFolderId(ownFolder) + const displayFolder = + isChatMode || !ownFolder + ? null + : (allFolders.find((f) => f.id === pickerSelectedId) ?? ownFolder) + const displayFolderName = isChatMode + ? t("chatModeLabel") + : resolveFolderDisplayName(ownFolder!, allFolders) + const displayFolderAlias = displayFolder?.alias ?? null + // The full `alias [ name ]` also goes in the tooltip for the truncated case. + const titleFolderName = displayFolder + ? formatFolderLabelWithAlias(displayFolder) + : displayFolderName + + return ( + { + const target = folders.find((f) => f.id === folderId) + if (!target) return + try { + // Route through openNewConversationTab so the target folder's saved + // default agent is applied; the existing-draft branch reuses ownTab + // via the singleton invariant. `inheritFromActive: true` keeps the + // user's current agent when the target folder has no pinned default. + openNewConversationTab(target.id, target.path, { + inheritFromActive: true, + }) + toast.success(t("toasts.folderChanged", { name: target.name })) + } catch (err) { + console.error( + "[ConversationHeaderFolderPicker] switch folder failed:", + err + ) + toast.error(t("toasts.openFolderFailed")) + } + }} + labelEmpty={t("noFolders")} + labelSearch={t("searchFolder")} + labelChatMode={t("chatModeLabel")} + isChatMode={isChatMode} + onSelectChatMode={() => { + try { + openChatModeTab() + toast.success(t("toasts.switchedToChatMode")) + } catch (err) { + console.error( + "[ConversationHeaderFolderPicker] switch to chat mode failed:", + err + ) + toast.error(t("toasts.openFolderFailed")) + } + }} + /> + ) + } +) + +ConversationHeaderFolderPicker.displayName = "ConversationHeaderFolderPicker" + // ============================================================================ // ConversationFolderBranchPicker — folder + branch buttons rendered below the -// message input. +// message input (mobile only; on desktop the folder picker lives in the +// conversation header and the branch selector in the bottom status bar). // ============================================================================ interface ConversationFolderBranchPickerProps { @@ -285,6 +412,7 @@ ConversationFolderBranchPicker.displayName = "ConversationFolderBranchPicker" export function useConversationFolderBranchPickerVisible( tabId?: string | null ): boolean { + const isMobile = useIsMobile() const tabs = useTabStore((s) => s.tabs) const activeTabId = useTabStore((s) => s.activeTabId) const allFolders = useAppWorkspaceStore((s) => s.allFolders) @@ -293,9 +421,12 @@ export function useConversationFolderBranchPickerVisible( const ownFolder = ownTab ? (allFolders.find((f) => f.id === ownTab.folderId) ?? null) : null - // Chat-mode drafts have no resolvable folder yet, but the picker row must - // still show so the folder chip (and the "no-folder mode" item) are reachable. - return Boolean(ownTab && (ownFolder || ownTab.isChat)) + // The below-composer folder+branch row is mobile-only: on desktop the folder + // picker moved into the conversation header and the branch selector into the + // bottom status bar. Chat-mode drafts have no resolvable folder yet, but the + // row must still show so the folder chip (and the chat-mode item) stay + // reachable. + return isMobile && Boolean(ownTab && (ownFolder || ownTab.isChat)) } // ============================================================================ @@ -303,7 +434,7 @@ export function useConversationFolderBranchPickerVisible( // ============================================================================ interface FolderPickerProps { - folders: { id: number; name: string; path: string }[] + folders: { id: number; name: string; path: string; alias?: string | null }[] currentFolderId: number currentFolderName: string title: string @@ -317,6 +448,14 @@ interface FolderPickerProps { isChatMode: boolean /** Select folderless chat mode. */ onSelectChatMode: () => void + /** Trigger appearance. `"chip"` (default) = folder icon + name + chevron, the + * compact below-input row (mobile). `"header"` = bare text sized to the + * conversation title, alias-aware, themed while editable — the desktop + * conversation-header breadcrumb. */ + variant?: "chip" | "header" + /** Folder alias for the `"header"` variant's `alias [ name ]` label (rendered + * via {@link FolderAliasLabel}). Ignored by the chip variant. */ + alias?: string | null } const FolderPicker = memo(function FolderPicker({ @@ -331,27 +470,58 @@ const FolderPicker = memo(function FolderPicker({ labelChatMode, isChatMode, onSelectChatMode, + variant = "chip", + alias = null, }: FolderPickerProps) { const [open, setOpen] = useState(false) - const trigger = ( - - ) + // Header variant: no icons, `alias [ name ]` inline (matching the sidebar + // folder header), sized to the neighbouring conversation title. A new/editable + // conversation renders in the theme color to advertise that it's switchable; + // a bound one is a static foreground chip that reads as a breadcrumb crumb. + const headerLabel = + alias && alias.trim() ? ( + + ) : ( + currentFolderName + ) + + const trigger = + variant === "header" ? ( + + ) : ( + + ) if (!editable) { return trigger @@ -369,7 +539,9 @@ const FolderPicker = memo(function FolderPicker({ {folders.map((f) => ( { setOpen(false) void onSelect(f.id) @@ -377,7 +549,13 @@ const FolderPicker = memo(function FolderPicker({ >
- {f.name} + + + {f.path} diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 2f7dc2e4f..6d73dc795 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -3022,17 +3022,21 @@ export function MessageInput({ // the default `border-input`, which is near-invisible at rest and // vanishes over a workspace background image); it adapts per theme // (dark ink in light mode, light ink in dark) and stays legible. - // Focus still swaps to `border-ring` below. - "codeg-composer-chrome @container relative flex flex-col rounded-xl border border-foreground/20 bg-transparent transition-colors", - // Standard focus ring — always shown when the composer is - // focused (the plain default input style). `bg-background + // Focus still swaps to `border-ring` below. `bg-background // ws-transparent-bg`: opaque surface normally, but with a // workspace-bg image the composer goes transparent to reveal the // real image like the rest of the canvas (no frosted treatment) — - // the border stays. Off (no image) it's the plain background, - // unchanged. + // the border stays. Always on now: the folder/branch row no + // longer wraps the composer on desktop, so the surface has to + // live on the composer itself rather than the (removed) wrapper. + "codeg-composer-chrome @container relative flex flex-col rounded-xl border border-foreground/20 bg-background ws-transparent-bg transition-colors", + // Standard focus ring — always shown when the composer is + // focused (the plain default input style). When the mobile + // folder/branch row is attached below, the composer is clipped by + // an `overflow-hidden` wrapper, so the ring must be inset to stay + // visible; standalone (desktop) it uses the normal outset ring. folderBranchPickerAttached - ? "bg-background ws-transparent-bg focus-within:border-ring focus-within:ring-[3px] focus-within:ring-inset focus-within:ring-ring/50" + ? "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-inset focus-within:ring-ring/50" : "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50", // Active session, tiled across multiple sessions: a gradient // flows around the border to mark which tile is active — but ONLY diff --git a/src/components/conversations/conversation-detail-header.test.tsx b/src/components/conversations/conversation-detail-header.test.tsx index 03f4e9cc3..66e84c0ea 100644 --- a/src/components/conversations/conversation-detail-header.test.tsx +++ b/src/components/conversations/conversation-detail-header.test.tsx @@ -59,6 +59,11 @@ vi.mock("@/stores/conversation-runtime-store", () => ({ vi.mock("./session-details-dialog", () => ({ SessionDetailsDialog: () => null, })) +// The header now embeds the folder picker (self-contained, store-driven); stub +// it so these tests exercise only the header's own menu/dialog logic. +vi.mock("@/components/chat/conversation-context-bar", () => ({ + ConversationHeaderFolderPicker: () => null, +})) import { ConversationDetailHeader } from "./conversation-detail-header" @@ -70,8 +75,6 @@ const A: Props = { runtimeConversationId: null, folderId: 1, folderPath: "/a", - folderName: "folder-a", - folderAlias: null, title: "conv-a", status: "in_progress", } @@ -79,7 +82,6 @@ const B: Props = { ...A, tabId: "tab-b", conversationId: 2, - folderName: "folder-b", title: "conv-b", } diff --git a/src/components/conversations/conversation-detail-header.tsx b/src/components/conversations/conversation-detail-header.tsx index 8a7c2b7ee..782c1caa9 100644 --- a/src/components/conversations/conversation-detail-header.tsx +++ b/src/components/conversations/conversation-detail-header.tsx @@ -21,8 +21,7 @@ import { updateConversationTitle, } from "@/lib/api" import { formatConversationTitle } from "@/lib/conversation-title" -import { formatFolderLabelWithAlias } from "@/lib/folder-display" -import { FolderAliasLabel } from "@/components/conversations/folder-alias-label" +import { ConversationHeaderFolderPicker } from "@/components/chat/conversation-context-bar" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { useTabActions } from "@/contexts/tab-context" import { useConversationLocate } from "@/contexts/conversation-locate-context" @@ -75,11 +74,6 @@ interface ConversationDetailHeaderProps { runtimeConversationId: number | null folderId: number folderPath: string | undefined - /** Owning folder's raw name, shown as a breadcrumb left of the title. */ - folderName: string | null - /** Owning folder's user-set alias, or null. When set the breadcrumb renders - * `alias [ name ]` via {@link FolderAliasLabel}. */ - folderAlias: string | null title: string status: ConversationStatus | undefined } @@ -104,8 +98,6 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({ runtimeConversationId, folderId, folderPath, - folderName, - folderAlias, title, status, }: ConversationDetailHeaderProps) { @@ -253,27 +245,13 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({ // so the whole top of the column reveals the canvas.
- {folderName && ( - <> - - - - - - )} + {/* Folder selector — replaces the old folder-name breadcrumb. Switches + folders for a draft; a static chip for a bound conversation. */} + + {displayTitle} diff --git a/src/components/conversations/conversation-detail-panel-layout.test.ts b/src/components/conversations/conversation-detail-panel-layout.test.ts index 831bc5254..2e1e11cf6 100644 --- a/src/components/conversations/conversation-detail-panel-layout.test.ts +++ b/src/components/conversations/conversation-detail-panel-layout.test.ts @@ -84,18 +84,20 @@ describe("ConversationDetailPanel new conversation layout", () => { ) expect(messageInputSource).not.toContain("bg-muted/60") expect(messageInputSource).toContain(': "contents"') - // The rounded border lives in the always-on base (so the active-session flow - // gradient can overlay a real 1px border without a layout shift); the - // attached folder-branch-picker treatment still adds a solid surface - // (`bg-background`, which goes transparent to reveal a workspace-bg image via - // `ws-transparent-bg` instead of frosting) + the inset focus ring on top. + // The rounded border AND the solid surface now live in the always-on base: + // `bg-background` (which goes transparent to reveal a workspace-bg image via + // `ws-transparent-bg` instead of frosting) is no longer gated on the folder- + // branch picker, because on desktop that row no longer wraps the composer — + // the composer is a standalone rounded box and must carry its own surface. // The resting border is `border-foreground/20` (a touch darker than the // near-invisible default `border-input`, and legible over a background image). expect(messageInputSource).toContain( - "rounded-xl border border-foreground/20 bg-transparent transition-colors" + "rounded-xl border border-foreground/20 bg-background ws-transparent-bg transition-colors" ) + // The attached (mobile) branch only adds the inset focus ring now — the + // surface moved to the base above. expect(messageInputSource).toContain( - '"bg-background ws-transparent-bg focus-within:border-ring focus-within:ring-[3px] focus-within:ring-inset focus-within:ring-ring/50"' + '"focus-within:border-ring focus-within:ring-[3px] focus-within:ring-inset focus-within:ring-ring/50"' ) expect(pickerWrapper).not.toContain("border-t border-input") expect(pickerWrapper).not.toContain("bg-muted/30") @@ -114,10 +116,11 @@ describe("ConversationDetailPanel new conversation layout", () => { expect(conversationShellSource).toContain( 'className="mx-auto w-full max-w-3xl"' ) - // Ordinary (active) chat input keeps its own px-4 gutter to align with the - // sibling cards in conversation-shell; only the welcome input drops it via - // `flush` (the welcome column already provides the px-4). - expect(chatInputSource).toContain('cn("pt-0 pb-1", !flush && "px-4")') + // Ordinary (active/historical) chat input keeps its own px-4 gutter to align + // with the sibling cards in conversation-shell AND gets extra bottom room + // (pb-4) since it docks at the very bottom; only the welcome input drops the + // gutter via `flush` (the welcome column already provides px-4) and stays pb-1. + expect(chatInputSource).toContain('cn("pt-0", flush ? "pb-1" : "px-4 pb-4")') expect(chatInputSource).toContain( 'cn(tall ? "min-h-30" : "min-h-24", "max-h-60")' ) diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 0b07f32a4..d8f19d826 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -2047,8 +2047,6 @@ export function ConversationDetailPanel() { runtimeConversationId={activeTab.runtimeConversationId ?? null} folderId={activeTab.folderId} folderPath={activeTabFolder?.path} - folderName={activeTabFolder?.name ?? null} - folderAlias={activeTabFolder?.alias ?? null} title={activeTab.title} status={activeTab.status as ConversationStatus | undefined} /> diff --git a/src/components/layout/aux-panel-session-details-tab.test.tsx b/src/components/layout/aux-panel-session-details-tab.test.tsx index bba63afb0..0a7548323 100644 --- a/src/components/layout/aux-panel-session-details-tab.test.tsx +++ b/src/components/layout/aux-panel-session-details-tab.test.tsx @@ -126,15 +126,31 @@ describe("SessionDetailsTab", () => { vi.clearAllMocks() }) - it("renders the active session's details and the folder actions bar", () => { + it("renders the active session's details; desktop hides the folder actions bar", () => { + // Desktop: branch + command moved to the bottom status bar / conversation + // header, so the aux tab shows the details alone. setupScene({ activeFolderId: 1, isChatMode: false, hasActiveConversation: true, + isMobile: false, }) - const { getByText, getByTestId } = renderTab() + const { getByText, queryByTestId } = renderTab() expect(getByText("My session")).toBeTruthy() expect(getByText("Claude Code")).toBeTruthy() + expect(queryByTestId("branch-dropdown")).toBeNull() + expect(queryByTestId("command-dropdown")).toBeNull() + }) + + it("shows the folder actions bar on mobile (the Sheet has no status bar/header)", () => { + setupScene({ + activeFolderId: 1, + isChatMode: false, + hasActiveConversation: true, + isMobile: true, + }) + const { getByText, getByTestId } = renderTab() + expect(getByText("My session")).toBeTruthy() expect(getByTestId("branch-dropdown")).toBeTruthy() expect(getByTestId("command-dropdown")).toBeTruthy() }) @@ -144,6 +160,7 @@ describe("SessionDetailsTab", () => { activeFolderId: 1, isChatMode: true, hasActiveConversation: true, + isMobile: true, }) const { getByText, queryByTestId } = renderTab() expect(getByText("My session")).toBeTruthy() @@ -162,29 +179,26 @@ describe("SessionDetailsTab", () => { expect(queryByTestId("branch-dropdown")).toBeNull() }) - it("sizes the actions bar h-10 on desktop but keeps the mobile Sheet's py-2", () => { - // Desktop: the bar matches the conversation/file detail headers (h-10). + it("keeps the mobile actions bar padded (py-2) and desktop-free", () => { + // Mobile (Sheet): the bar shows with the original py-2 sizing. setupScene({ activeFolderId: 1, isChatMode: false, hasActiveConversation: true, - isMobile: false, + isMobile: true, }) - const desktop = renderTab() - const desktopBar = desktop.getByTestId("branch-dropdown").parentElement - expect(desktopBar?.className).toContain("h-10") - expect(desktopBar?.className).not.toContain("py-2") - desktop.unmount() + const mobile = renderTab() + const mobileBar = mobile.getByTestId("branch-dropdown").parentElement + expect(mobileBar?.className).toContain("py-2") + mobile.unmount() - // Mobile (Sheet): unchanged from before — the original py-2, no fixed height. + // Desktop: no actions bar at all — branch/command live elsewhere. setupScene({ activeFolderId: 1, isChatMode: false, hasActiveConversation: true, - isMobile: true, + isMobile: false, }) - const mobileBar = renderTab().getByTestId("branch-dropdown").parentElement - expect(mobileBar?.className).toContain("py-2") - expect(mobileBar?.className).not.toContain("h-10") + expect(renderTab().queryByTestId("branch-dropdown")).toBeNull() }) }) diff --git a/src/components/layout/aux-panel-session-details-tab.tsx b/src/components/layout/aux-panel-session-details-tab.tsx index 2a400ad03..1208d2755 100644 --- a/src/components/layout/aux-panel-session-details-tab.tsx +++ b/src/components/layout/aux-panel-session-details-tab.tsx @@ -14,7 +14,6 @@ import { useActiveFolder } from "@/contexts/active-folder-context" import { useIsActiveChatMode } from "@/hooks/use-is-active-chat-mode" import { useIsMobile } from "@/hooks/use-mobile" import { useAuxPanelContext } from "@/contexts/aux-panel-context" -import { cn } from "@/lib/utils" import { BranchDropdown } from "./branch-dropdown" import { CommandDropdown } from "./command-dropdown" @@ -84,23 +83,18 @@ export function SessionDetailsTab() { conversations ) - // Branch + command are folder-scoped and both self-hide in chat mode / without - // a folder, so only surface the actions bar for a real folder workspace to - // avoid an empty bordered row. - const showFolderActions = activeFolderId != null && !isChatMode + // Branch + command are folder-scoped and self-hide in chat mode / without a + // folder. This actions bar is now MOBILE-ONLY: on desktop the branch selector + // moved to the bottom status bar and the folder chip to the conversation + // header, so the desktop aux "session details" tab shows the details alone. + // The mobile Sheet has no status bar / header to host them, so it keeps this + // bar unchanged. + const showFolderActions = isMobile && activeFolderId != null && !isChatMode return (
{showFolderActions && ( -
+
diff --git a/src/components/layout/branch-dropdown.tsx b/src/components/layout/branch-dropdown.tsx index d3abbcdc7..1ae3b6a9c 100644 --- a/src/components/layout/branch-dropdown.tsx +++ b/src/components/layout/branch-dropdown.tsx @@ -11,9 +11,10 @@ import { import { ArchiveRestore, Archive, - ArrowDownToLine, - ChevronDown, ChevronRight, + CloudDownload, + CloudSync, + CloudUpload, FolderGit2, FolderOpen, GitBranch, @@ -24,9 +25,7 @@ import { GitPullRequestArrow, Globe, Loader2, - RefreshCw, Trash2, - Upload, } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" @@ -135,7 +134,15 @@ interface GitPushSucceededEventPayload { upstream_set: boolean } -export function BranchDropdown() { +interface BranchDropdownProps { + /** Show the owning-folder name before the branch (default). The bottom + * status-bar instance sets this false to render just the branch name. */ + showFolderName?: boolean +} + +export function BranchDropdown({ + showFolderName = true, +}: BranchDropdownProps = {}) { const t = useTranslations("Folder.branchDropdown") const tCommon = useTranslations("Folder.common") const { activeFolder } = useActiveFolder() @@ -364,6 +371,29 @@ export function BranchDropdown() { }) } + // Pull, shared by the dropdown menu item and the status-bar quick-pull icon + // button beside the branch name (so both entry points behave identically). + function handlePull() { + setDropdownOpen(false) + void runGitTask( + t("tasks.pullCode"), + () => + withCredentialRetry((creds) => gitPull(folderPath, creds), { + folderPath, + }), + (result) => { + if (result.conflict?.has_conflicts) { + setConflictInfo(result.conflict) + return false + } + if (result.updated_files === 0) { + return t("toasts.allFilesUpToDate") + } + return t("toasts.updatedFiles", { count: result.updated_files }) + } + ) + } + async function handleNewBranch() { const name = newBranchName.trim() if (!name) return @@ -692,18 +722,41 @@ export function BranchDropdown() { // below still use `activeFolder` (the worktree) unchanged. const folderName = resolveFolderDisplayName(activeFolder, allFolders) + // In the compact bottom status bar (showFolderName=false) the branch name is + // the LEFT half of a command-style split control (a quick-pull button sits on + // the right): it inherits the bar's text-xs, goes muted→foreground on its own + // hover, and drops the primary accent. The mobile combo (showFolderName=true, + // `folder | branch`) stays a single text-sm button with a primary-highlighted + // branch so the two segments read as distinct. + const isStatusBar = !showFolderName + const triggerClassName = cn( + "flex min-w-0 items-center outline-none transition-colors cursor-default", + isStatusBar + ? "h-6 gap-1.5 hover:text-foreground" + : "gap-1 text-sm tracking-tight hover:text-foreground/80" + ) + const branchAccentClassName = showFolderName ? "text-primary" : undefined + if (!isRepo) { return ( - @@ -723,260 +776,277 @@ export function BranchDropdown() { return ( <> - - - - - - - - runGitTask( - t("tasks.pullCode"), - () => - withCredentialRetry((creds) => gitPull(folderPath, creds), { - folderPath, - }), - (result) => { - if (result.conflict?.has_conflicts) { - setConflictInfo(result.conflict) - return false - } - if (result.updated_files === 0) { - return t("toasts.allFilesUpToDate") - } - return t("toasts.updatedFiles", { - count: result.updated_files, - }) - } - ) +
+ + + + + + + + + {t("pullCode")} + + + runGitTask(t("tasks.fetchInfo"), () => + withCredentialRetry( + (creds) => gitFetch(folderPath, creds), + { + folderPath, + } + ) + ) + } + > + + {t("fetchRemoteBranches")} + + + + + { + if (!folderId) return + setDropdownOpen(false) + openCommitWindow(folderId).catch((err) => { + const title = t("toasts.openCommitWindowFailed") + const msg = toErrorMessage(err) + pushAlert("error", title, msg) + toast.error(title, { description: msg }) }) - ) - } - > - - {t("fetchRemoteBranches")} - - - - - { - if (!folderId) return - setDropdownOpen(false) - openCommitWindow(folderId).catch((err) => { - const title = t("toasts.openCommitWindowFailed") - const msg = toErrorMessage(err) - pushAlert("error", title, msg) - toast.error(title, { description: msg }) - }) - }} - > - - {t("openCommitWindow")} - - { - if (!folderId) return - setDropdownOpen(false) - openPushWindow(folderId).catch((err) => { - const title = t("toasts.openPushWindowFailed") - const msg = toErrorMessage(err) - pushAlert("error", title, msg) - toast.error(title, { description: msg }) - }) - }} - > - - {t("pushCode")} - - - - - { - setNewBranchName("") - setNewBranchOpen(true) - }} - > - - {t("newBranch")} - - - - {t("newWorktree")} - - - - - { - setDropdownOpen(false) - setStashDialogOpen(true) - }} - > - - {t("stashChanges")} - - { - if (!folderId) return - openStashWindow(folderId).catch((err) => { - const msg = toErrorMessage(err) - pushAlert("error", t("stashPop"), msg) - }) - }} - > - - {t("stashPop")} - - - - - { - setDropdownOpen(false) - setManageRemotesOpen(true) - }} - > - - {t("manageRemotes")} - - - - {branchLoading ? ( -
- -
- ) : ( - + }} + > + + {t("openCommitWindow")} + { - e.preventDefault() - toggle(localSectionKey) + disabled={loading} + onSelect={() => { + if (!folderId) return + setDropdownOpen(false) + openPushWindow(folderId).catch((err) => { + const title = t("toasts.openPushWindowFailed") + const msg = toErrorMessage(err) + pushAlert("error", title, msg) + toast.error(title, { description: msg }) + }) }} > - - {t("localBranches", { count: branchList.local.length })} + + {t("pushCode")} - {isExpanded(localSectionKey) && - (branchList.local.length === 0 ? ( - - {t("noLocalBranches")} - - ) : ( - renderDropdownTree(localNodes, 0, false) - ))} - + + + { - e.preventDefault() - toggle(remoteSectionKey) + disabled={loading} + onSelect={() => { + setNewBranchName("") + setNewBranchOpen(true) }} > - - {t("remoteBranches", { count: branchList.remote.length })} + + {t("newBranch")} + + + + {t("newWorktree")} + + + + + { + setDropdownOpen(false) + setStashDialogOpen(true) + }} + > + + {t("stashChanges")} + + { + if (!folderId) return + openStashWindow(folderId).catch((err) => { + const msg = toErrorMessage(err) + pushAlert("error", t("stashPop"), msg) + }) + }} + > + + {t("stashPop")} + + + + + { + setDropdownOpen(false) + setManageRemotesOpen(true) + }} + > + + {t("manageRemotes")} - {isExpanded(remoteSectionKey) && - (branchList.remote.length === 0 ? ( - - {t("noRemoteBranches")} - - ) : ( - remoteSections.map((section) => - section.remoteName == null ? ( - - {renderDropdownTree(section.nodes, 0, true)} - - ) : ( - - { - e.preventDefault() - toggle(section.key) - }} - style={{ - paddingLeft: branchRowPaddingLeft("dropdown", 0), - }} - > - - - {section.remoteName} - - - {section.count} - - - {isExpanded(section.key) && - renderDropdownTree(section.nodes, 1, true)} - + + + {branchLoading ? ( +
+ +
+ ) : ( + + { + e.preventDefault() + toggle(localSectionKey) + }} + > + + {t("localBranches", { count: branchList.local.length })} + + {isExpanded(localSectionKey) && + (branchList.local.length === 0 ? ( + + {t("noLocalBranches")} + + ) : ( + renderDropdownTree(localNodes, 0, false) + ))} + + { + e.preventDefault() + toggle(remoteSectionKey) + }} + > + + {t("remoteBranches", { count: branchList.remote.length })} + + {isExpanded(remoteSectionKey) && + (branchList.remote.length === 0 ? ( + + {t("noRemoteBranches")} + + ) : ( + remoteSections.map((section) => + section.remoteName == null ? ( + + {renderDropdownTree(section.nodes, 0, true)} + + ) : ( + + { + e.preventDefault() + toggle(section.key) + }} + style={{ + paddingLeft: branchRowPaddingLeft("dropdown", 0), + }} + > + + + {section.remoteName} + + + {section.count} + + + {isExpanded(section.key) && + renderDropdownTree(section.nodes, 1, true)} + + ) ) - ) - ))} - - )} -
-
+ ))} + + )} + + + {/* Quick-pull button — the right half of the split, mirroring the + command block. Its own hover highlights just this icon; the shared + container tints the whole pill and the divider brightens. */} + {isStatusBar && ( + <> + + + + )} +
) : ( - // Has commands → split button: [name ▼] [run/stop] -
+ // Has commands → one cohesive control: [name ▼ | run/stop]. The whole + // pill highlights as a SINGLE unit on hover (background overlay on the + // group container, not on each half), so the two affordances read as one + // command block rather than two adjacent buttons. `bg-foreground/10` is a + // translucent overlay on purpose: the status-bar surface is already + // `--muted`, so a `bg-muted`/`bg-accent` hover would be invisible against + // it in light mode. +
- + {commands.map((cmd) => ( @@ -285,27 +294,34 @@ export function CommandDropdown() { - +
)} diff --git a/src/components/layout/status-bar-session-info.tsx b/src/components/layout/status-bar-session-info.tsx deleted file mode 100644 index a37aeac61..000000000 --- a/src/components/layout/status-bar-session-info.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client" - -import { useMemo } from "react" -import { GitBranch } from "lucide-react" -import { useTabStore } from "@/contexts/tab-context" -import { useAppWorkspaceStore } from "@/stores/app-workspace-store" - -export function StatusBarSessionInfo() { - const tabs = useTabStore((s) => s.tabs) - const activeTabId = useTabStore((s) => s.activeTabId) - - const activeTab = useMemo( - () => tabs.find((t) => t.id === activeTabId) ?? null, - [tabs, activeTabId] - ) - - // Selecting the matching summary (not the whole list) keeps this component - // inert to unrelated conversation updates: `find` returns the same object - // reference until this conversation itself changes. - const summary = useAppWorkspaceStore((s) => { - if (!activeTab || activeTab.kind !== "conversation") return null - return ( - s.conversations.find( - (c) => - c.id === activeTab.conversationId && - c.agent_type === activeTab.agentType - ) ?? null - ) - }) - - if (!summary) return null - - return ( -
- {summary.git_branch && ( - - - {summary.git_branch} - - )} -
- ) -} diff --git a/src/components/layout/status-bar.tsx b/src/components/layout/status-bar.tsx index 3622b4eb5..998f18712 100644 --- a/src/components/layout/status-bar.tsx +++ b/src/components/layout/status-bar.tsx @@ -1,12 +1,13 @@ "use client" import { StatusBarStats } from "@/components/layout/status-bar-stats" -import { StatusBarSessionInfo } from "@/components/layout/status-bar-session-info" import { StatusBarTasks } from "@/components/layout/status-bar-tasks" import { StatusBarTokens } from "@/components/layout/status-bar-tokens" import { StatusBarConnection } from "@/components/layout/status-bar-connection" import { StatusBarAlerts } from "@/components/layout/status-bar-alerts" import { StatusBarUpdate } from "@/components/layout/status-bar-update" +import { BranchDropdown } from "@/components/layout/branch-dropdown" +import { CommandDropdown } from "@/components/layout/command-dropdown" import { useIsMobile } from "@/hooks/use-mobile" export function StatusBar() { @@ -27,13 +28,19 @@ export function StatusBar() { return (
-
+
+ {/* Branch selector (moved from the aux "session details" tab). Folder + name is hidden — just the branch — since the folder chip now lives in + the conversation header. Self-hides in chat mode / without a repo. */} +
- + {/* Command launcher (moved from the aux "session details" tab), taking + the slot the old static branch label (StatusBarSessionInfo) held. */} + diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index a968120fa..3e1de9843 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2786,7 +2786,7 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", - "chatModeLabel": "وضع بدون مجلد", + "chatModeLabel": "وضع الدردشة", "commit": "Commit", "push": "Push", "merge": "Merge", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index bd637bfeb..95edb58dd 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2786,7 +2786,7 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", - "chatModeLabel": "Ohne-Ordner-Modus", + "chatModeLabel": "Chat-Modus", "commit": "Commit", "push": "Push", "merge": "Merge", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 501648bb2..eced8b101 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2786,7 +2786,7 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", - "chatModeLabel": "No-folder mode", + "chatModeLabel": "Chat mode", "commit": "Commit", "push": "Push", "merge": "Merge", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 0b2a582b1..a87a2f0da 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2786,7 +2786,7 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", - "chatModeLabel": "Modo sin carpeta", + "chatModeLabel": "Modo de chat", "commit": "Commit", "push": "Push", "merge": "Merge", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index bda8f4747..1c085b863 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2786,7 +2786,7 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", - "chatModeLabel": "Mode sans dossier", + "chatModeLabel": "Mode chat", "commit": "Commit", "push": "Push", "merge": "Merge", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 67ce8f71a..222203c67 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2786,7 +2786,7 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", - "chatModeLabel": "フォルダーなしモード", + "chatModeLabel": "チャットモード", "commit": "Commit", "push": "Push", "merge": "Merge", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 55ea3abc7..28705dda8 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2786,7 +2786,7 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", - "chatModeLabel": "폴더 없음 모드", + "chatModeLabel": "채팅 모드", "commit": "Commit", "push": "Push", "merge": "Merge", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index e83807984..888d0950e 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2786,7 +2786,7 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", - "chatModeLabel": "Modo sem pasta", + "chatModeLabel": "Modo de chat", "commit": "Commit", "push": "Push", "merge": "Merge", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 909927e91..858a59d86 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2786,7 +2786,7 @@ "noFolders": "暂无文件夹", "noBranches": "暂无分支", "noBranch": "(无分支)", - "chatModeLabel": "无文件夹模式", + "chatModeLabel": "聊天模式", "commit": "提交", "push": "推送", "merge": "合并", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index a269244ac..ed563afeb 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2786,7 +2786,7 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", - "chatModeLabel": "無資料夾模式", + "chatModeLabel": "聊天模式", "commit": "Commit", "push": "Push", "merge": "Merge", From a6dd7f322a35ebc633a307c0a6393777654d5d35 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 21 Jul 2026 20:13:37 +0800 Subject: [PATCH 2/7] feat(workspace): align the mobile workspace chrome with the desktop layout - Replace the mobile conversation and file tab strips with the folder-and-title conversation header and the file breadcrumb header - Slim the top bar to a sidebar toggle, a new-conversation button and the terminal, aux-panel and settings controls, matched to the desktop height and draggable as a window region - Host the git branch selector on the left of the bottom status bar and the command launcher, context-window meter and alerts on the right - Show only session details in the session-details tab - Close the sidebar or file drawer after picking a conversation, starting a new one or opening a file - Drop the redundant folder and branch pickers below the composer - Register the tab close and navigation shortcuts in the always-mounted workspace controller so they keep working when the tab strips are hidden - Remove the unused embedded tab-bar variants and the retired picker code --- src/app/workspace/layout.tsx | 12 +- .../chat/conversation-context-bar.test.tsx | 97 +--- .../chat/conversation-context-bar.tsx | 493 +----------------- src/components/chat/message-input.test.tsx | 2 - src/components/chat/message-input.tsx | 59 +-- .../conversation-detail-panel-layout.test.ts | 62 +-- .../conversation-detail-panel.tsx | 13 +- .../files/file-workspace-tab-bar.tsx | 290 ++--------- .../layout/aux-panel-file-tree-tab.tsx | 13 +- .../aux-panel-session-details-tab.test.tsx | 101 +--- .../layout/aux-panel-session-details-tab.tsx | 28 +- src/components/layout/folder-title-bar.tsx | 186 ++++--- src/components/layout/sidebar.tsx | 13 +- src/components/layout/status-bar.tsx | 14 +- ...workspace-chrome-controller-source.test.ts | 53 ++ .../layout/workspace-chrome-controller.tsx | 72 ++- src/components/tabs/tab-bar.tsx | 215 +++----- 17 files changed, 446 insertions(+), 1277 deletions(-) create mode 100644 src/components/layout/workspace-chrome-controller-source.test.ts diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index db5c7d082..0f712eea4 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -337,7 +337,7 @@ function WorkspaceContent({ children }: { children: React.ReactNode }) { )}
{hasConvTabs ? ( - + ) : ( // No tabs → TabBar renders null; keep a drag region so the // empty bar can still move the window. @@ -427,7 +427,7 @@ function WorkspaceContent({ children }: { children: React.ReactNode }) { /> )}
- +
{fileReservesRight && (
{showConversation ? ( + // Mobile mirrors the desktop chrome: no tab strip — the conversation + // detail header (folder › title) renders inside {children}, and tabs + // are navigated from the sidebar (single active conversation at a time).
-
{children}
) : ( + // File view: the shared FileWorkspaceHeader (folder › file breadcrumb) + // replaces the file tab strip, matching the desktop file column.
- +
diff --git a/src/components/chat/conversation-context-bar.test.tsx b/src/components/chat/conversation-context-bar.test.tsx index c932035c7..aaa321a13 100644 --- a/src/components/chat/conversation-context-bar.test.tsx +++ b/src/components/chat/conversation-context-bar.test.tsx @@ -2,10 +2,7 @@ import { render, screen, cleanup } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { - ConversationFolderBranchPicker, - ConversationHeaderFolderPicker, -} from "./conversation-context-bar" +import { ConversationHeaderFolderPicker } from "./conversation-context-bar" import type { FolderDetail } from "@/lib/types" import { resetAppWorkspaceStore, @@ -13,13 +10,10 @@ import { } from "@/stores/app-workspace-store" // --------------------------------------------------------------------------- -// Mocks. The picker reads three contexts/hooks and one api call; everything -// else (cmdk tree building, branch-tree expansion) is pure and runs for real. +// Mocks. The header folder picker reads the tab store + tab actions and renders +// the shared FolderPicker (cmdk); the folder-display helpers run for real. // --------------------------------------------------------------------------- -const switchToBranch = vi.fn().mockResolvedValue(undefined) -const gitCheckout = vi.fn().mockResolvedValue(undefined) -const gitListAllBranches = vi.fn() const openNewConversationTab = vi.fn() vi.mock("next-intl", () => ({ @@ -30,17 +24,6 @@ vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() }, })) -vi.mock("@/hooks/use-switch-to-branch", () => ({ - useSwitchToBranch: () => switchToBranch, -})) - -vi.mock("@/lib/api", () => ({ - gitListAllBranches: (path: string) => gitListAllBranches(path), - // Present only so the "never bare-checkout" assertion has a spy to read; the - // component must not reach for it any more. - gitCheckout: (path: string, branch: string) => gitCheckout(path, branch), -})) - // Tab state, mutated per test before render. Workspace state (folders / // branches) is seeded into the real zustand store in beforeEach. let tabs: Array<{ @@ -88,15 +71,7 @@ const repo = mkFolder({ }) beforeEach(() => { - switchToBranch.mockClear() - gitCheckout.mockClear() openNewConversationTab.mockClear() - gitListAllBranches.mockReset() - gitListAllBranches.mockResolvedValue({ - local: ["main", "feat-x"], - remote: [], - worktree_branches: ["feat-x"], - }) resetAppWorkspaceStore() useAppWorkspaceStore.setState({ folders: [repo], @@ -107,68 +82,6 @@ beforeEach(() => { afterEach(() => cleanup()) -async function openBranchPickerAndSelect(branchName: string) { - const user = userEvent.setup() - // Two triggers render (folder + branch); the branch one is labelled by the - // current branch. - await user.click(screen.getByRole("button", { name: /main/ })) - const item = await screen.findByText(branchName) - await user.click(item) -} - -describe("ConversationFolderBranchPicker — branch checkout", () => { - it("routes an EXISTING conversation through switchToBranch (not a bare checkout)", async () => { - tabs = [{ id: "tab-1", folderId: 1, conversationId: 42 }] - activeTabId = "tab-1" - - render() - await openBranchPickerAndSelect("feat-x") - - expect(switchToBranch).toHaveBeenCalledTimes(1) - expect(switchToBranch).toHaveBeenCalledWith({ - activeFolder: repo, - branchName: "feat-x", - currentBranch: "main", - isRemote: false, - }) - // The whole point of the fix: existing conversations must never run a bare - // in-place `git checkout` (which fails for a worktree branch or hijacks a - // worktree onto a free one). - expect(gitCheckout).not.toHaveBeenCalled() - }) - - it("routes a DRAFT conversation through switchToBranch too (unchanged)", async () => { - tabs = [{ id: "tab-draft", folderId: 1, conversationId: null }] - activeTabId = "tab-draft" - - render() - await openBranchPickerAndSelect("feat-x") - - expect(switchToBranch).toHaveBeenCalledWith({ - activeFolder: repo, - branchName: "feat-x", - currentBranch: "main", - isRemote: false, - }) - expect(gitCheckout).not.toHaveBeenCalled() - }) - - it("does not fire a checkout when the picked branch is the current one", async () => { - tabs = [{ id: "tab-1", folderId: 1, conversationId: 42 }] - activeTabId = "tab-1" - - render() - const user = userEvent.setup() - await user.click(screen.getByRole("button", { name: /main/ })) - // Click the already-current branch inside the open popover. - const items = await screen.findAllByText("main") - await user.click(items[items.length - 1]) - - expect(switchToBranch).not.toHaveBeenCalled() - expect(gitCheckout).not.toHaveBeenCalled() - }) -}) - // The desktop conversation header renders this folder-only picker in place of // the old folder-name breadcrumb. `next-intl` is mocked to echo keys, so a // translated label like the chat-mode item reads back as its key. @@ -218,8 +131,6 @@ describe("ConversationHeaderFolderPicker", () => { activeTabId = "tab-chat" render() - expect( - screen.getByRole("button", { name: /chatModeLabel/ }) - ).toBeTruthy() + expect(screen.getByRole("button", { name: /chatModeLabel/ })).toBeTruthy() }) }) diff --git a/src/components/chat/conversation-context-bar.tsx b/src/components/chat/conversation-context-bar.tsx index e3948413b..f55c28025 100644 --- a/src/components/chat/conversation-context-bar.tsx +++ b/src/components/chat/conversation-context-bar.tsx @@ -1,38 +1,12 @@ "use client" -import { - Fragment, - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react" +import { memo, useEffect, useMemo, useRef, useState } from "react" import { useTranslations } from "next-intl" import { toast } from "sonner" -import { - Check, - ChevronDown, - ChevronRight, - Folder, - GitBranch, - Loader2, - MessageSquare, -} from "lucide-react" +import { Check, ChevronDown, Folder, MessageSquare } from "lucide-react" import type { OverlayScrollbarsComponentRef } from "overlayscrollbars-react" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { useTabActions, useTabStore } from "@/contexts/tab-context" -import { gitListAllBranches } from "@/lib/api" -import { - buildBranchTree, - buildRemoteBranchSections, - expandedKeysForBranch, - localBranchItems, - type BranchTreeNode, -} from "@/lib/branch-tree" -import { useBranchTreeExpansion } from "@/hooks/use-branch-tree-expansion" -import type { GitBranchList } from "@/lib/types" import { Button } from "@/components/ui/button" import { Popover, @@ -57,8 +31,6 @@ import { resolveFolderDisplayName, resolvePickerSelectedFolderId, } from "@/lib/folder-display" -import { useSwitchToBranch } from "@/hooks/use-switch-to-branch" -import { useIsMobile } from "@/hooks/use-mobile" import { FolderAliasLabel } from "@/components/conversations/folder-alias-label" interface ConversationContextBarProps { @@ -249,186 +221,6 @@ export const ConversationHeaderFolderPicker = memo( ConversationHeaderFolderPicker.displayName = "ConversationHeaderFolderPicker" -// ============================================================================ -// ConversationFolderBranchPicker — folder + branch buttons rendered below the -// message input (mobile only; on desktop the folder picker lives in the -// conversation header and the branch selector in the bottom status bar). -// ============================================================================ - -interface ConversationFolderBranchPickerProps { - tabId?: string | null -} - -export const ConversationFolderBranchPicker = memo( - function ConversationFolderBranchPicker({ - tabId, - }: ConversationFolderBranchPickerProps) { - const t = useTranslations("Folder.conversationContextBar") - const tabs = useTabStore((s) => s.tabs) - const activeTabId = useTabStore((s) => s.activeTabId) - const { openNewConversationTab, openChatModeTab } = useTabActions() - const folders = useAppWorkspaceStore((s) => s.folders) - const allFolders = useAppWorkspaceStore((s) => s.allFolders) - const branches = useAppWorkspaceStore((s) => s.branches) - const switchToBranch = useSwitchToBranch() - - const ownTab = useMemo(() => { - const lookupId = tabId ?? activeTabId - return tabs.find((x) => x.id === lookupId) ?? null - }, [tabs, tabId, activeTabId]) - - const ownFolder = useMemo( - () => - ownTab - ? (allFolders.find((f) => f.id === ownTab.folderId) ?? null) - : null, - [ownTab, allFolders] - ) - - // The folder picker lists only top-level repos — worktree folders - // (`parent_id != null`) are reached through the branch picker, not here, so - // they're hidden to keep this picker a clean repo switcher. Hidden chat - // folders are excluded too (they're a per-conversation implementation - // detail, not a switchable repo). - const topLevelFolders = useMemo( - () => excludeChatFolders(filterTopLevelFolders(folders)), - [folders] - ) - - if (!ownTab) return null - // Chat mode: either a draft flagged `isChat` (no folder yet) or a bound - // conversation whose folder is a hidden chat folder. Show the folder - // chip (so the user can switch back to a real folder while drafting) but - // suppress the branch picker — a folderless chat has no git branch. - const isChatMode = ownTab.isChat === true || ownFolder?.kind === "chat" - if (!ownFolder && !isChatMode) return null - - const isNewConversation = ownTab.conversationId == null - const currentBranch = - isChatMode || !ownFolder - ? null - : (branches.get(ownFolder.id) ?? ownFolder.git_branch ?? null) - const showBranchPicker = currentBranch != null - // Worktree folders surface their parent (root repo) name here; the picker's - // own list below keeps real folder names/paths for selection, and every - // git/path operation still uses `ownFolder` (the worktree) unchanged. - const displayFolderName = isChatMode - ? t("chatModeLabel") - : resolveFolderDisplayName(ownFolder!, allFolders) - // When the conversation lives in a worktree, the picker highlights its - // parent repo (the worktree itself isn't listed). Display-only — the tab's - // real folder/working dir is untouched. Chat mode has no real folder, so - // `-1` (no row) is highlighted. - const pickerSelectedId = - isChatMode || !ownFolder ? -1 : resolvePickerSelectedFolderId(ownFolder) - - return ( - <> - { - const target = folders.find((f) => f.id === folderId) - if (!target) return - try { - // Route through openNewConversationTab so the target folder's - // saved default agent is applied. The function's existing- - // draft branch reuses ownTab via the singleton invariant and - // runs the disconnect-then-patch dance for folder+agent - // changes. `inheritFromActive: true` preserves the user's - // current agent when the target folder has no pinned default - // — "I'm switching folders, keep my workflow". - openNewConversationTab(target.id, target.path, { - inheritFromActive: true, - }) - toast.success(t("toasts.folderChanged", { name: target.name })) - } catch (err) { - console.error( - "[ConversationFolderBranchPicker] switch folder failed:", - err - ) - toast.error(t("toasts.openFolderFailed")) - } - }} - labelEmpty={t("noFolders")} - labelSearch={t("searchFolder")} - labelChatMode={t("chatModeLabel")} - isChatMode={isChatMode} - onSelectChatMode={() => { - try { - openChatModeTab() - toast.success(t("toasts.switchedToChatMode")) - } catch (err) { - console.error( - "[ConversationFolderBranchPicker] switch to chat mode failed:", - err - ) - toast.error(t("toasts.openFolderFailed")) - } - }} - /> - - {showBranchPicker && ownFolder && ( - { - // Both draft and existing conversations route through the shared - // switch logic (mirroring the top-bar branch dropdown). It never - // mutates the live conversation: a branch checked out in another - // worktree navigates to that folder (opening a fresh draft there - // via the singleton), and a free branch checks out in place only - // when the active folder is already the root tree. A bare - // in-place `git checkout` would instead fail (branch already - // checked out elsewhere) or hijack a worktree onto another branch. - await switchToBranch({ - activeFolder: ownFolder, - branchName, - currentBranch, - isRemote, - }) - }} - /> - )} - - ) - } -) - -ConversationFolderBranchPicker.displayName = "ConversationFolderBranchPicker" - -/** - * Mirror the visibility check inside `ConversationFolderBranchPicker` so the - * parent can decide whether to render its wrapper row at all. The picker - * itself returns `null` when no tab/folder is resolved (e.g. while folders - * are still loading on first paint), and the parent must avoid rendering an - * otherwise-empty wrapper in that interval. - */ -export function useConversationFolderBranchPickerVisible( - tabId?: string | null -): boolean { - const isMobile = useIsMobile() - const tabs = useTabStore((s) => s.tabs) - const activeTabId = useTabStore((s) => s.activeTabId) - const allFolders = useAppWorkspaceStore((s) => s.allFolders) - const lookupId = tabId ?? activeTabId - const ownTab = tabs.find((x) => x.id === lookupId) ?? null - const ownFolder = ownTab - ? (allFolders.find((f) => f.id === ownTab.folderId) ?? null) - : null - // The below-composer folder+branch row is mobile-only: on desktop the folder - // picker moved into the conversation header and the branch selector into the - // bottom status bar. Chat-mode drafts have no resolvable folder yet, but the - // row must still show so the folder chip (and the chat-mode item) stay - // reachable. - return isMobile && Boolean(ownTab && (ownFolder || ownTab.isChat)) -} - // ============================================================================ // FolderPicker // ============================================================================ @@ -597,284 +389,3 @@ const FolderPicker = memo(function FolderPicker({ ) }) - -// ============================================================================ -// BranchPicker -// ============================================================================ - -interface BranchPickerProps { - folderId: number - folderPath: string - currentBranch: string | null - title: string - onCheckout: (branchName: string, isRemote: boolean) => Promise -} - -const BranchPicker = memo(function BranchPicker({ - folderId, - folderPath, - currentBranch, - title, - onCheckout, -}: BranchPickerProps) { - const t = useTranslations("Folder.conversationContextBar") - const tBd = useTranslations("Folder.branchDropdown") - const [open, setOpen] = useState(false) - const [branchList, setBranchList] = useState(null) - const [loading, setLoading] = useState(false) - - const loadBranches = useCallback(async () => { - setLoading(true) - try { - const list = await gitListAllBranches(folderPath) - setBranchList(list) - } catch (err) { - console.error("[BranchPicker] list failed:", err) - setBranchList({ local: [], remote: [], worktree_branches: [] }) - } finally { - setLoading(false) - } - }, [folderPath]) - - useEffect(() => { - if (open) void loadBranches() - }, [open, loadBranches]) - - // Tree mode (browse) when the search box is empty; flat list (cmdk filters) - // when the user types — cmdk unmounts filtered items, so collapsed branches - // would otherwise be unsearchable. The query is controlled, so it must be - // cleared explicitly (an uncontrolled input used to reset when the popover - // content unmounted). - const [query, setQuery] = useState("") - - // Clear the search on EVERY close, regardless of path. `onOpenChange` doesn't - // fire when a leaf's `onSelect` closes the popover via `setOpen(false)` - // directly, so reset off the `open` transition (render-time, not an effect) - // — covers select, Escape, outside-click, and trigger-toggle alike. - const [prevOpen, setPrevOpen] = useState(open) - if (open !== prevOpen) { - setPrevOpen(open) - if (!open) setQuery("") - } - - // Reset branches cache + search when folder changes. - useEffect(() => { - setBranchList(null) - setQuery("") - }, [folderId]) - const isSearching = query.trim().length > 0 - - const localNodes = useMemo( - () => buildBranchTree(localBranchItems(branchList?.local ?? []), "local"), - [branchList] - ) - const remoteSections = useMemo( - () => buildRemoteBranchSections(branchList?.remote ?? []), - [branchList] - ) - - // Auto-expand the prefix groups leading to the current (local) branch. - const seedKeys = useMemo( - () => - currentBranch ? expandedKeysForBranch(localNodes, currentBranch) : [], - [currentBranch, localNodes] - ) - const { isExpanded, toggle } = useBranchTreeExpansion(open, seedKeys) - - const indentStyle = (depth: number) => ({ - paddingLeft: `${0.5 + depth * 0.75}rem`, - }) - - // Recursively render the prefix tree as cmdk items. Group headers toggle - // expansion (and never close the popover); leaves check out. - const renderTreeItems = ( - nodes: BranchTreeNode[], - depth: number, - isRemote: boolean - ): React.ReactNode[] => - nodes.flatMap((node) => { - if (node.type === "group") { - const groupOpen = isExpanded(node.key) - const header = ( - toggle(node.key)} - style={indentStyle(depth)} - > - - {node.label} - - {node.count} - - - ) - return groupOpen - ? [header, ...renderTreeItems(node.children, depth + 1, isRemote)] - : [header] - } - const localName = isRemote - ? node.fullName.replace(/^[^/]+\//, "") - : node.fullName - const selected = localName === currentBranch - return [ - { - setOpen(false) - if (localName !== currentBranch) - void onCheckout(localName, isRemote) - }} - style={indentStyle(depth)} - > - - - {node.label} - - {selected && } - , - ] - }) - - return ( - - - - - - - - - {loading ? ( -
- -
- ) : ( - <> - {t("noBranches")} - {branchList && branchList.local.length > 0 && ( - - {isSearching - ? branchList.local.map((b) => ( - { - setOpen(false) - if (b !== currentBranch) void onCheckout(b, false) - }} - > - - {b} - {b === currentBranch && ( - - )} - - )) - : renderTreeItems(localNodes, 0, false)} - - )} - {branchList && branchList.remote.length > 0 && ( - - {isSearching - ? branchList.remote.map((b) => { - const localName = b.replace(/^[^/]+\//, "") - return ( - { - setOpen(false) - if (localName !== currentBranch) - void onCheckout(localName, true) - }} - > - - - {b} - - {localName === currentBranch && ( - - )} - - ) - }) - : remoteSections.map((section) => - section.remoteName == null ? ( - - {renderTreeItems(section.nodes, 0, true)} - - ) : ( - - toggle(section.key)} - style={indentStyle(0)} - > - - - {section.remoteName} - - - {section.count} - - - {isExpanded(section.key) && - renderTreeItems(section.nodes, 1, true)} - - ) - )} - - )} - - )} -
-
-
-
- ) -}) diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index 186262549..ad00e5d50 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -72,8 +72,6 @@ vi.mock("@/components/chat/conversation-context-bar", () => ({ }: { extraContent?: React.ReactNode }) =>
{extraContent}
, - ConversationFolderBranchPicker: () => null, - useConversationFolderBranchPickerVisible: () => false, })) vi.mock("@/lib/platform", () => ({ isDesktop: () => false, diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 6d73dc795..1668f5b87 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -112,11 +112,7 @@ import { type AttachFileToSessionDetail, type AppendTextToSessionDetail, } from "@/lib/session-attachment-events" -import { - ConversationContextBar, - ConversationFolderBranchPicker, - useConversationFolderBranchPickerVisible, -} from "@/components/chat/conversation-context-bar" +import { ConversationContextBar } from "@/components/chat/conversation-context-bar" import { InlineModeSelector } from "@/components/chat/mode-selector" import { InlineSessionConfigSelector } from "@/components/chat/session-config-selector" import { ModelOptionPicker } from "@/components/chat/model-option-picker" @@ -970,9 +966,6 @@ export function MessageInput({ const hasAnySelector = showConfigLoading || hasConfigOptions || showModeLoading || showModeSelector const hasInlineSelectors = hasConfigOptions || showModeSelector - const hasFolderBranchPicker = - useConversationFolderBranchPickerVisible(attachmentTabId) - const folderBranchPickerAttached = hasFolderBranchPicker const imageAttachments = useMemo( () => attachments.filter( @@ -2996,16 +2989,10 @@ export function MessageInput({
)} -
+ {/* Layout-neutral group (`display:contents`): it once clipped the attached + mobile folder/branch row, which is retired, so it just wraps the + composer's context menu without affecting layout. */} +
{/* Disabled in non-secure web (no async clipboard read) so the native context menu — whose Paste still works over the editor text — is @@ -3026,18 +3013,13 @@ export function MessageInput({ // ws-transparent-bg`: opaque surface normally, but with a // workspace-bg image the composer goes transparent to reveal the // real image like the rest of the canvas (no frosted treatment) — - // the border stays. Always on now: the folder/branch row no - // longer wraps the composer on desktop, so the surface has to - // live on the composer itself rather than the (removed) wrapper. + // the border stays. The surface lives on the composer itself — + // the old below-composer folder/branch row that used to wrap it + // is gone. "codeg-composer-chrome @container relative flex flex-col rounded-xl border border-foreground/20 bg-background ws-transparent-bg transition-colors", - // Standard focus ring — always shown when the composer is - // focused (the plain default input style). When the mobile - // folder/branch row is attached below, the composer is clipped by - // an `overflow-hidden` wrapper, so the ring must be inset to stay - // visible; standalone (desktop) it uses the normal outset ring. - folderBranchPickerAttached - ? "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-inset focus-within:ring-ring/50" - : "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50", + // Standard focus ring — always shown when the composer is focused + // (the plain default input style). + "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50", // Active session, tiled across multiple sessions: a gradient // flows around the border to mark which tile is active — but ONLY // while the composer itself is not focused. Focusing it hides the @@ -3045,9 +3027,7 @@ export function MessageInput({ // A lone/non-tiled session (showActiveFlow=false) and inactive // tiles show the plain default border. showActiveFlow && "codeg-composer-flow", - !folderBranchPickerAttached && - showDragActive && - "ring-1 ring-primary/40", + showDragActive && "ring-1 ring-primary/40", className )} > @@ -3590,21 +3570,6 @@ export function MessageInput({ - {hasFolderBranchPicker && ( - // `pl-2` mirrors the action bar's `px-2` so this row lines up with the - // composer above. Kept on the rem scale (no px literals) so it tracks - // UI zoom; the folder icon then aligns with the centered "+" icon - // because both buttons add the same 1px transparent border (paired - // with the picker buttons' `px-1.5`). -
- -
- )}
{ expect(welcomeHeroSource).not.toContain("bg-gradient-to-r") }) - it("uses the shared attached folder branch picker treatment for all chat inputs", () => { + it("has retired the below-composer folder/branch picker on every platform", () => { + // The mobile folder+branch row is gone: the folder picker lives in the + // conversation header and the branch selector in the bottom status bar, so + // the composer no longer renders (or is wrapped by) that row anywhere. expect(source).not.toContain("attachFolderBranchPickerToInput") expect(conversationShellSource).not.toContain( "attachFolderBranchPickerToInput" ) expect(messageInputSource).not.toContain("attachFolderBranchPickerToInput") - expect(messageInputSource).toContain( - "const folderBranchPickerAttached = hasFolderBranchPicker" + expect(messageInputSource).not.toContain("hasFolderBranchPicker") + expect(messageInputSource).not.toContain("folderBranchPickerAttached") + expect(messageInputSource).not.toContain("ConversationFolderBranchPicker") + expect(messageInputSource).not.toContain( + "useConversationFolderBranchPickerVisible" ) expect(messageInputSource).not.toContain("rounded-b-none") - const pickerStart = messageInputSource.indexOf( - "{hasFolderBranchPicker && (" - ) - const pickerEnd = messageInputSource.indexOf( - " { @@ -120,7 +94,9 @@ describe("ConversationDetailPanel new conversation layout", () => { // with the sibling cards in conversation-shell AND gets extra bottom room // (pb-4) since it docks at the very bottom; only the welcome input drops the // gutter via `flush` (the welcome column already provides px-4) and stays pb-1. - expect(chatInputSource).toContain('cn("pt-0", flush ? "pb-1" : "px-4 pb-4")') + expect(chatInputSource).toContain( + 'cn("pt-0", flush ? "pb-1" : "px-4 pb-4")' + ) expect(chatInputSource).toContain( 'cn(tall ? "min-h-30" : "min-h-24", "max-h-60")' ) diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index d8f19d826..505973f8d 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -115,7 +115,6 @@ import { ExportTooLongError, } from "@/lib/export-conversation" import { useExportLabels } from "@/lib/use-export-labels" -import { useIsMobile } from "@/hooks/use-mobile" import { resolveActiveSessionDetails } from "./active-session-details" import { ConversationDetailHeader } from "./conversation-detail-header" import { SessionDetailsDialog } from "./session-details-dialog" @@ -1671,9 +1670,6 @@ export function ConversationDetailPanel() { const tabs = useTabStore((s) => s.tabs) const activeTabId = useTabStore((s) => s.activeTabId) const isTileMode = useTabStore((s) => s.isTileMode) - // The per-tile conversation header is desktop-only; the mobile shell keeps its - // own tab bar + menu, so the tile renders the view alone there. - const isMobile = useIsMobile() const { openNewConversationTab, closeTab, switchTab, onPreviewTabReplaced } = useTabActions() const newConversation = useMemo(() => { @@ -2029,9 +2025,10 @@ export function ConversationDetailPanel() { ) }) - // A single header (desktop only) sits fixed above the horizontally-scrolling - // tile row, so it never scrolls on the x-axis when conversations are tiled. - // It reflects the ACTIVE conversation (title + owning folder). + // A single header sits fixed above the horizontally-scrolling tile row, so it + // never scrolls on the x-axis when conversations are tiled. It reflects the + // ACTIVE conversation (title + owning folder). On mobile there's no tile row — + // it's simply the sole conversation's header. const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null const activeTabFolder = activeTab ? allFolders.find((f) => f.id === activeTab.folderId) @@ -2040,7 +2037,7 @@ export function ConversationDetailPanel() { return ( <>
- {!isMobile && activeTab && ( + {activeTab && ( (null) const isCoarsePointer = useIsCoarsePointer() - const isMobile = useIsMobile() - const [isHovered, setIsHovered] = useState(false) const [touchSortingTabId, setTouchSortingTabId] = useState( null ) - const handleWheel = useCallback((e: React.WheelEvent) => { - if (e.deltaY !== 0 && scrollRef.current) { - e.preventDefault() - scrollRef.current.scrollLeft += e.deltaY - } - }, []) - useEffect(() => { if (!activeFileTabId || !scrollRef.current) return const el = scrollRef.current.querySelector( @@ -77,40 +50,6 @@ export function FileWorkspaceTabBar({ el?.scrollIntoView({ block: "nearest", inline: "nearest" }) }, [activeFileTabId]) - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - // While maximized only the files pane is interactive, so route shortcuts - // here regardless of the user's last-clicked pane. - const shouldHandleShortcut = - mode === "fusion" && (activePane === "files" || filesMaximized) - if (!shouldHandleShortcut) return - if (matchShortcutEvent(event, shortcuts.close_all_file_tabs)) { - event.preventDefault() - closeAllFileTabs() - return - } - if (!matchShortcutEvent(event, shortcuts.close_current_tab)) return - - if (!activeFileTabId) return - event.preventDefault() - closeFileTab(activeFileTabId) - } - - window.addEventListener("keydown", onKeyDown) - return () => { - window.removeEventListener("keydown", onKeyDown) - } - }, [ - activeFileTabId, - closeAllFileTabs, - closeFileTab, - mode, - activePane, - filesMaximized, - shortcuts.close_all_file_tabs, - shortcuts.close_current_tab, - ]) - const handleReorder = useCallback( (nextTabs: FileWorkspaceTab[]) => { if (isCoarsePointer && !touchSortingTabId) return @@ -124,45 +63,14 @@ export function FileWorkspaceTabBar({ [] ) - const activeTab = fileTabs.find((tab) => tab.id === activeFileTabId) const activeFileIndex = fileTabs.findIndex( (tab) => tab.id === activeFileTabId ) - const canPreview = - activeTab?.kind === "file" && - (activeTab.language === "markdown" || isHtmlPreviewable(activeTab.path)) - const canOpenInBrowser = - activeTab?.kind === "file" && isHtmlPreviewable(activeTab.path) - const isPreviewActive = - canPreview && activeFileTabId - ? previewFileTabIds.has(activeFileTabId) - : false - // Embedded in the title bar: fill its height and let the bar own the bottom - // border. Standalone (mobile panel row): keep the h-10 row + border. - const rowHeight = embedded ? "h-full" : "h-10" - const rowBorder = embedded ? "" : "border-b border-border" - - if (fileTabs.length === 0) { - // In the title bar an empty file workspace shows nothing (only the - // conversation tabs remain); the standalone panel row keeps its label. - if (embedded) return null - return ( -
- {t("files")} -
- ) - } + if (fileTabs.length === 0) return null return ( -
+
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - className={cn( - "pt-1.5 px-1.5 min-w-0 flex items-stretch", - // Standalone row fills its container so the trailing action buttons - // sit flush right; embedded sizes to content so the wrapper's drag - // spacer claims the leftover row. - !embedded && "flex-1", - rowHeight, - rowBorder, - // Embedded: no scrollbar — tabs shrink browser-style and sit flush - // (`gap-0`) so their hairline separators read as dividers (see - // FileWorkspaceTabItem `embedded`); no bottom padding so they reach - // the strip's bottom and the active (white) tab merges into the file - // detail header below. Standalone: horizontal scroll with a hover - // scrollbar + the original inter-tab gap (mobile panel row). - embedded - ? "gap-0 overflow-hidden px-2" - : [ - "gap-1.5 overflow-x-scroll", - isHovered - ? [ - "pb-0.5", - "[&::-webkit-scrollbar]:h-1", - "[&::-webkit-scrollbar-track]:bg-transparent", - "[&::-webkit-scrollbar-thumb]:rounded-full", - "[&::-webkit-scrollbar-thumb]:bg-border", - ] - : ["pb-1.5", "[&::-webkit-scrollbar]:h-0"], - ] - )} + // Tabs shrink browser-style and sit flush (`gap-0`) so their hairline + // separators read as dividers (see FileWorkspaceTabItem); no scrollbar + // (`overflow-hidden` still scrolls programmatically) and no bottom padding + // so they reach the strip's bottom and the active (white) tab merges into + // the file detail header below. + className="pt-1.5 px-2 min-w-0 flex h-full items-stretch gap-0 overflow-hidden" > {fileTabs.map((tab, index) => ( ))} - {/* Title-bar trailing area (desktop title bar): a drag spacer fills the - leftover panel width (window-drag region) and, in fusion, a - maximize/restore button sits flush right (it used to live in the file - detail header). Wrapped in one `flex-1` box so the workspace-bg bottom - hairline (ws-strip-line) runs unbroken under both — the short - `self-center h-7` button can't carry the line itself. NO `min-w-0`: the - wrapper's min-content (the spacer's `min-w-10` + the shrink-0 button) is - its floor, so under many-tab overflow the group shrinks to reserve them - instead of the wrapper collapsing to 0 and clipping. Off (no bg image): - ws-strip-line is inert. */} - {embedded && ( -
- {/* Drag spacer, floored at `min-w-10` (40px) instead of `min-w-0`: even - when many tabs overflow and squeeze this region, a grabbable - window-drag gap always remains between the last tab and the - maximize button, so the tabs never butt against the button and the - packed title bar stays draggable. */} -
- {mode === "fusion" && ( - - )} -
- )} - {/* Trailing file-action buttons render only in the standalone (mobile - panel) row. In the desktop title bar (embedded) they live in the file - detail header instead (FileWorkspaceHeader). */} - {!embedded && canPreview && activeFileTabId && ( - - )} - {!embedded && canOpenInBrowser && activeTab?.path && ( - - )} - {!embedded && !isMobile && mode === "fusion" && ( - - )} + {/* Trailing area: a drag spacer fills the leftover panel width (window-drag + region) and, in fusion, a maximize/restore button sits flush right (it + used to live in the file detail header). Wrapped in one `flex-1` box so + the workspace-bg bottom hairline (ws-strip-line) runs unbroken under + both. NO `min-w-0`: the wrapper's min-content (the spacer's `min-w-10` + + the shrink-0 button) is its floor, so under many-tab overflow the group + shrinks to reserve them instead of the wrapper collapsing to 0. */} +
+ {/* Drag spacer, floored at `min-w-10` (40px): even when many tabs overflow + and squeeze this region, a grabbable window-drag gap always remains + between the last tab and the maximize button. */} +
+ {mode === "fusion" && ( + + )} +
) } diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index 8f7350bf6..6e7b50653 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -21,6 +21,7 @@ import { useActiveFolder } from "@/contexts/active-folder-context" import { useAuxPanelContext } from "@/contexts/aux-panel-context" import { useTabStore } from "@/contexts/tab-context" import { useTerminalContext } from "@/contexts/terminal-context" +import { useIsMobile } from "@/hooks/use-mobile" import { useWorkspaceActions, useWorkspaceFileTabs, @@ -1088,7 +1089,12 @@ function RenderNode({ export function FileTreeTab() { const t = useTranslations("Folder.fileTreeTab") const tCommon = useTranslations("Folder.common") - const { pendingRevealPath, consumePendingRevealPath } = useAuxPanelContext() + const { + pendingRevealPath, + consumePendingRevealPath, + setOpen: setAuxOpen, + } = useAuxPanelContext() + const isMobile = useIsMobile() // Defer the folder so a cross-folder conversation-tab switch commits first and // this tab's heavy tree rebuild (remount, applyLazyTreeOverrides, per-row // ContextMenus) runs in a non-blocking transition a frame later instead of @@ -1664,8 +1670,11 @@ export function FileTreeTab() { treeContainerRef.current?.focus({ preventScroll: true }) if (!filePathSet.has(path)) return void openFilePreview(path) + // On mobile the file tree lives in a Sheet overlay — close it so the + // opened file is visible in the main pane. + if (isMobile) setAuxOpen(false) }, - [filePathSet, openFilePreview] + [filePathSet, openFilePreview, isMobile, setAuxOpen] ) // ─── File-tree keyboard navigation (IDEA-style project tree) ─── diff --git a/src/components/layout/aux-panel-session-details-tab.test.tsx b/src/components/layout/aux-panel-session-details-tab.test.tsx index 0a7548323..70a540a13 100644 --- a/src/components/layout/aux-panel-session-details-tab.test.tsx +++ b/src/components/layout/aux-panel-session-details-tab.test.tsx @@ -5,8 +5,10 @@ import { beforeEach, describe, expect, it, vi, type Mock } from "vitest" import type { DbConversationSummary } from "@/lib/types" import enMessages from "@/i18n/messages/en.json" -// Heavy, context-hungry children relocated from the title bar — stub them so -// this test exercises only the tab's own layout/gating, not their internals. +// Regression guard: the branch selector + command launcher were REMOVED from +// this tab — they live in the bottom status bar now, on every platform. Stub +// them so that if either is ever re-added here, its test id surfaces and the +// "no actions bar" assertions below fail. vi.mock("./branch-dropdown", () => ({ BranchDropdown: () =>
, })) @@ -20,16 +22,6 @@ vi.mock("@/components/agent-icon", () => ({ AgentIcon: () => null })) vi.mock("@/lib/api", () => ({ getFolderConversation: vi.fn() })) vi.mock("@/contexts/aux-panel-context", () => ({ useAuxPanelContext: vi.fn() })) -vi.mock("@/contexts/active-folder-context", () => ({ - useActiveFolder: vi.fn(), -})) -vi.mock("@/hooks/use-is-active-chat-mode", () => ({ - useIsActiveChatMode: vi.fn(), -})) -// The tab now reads viewport size to size its header (desktop h-10 vs the -// mobile Sheet's original py-2); the real hook calls `window.matchMedia`, which -// jsdom lacks, so mock it. -vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: vi.fn() })) vi.mock("@/contexts/tab-context", () => ({ useTabStore: vi.fn() })) vi.mock("@/stores/conversation-runtime-store", () => ({ useConversationRuntimeStore: vi.fn(), @@ -40,17 +32,11 @@ vi.mock("@/stores/app-workspace-store", () => ({ import { SessionDetailsTab } from "./aux-panel-session-details-tab" import { useAuxPanelContext } from "@/contexts/aux-panel-context" -import { useActiveFolder } from "@/contexts/active-folder-context" -import { useIsActiveChatMode } from "@/hooks/use-is-active-chat-mode" -import { useIsMobile } from "@/hooks/use-mobile" import { useTabStore } from "@/contexts/tab-context" import { useConversationRuntimeStore } from "@/stores/conversation-runtime-store" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" const mockAux = useAuxPanelContext as unknown as Mock -const mockFolder = useActiveFolder as unknown as Mock -const mockChat = useIsActiveChatMode as unknown as Mock -const mockMobile = useIsMobile as unknown as Mock const mockTabs = useTabStore as unknown as Mock const mockRuntime = useConversationRuntimeStore as unknown as Mock const mockWorkspace = useAppWorkspaceStore as unknown as Mock @@ -89,16 +75,8 @@ function summary( } } -function setupScene(opts: { - activeFolderId: number | null - isChatMode: boolean - hasActiveConversation: boolean - isMobile?: boolean -}) { +function setupScene(opts: { hasActiveConversation: boolean }) { mockAux.mockReturnValue({ isOpen: true, activeTab: "session_details" }) - mockFolder.mockReturnValue({ activeFolderId: opts.activeFolderId }) - mockChat.mockReturnValue(opts.isChatMode) - mockMobile.mockReturnValue(opts.isMobile ?? false) const tabState: TabSlice = { tabs: opts.hasActiveConversation ? [{ id: 1, conversationId: 7 }] : [], @@ -126,15 +104,10 @@ describe("SessionDetailsTab", () => { vi.clearAllMocks() }) - it("renders the active session's details; desktop hides the folder actions bar", () => { - // Desktop: branch + command moved to the bottom status bar / conversation - // header, so the aux tab shows the details alone. - setupScene({ - activeFolderId: 1, - isChatMode: false, - hasActiveConversation: true, - isMobile: false, - }) + it("renders the active session's details with no folder-actions bar", () => { + // Branch + command moved to the bottom status bar on every platform, so the + // tab shows the details alone — no branch/command controls here. + setupScene({ hasActiveConversation: true }) const { getByText, queryByTestId } = renderTab() expect(getByText("My session")).toBeTruthy() expect(getByText("Claude Code")).toBeTruthy() @@ -142,63 +115,11 @@ describe("SessionDetailsTab", () => { expect(queryByTestId("command-dropdown")).toBeNull() }) - it("shows the folder actions bar on mobile (the Sheet has no status bar/header)", () => { - setupScene({ - activeFolderId: 1, - isChatMode: false, - hasActiveConversation: true, - isMobile: true, - }) - const { getByText, getByTestId } = renderTab() - expect(getByText("My session")).toBeTruthy() - expect(getByTestId("branch-dropdown")).toBeTruthy() - expect(getByTestId("command-dropdown")).toBeTruthy() - }) - - it("hides the folder actions bar in chat mode but still shows details", () => { - setupScene({ - activeFolderId: 1, - isChatMode: true, - hasActiveConversation: true, - isMobile: true, - }) - const { getByText, queryByTestId } = renderTab() - expect(getByText("My session")).toBeTruthy() - expect(queryByTestId("branch-dropdown")).toBeNull() - expect(queryByTestId("command-dropdown")).toBeNull() - }) - it("shows the empty state when there is no active session", () => { - setupScene({ - activeFolderId: null, - isChatMode: false, - hasActiveConversation: false, - }) + setupScene({ hasActiveConversation: false }) const { getByText, queryByTestId } = renderTab() expect(getByText("No active session")).toBeTruthy() expect(queryByTestId("branch-dropdown")).toBeNull() - }) - - it("keeps the mobile actions bar padded (py-2) and desktop-free", () => { - // Mobile (Sheet): the bar shows with the original py-2 sizing. - setupScene({ - activeFolderId: 1, - isChatMode: false, - hasActiveConversation: true, - isMobile: true, - }) - const mobile = renderTab() - const mobileBar = mobile.getByTestId("branch-dropdown").parentElement - expect(mobileBar?.className).toContain("py-2") - mobile.unmount() - - // Desktop: no actions bar at all — branch/command live elsewhere. - setupScene({ - activeFolderId: 1, - isChatMode: false, - hasActiveConversation: true, - isMobile: false, - }) - expect(renderTab().queryByTestId("branch-dropdown")).toBeNull() + expect(queryByTestId("command-dropdown")).toBeNull() }) }) diff --git a/src/components/layout/aux-panel-session-details-tab.tsx b/src/components/layout/aux-panel-session-details-tab.tsx index 1208d2755..1335f014a 100644 --- a/src/components/layout/aux-panel-session-details-tab.tsx +++ b/src/components/layout/aux-panel-session-details-tab.tsx @@ -10,12 +10,7 @@ import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { resolveActiveSessionDetails } from "@/components/conversations/active-session-details" import { SessionDetailsContent } from "@/components/conversations/session-details-content" import { ScrollArea } from "@/components/ui/scroll-area" -import { useActiveFolder } from "@/contexts/active-folder-context" -import { useIsActiveChatMode } from "@/hooks/use-is-active-chat-mode" -import { useIsMobile } from "@/hooks/use-mobile" import { useAuxPanelContext } from "@/contexts/aux-panel-context" -import { BranchDropdown } from "./branch-dropdown" -import { CommandDropdown } from "./command-dropdown" // Stable empty-turns reference so the `useShallow` slice below stays // reference-equal when there's no active session — otherwise a fresh `[]` each @@ -25,9 +20,9 @@ const EMPTY_TURNS: MessageTurn[] = [] /** * The aux-panel "Session Details" tab. Shows the active conversation's metadata - * and token usage (via the shared `SessionDetailsContent`), with a folder-scoped - * actions bar hosting the branch selector + command launcher relocated here from - * the top title bar. + * and token usage (via the shared `SessionDetailsContent`). The branch selector + * + command launcher that used to sit atop this tab now live in the bottom + * status bar on both platforms, so the tab shows the details alone. * * Details are resolved from live runtime state exactly the way the conversation * detail panel does it (`resolveActiveSessionDetails`), so no network fetch is @@ -36,9 +31,6 @@ const EMPTY_TURNS: MessageTurn[] = [] export function SessionDetailsTab() { const t = useTranslations("Folder.sessionDetails") const { isOpen, activeTab } = useAuxPanelContext() - const { activeFolderId } = useActiveFolder() - const isChatMode = useIsActiveChatMode() - const isMobile = useIsMobile() const tabs = useTabStore((s) => s.tabs) const activeTabId = useTabStore((s) => s.activeTabId) @@ -83,22 +75,8 @@ export function SessionDetailsTab() { conversations ) - // Branch + command are folder-scoped and self-hide in chat mode / without a - // folder. This actions bar is now MOBILE-ONLY: on desktop the branch selector - // moved to the bottom status bar and the folder chip to the conversation - // header, so the desktop aux "session details" tab shows the details alone. - // The mobile Sheet has no status bar / header to host them, so it keeps this - // bar unchanged. - const showFolderActions = isMobile && activeFolderId != null && !isChatMode - return (
- {showFolderActions && ( -
- - -
- )} {summary ? (
diff --git a/src/components/layout/folder-title-bar.tsx b/src/components/layout/folder-title-bar.tsx index 850001eb5..27b5e0014 100644 --- a/src/components/layout/folder-title-bar.tsx +++ b/src/components/layout/folder-title-bar.tsx @@ -2,48 +2,57 @@ import { useCallback } from "react" import { - EllipsisVertical, Menu, PanelRight, Settings, + SquarePen, SquareTerminal, } from "lucide-react" import { useTranslations } from "next-intl" import { openSettingsWindow } from "@/lib/api" +import { isDesktop } from "@/lib/platform" import { useActiveFolder } from "@/contexts/active-folder-context" import { useIsActiveChatMode } from "@/hooks/use-is-active-chat-mode" +import { usePlatform } from "@/hooks/use-platform" import { Button } from "@/components/ui/button" import { useSidebarContext } from "@/contexts/sidebar-context" import { useAuxPanelContext } from "@/contexts/aux-panel-context" import { useTerminalContext } from "@/contexts/terminal-context" -import { AppTitleBar } from "./app-title-bar" -import { NewFolderDropdown } from "./new-folder-dropdown" -import { RemoteWorkspaceDropdown } from "./remote-workspace-dropdown" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" +import { useTabActions } from "@/contexts/tab-context" +import { useWorkbenchRoute } from "@/contexts/workbench-route-context" +import { MAC_TRAFFIC_LIGHT_INSET } from "@/lib/window-chrome" +import { cn } from "@/lib/utils" +import { WindowControls } from "./window-controls" /** - * Mobile-only workspace title bar. + * Mobile-only workspace title bar (`h-10`, matching the desktop column strip). * * On desktop the full-width title bar was removed: its buttons were relocated - * into per-column edge clusters (`LeftEdgeChrome` / `RightEdgeChrome`) so the - * four columns' divider lines run unbroken from the top, and its global - * shortcuts + search/directory dialogs moved to `WorkspaceChromeController`. - * This component is mounted only on the mobile path (`FolderLayoutShell`), where - * the sidebar / aux / terminal are `Sheet` overlays that still need a compact - * bar to summon them. + * into fixed corner overlays (`LeftEdgeChrome` / `RightEdgeChrome`) and its + * global shortcuts + dialogs moved to `WorkspaceChromeController`. This bar is + * mounted only on the mobile path (`FolderLayoutShell`), where the sidebar / aux + * / terminal are `Sheet` overlays that need a compact bar to summon them. + * + * It mirrors the desktop chrome directly (rather than via `AppTitleBar`): the + * left holds the sidebar toggle + a new-conversation shortcut; the right holds + * the same terminal / aux / settings cluster as `RightEdgeChrome` (active + * `bg-accent`, same disabled predicates). The empty middle is a full-height + * `data-tauri-drag-region` filler so the window drags by it — plus a macOS + * traffic-light inset and the Windows/Linux caption buttons (`WindowControls` + * self-nulls elsewhere), exactly like the desktop edges. */ export function FolderTitleBar() { const tTitleBar = useTranslations("Folder.folderTitleBar") - const { toggle } = useSidebarContext() - const { toggle: toggleAuxPanel } = useAuxPanelContext() - const { toggle: toggleTerminal } = useTerminalContext() + const tCard = useTranslations("Folder.conversationCard") + const { isOpen: sidebarOpen, toggle } = useSidebarContext() + const { isOpen: auxPanelOpen, toggle: toggleAuxPanel } = useAuxPanelContext() + const { isOpen: terminalOpen, toggle: toggleTerminal } = useTerminalContext() const { activeFolder } = useActiveFolder() const isChatMode = useIsActiveChatMode() + const { openNewConversationTab, openChatModeTab } = useTabActions() + const { openConversations } = useWorkbenchRoute() + const { isMac } = usePlatform() + const showMacInset = isMac && isDesktop() const handleOpenSettings = useCallback(() => { openSettingsWindow().catch((err) => { @@ -51,57 +60,92 @@ export function FolderTitleBar() { }) }, []) + // Mirror the sidebar's "New chat": return to the conversation workspace, then + // start a new conversation in the active folder — or folderless chat mode when + // there's none, so this entry point is never a dead end. + const handleNewConversation = useCallback(() => { + openConversations() + if (!activeFolder) { + openChatModeTab() + return + } + openNewConversationTab(activeFolder.id, activeFolder.path) + }, [activeFolder, openChatModeTab, openNewConversationTab, openConversations]) + return ( - - - - -
- } - right={ -
- {/* Search lives in the left sidebar's fixed actions region; the ⌘K - shortcut + SearchCommandDialog live in WorkspaceChromeController. */} - - - - - - {/* The aux panel hosts the Session Details tab, so it's reachable - in chat mode too. */} - - - {tTitleBar("toggleAuxPanel")} - - toggleTerminal()} - disabled={!activeFolder} - > - - {tTitleBar("toggleTerminal")} - - - - {tTitleBar("openSettings")} - - - -
- } - /> +
+ {/* macOS traffic-light inset — a window-drag region so the left cluster + clears the OS-drawn lights. */} + {showMacInset && ( +
+ )} + {/* Left cluster: sidebar toggle + new conversation. */} +
+ + +
+ {/* Empty middle is a full-height window-drag region. */} +
+ {/* Right cluster: terminal + aux + settings — the same controls the + desktop RightEdgeChrome shows, now as direct buttons (no ⋯ menu). */} +
+ + + +
+ {/* Windows/Linux caption buttons; self-nulls on macOS / web. */} + +
) } diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 3866705c2..2352dae0c 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -188,6 +188,10 @@ export function Sidebar() { }, [allExpanded]) const handleNewConversation = useCallback(() => { + // On mobile the sidebar is a Sheet overlay — close it so the new + // conversation is visible (mirrors tapping a conversation card, which the + // list wrapper already closes on). + if (isMobile) toggle() // Starting a conversation always returns to the conversation workspace (in // case a route like Automations was taking over the content region). openConversations() @@ -199,7 +203,14 @@ export function Sidebar() { return } openNewConversationTab(activeFolder.id, activeFolder.path) - }, [activeFolder, openChatModeTab, openNewConversationTab, openConversations]) + }, [ + activeFolder, + openChatModeTab, + openNewConversationTab, + openConversations, + isMobile, + toggle, + ]) if (!isOpen) return null diff --git a/src/components/layout/status-bar.tsx b/src/components/layout/status-bar.tsx index 998f18712..535d495b5 100644 --- a/src/components/layout/status-bar.tsx +++ b/src/components/layout/status-bar.tsx @@ -14,12 +14,18 @@ export function StatusBar() { const isMobile = useIsMobile() if (isMobile) { + // Mobile mirrors the desktop bar: the branch selector on the left, the + // command launcher + context-window circle + alerts on the right. `h-8` + // (matching desktop) gives the h-6 branch/command controls room. Branch and + // command self-hide in chat mode / without a repo. return ( -
- +
+
+ +
- - + +
diff --git a/src/components/layout/workspace-chrome-controller-source.test.ts b/src/components/layout/workspace-chrome-controller-source.test.ts new file mode 100644 index 000000000..79b53e664 --- /dev/null +++ b/src/components/layout/workspace-chrome-controller-source.test.ts @@ -0,0 +1,53 @@ +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +const controllerSource = readFileSync( + resolve( + process.cwd(), + "src/components/layout/workspace-chrome-controller.tsx" + ), + "utf8" +) + +const tabBarSource = readFileSync( + resolve(process.cwd(), "src/components/tabs/tab-bar.tsx"), + "utf8" +) + +const fileTabBarSource = readFileSync( + resolve(process.cwd(), "src/components/files/file-workspace-tab-bar.tsx"), + "utf8" +) + +describe("tab close/navigation shortcuts live in the always-mounted controller", () => { + // Regression guard. mod+w / mod+tab / mod+shift+tab / close-all-file-tabs + // used to be registered by a `window` keydown listener INSIDE the visible tab + // strips (TabBar, FileWorkspaceTabBar). The mobile workspace no longer mounts + // those strips (it shows the folder-title header instead), and the file strip + // is desktop-only anyway — so listeners living there would silently die below + // the 768px breakpoint. Worse, with the listener gone, mod+w falls through to + // the native OS default and closes the whole Tauri/browser window instead of + // the active tab. The shortcuts must therefore live in the headless, + // always-mounted WorkspaceChromeController (mounted on BOTH platforms). + it("registers the tab shortcuts in WorkspaceChromeController", () => { + expect(controllerSource).toMatch(/shortcuts\.next_tab/) + expect(controllerSource).toMatch(/shortcuts\.prev_tab/) + expect(controllerSource).toMatch(/shortcuts\.close_current_tab/) + expect(controllerSource).toMatch(/shortcuts\.close_all_file_tabs/) + // ...and actually drives the tab / file-tab actions. The e.preventDefault() + // calls next to these are what stop mod+w reaching the window-close default. + expect(controllerSource).toMatch(/switchTab\(/) + expect(controllerSource).toMatch(/closeTab\(/) + expect(controllerSource).toMatch(/closeFileTab\(/) + expect(controllerSource).toMatch(/closeAllFileTabs\(/) + }) + + it("removes the keydown shortcut listeners from both tab strips", () => { + // The strips are conditionally mounted (desktop only; the file strip only + // when a file tab is open), so they must not own any global shortcut. + expect(tabBarSource).not.toContain('addEventListener("keydown"') + expect(tabBarSource).not.toContain("matchShortcutEvent") + expect(fileTabBarSource).not.toContain('addEventListener("keydown"') + expect(fileTabBarSource).not.toContain("matchShortcutEvent") + }) +}) diff --git a/src/components/layout/workspace-chrome-controller.tsx b/src/components/layout/workspace-chrome-controller.tsx index 0cbc585b0..98664a7ab 100644 --- a/src/components/layout/workspace-chrome-controller.tsx +++ b/src/components/layout/workspace-chrome-controller.tsx @@ -10,7 +10,12 @@ import { getActiveRemoteConnectionId } from "@/lib/transport" import { useSidebarContext } from "@/contexts/sidebar-context" import { useAuxPanelContext } from "@/contexts/aux-panel-context" import { useTerminalContext } from "@/contexts/terminal-context" -import { useTabActions } from "@/contexts/tab-context" +import { useTabActions, useTabStore } from "@/contexts/tab-context" +import { + useWorkspaceActions, + useWorkspaceFileTabs, + useWorkspaceView, +} from "@/contexts/workspace-context" import { useWorkbenchRoute } from "@/contexts/workbench-route-context" import { useSearchDialog } from "@/contexts/search-dialog-context" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" @@ -33,7 +38,15 @@ export function WorkspaceChromeController() { const { toggle } = useSidebarContext() const { toggle: toggleAuxPanel } = useAuxPanelContext() const { toggle: toggleTerminal } = useTerminalContext() - const { openNewConversationTab } = useTabActions() + const { openNewConversationTab, switchTab, closeTab } = useTabActions() + const tabs = useTabStore((s) => s.tabs) + const activeTabId = useTabStore((s) => s.activeTabId) + // Tab-close/navigation shortcuts used to live in the visible tab strips. + // Mobile no longer mounts those strips, so this always-mounted controller now + // owns them too (see the keydown handler below). + const { mode, activePane, filesMaximized } = useWorkspaceView() + const { activeFileTabId } = useWorkspaceFileTabs() + const { closeFileTab, closeAllFileTabs } = useWorkspaceActions() const { openConversations } = useWorkbenchRoute() const { shortcuts } = useShortcutSettings() // Search open-state is shared (see search-dialog-context): the trigger lives @@ -111,6 +124,51 @@ export function WorkspaceChromeController() { if (matchShortcutEvent(e, shortcuts.open_settings)) { e.preventDefault() handleOpenSettings() + return + } + + // Tab navigation + close. These once lived in the visible tab strips, + // which mobile no longer mounts; owning them here keeps mod+w / mod+tab / + // mod+shift+tab working at every width — and, crucially, keeps + // preventDefault firing so mod+w never falls through to closing the OS + // window. Routing mirrors the old split: conversation pane vs files pane. + const conversationPaneActive = + mode === "conversation" || + (mode === "fusion" && activePane === "conversation" && !filesMaximized) + const filesPaneActive = + mode === "fusion" && (activePane === "files" || filesMaximized) + + const isNextTab = matchShortcutEvent(e, shortcuts.next_tab) + const isPrevTab = matchShortcutEvent(e, shortcuts.prev_tab) + if (isNextTab || isPrevTab) { + if (!conversationPaneActive) return + if (tabs.length < 2 || !activeTabId) return + const currentIndex = tabs.findIndex((tab) => tab.id === activeTabId) + if (currentIndex === -1) return + e.preventDefault() + const offset = isNextTab ? 1 : -1 + const nextIndex = (currentIndex + offset + tabs.length) % tabs.length + switchTab(tabs[nextIndex].id) + return + } + + if (matchShortcutEvent(e, shortcuts.close_all_file_tabs)) { + if (!filesPaneActive) return + e.preventDefault() + closeAllFileTabs() + return + } + + if (matchShortcutEvent(e, shortcuts.close_current_tab)) { + if (conversationPaneActive) { + if (!activeTabId) return + e.preventDefault() + closeTab(activeTabId) + } else if (filesPaneActive) { + if (!activeFileTabId) return + e.preventDefault() + closeFileTab(activeFileTabId) + } } } document.addEventListener("keydown", handleKeyDown) @@ -127,6 +185,16 @@ export function WorkspaceChromeController() { toggleAuxPanel, toggleTerminal, isChatMode, + tabs, + activeTabId, + switchTab, + closeTab, + mode, + activePane, + filesMaximized, + activeFileTabId, + closeFileTab, + closeAllFileTabs, ]) return ( diff --git a/src/components/tabs/tab-bar.tsx b/src/components/tabs/tab-bar.tsx index f159baadc..8d6e3fd64 100644 --- a/src/components/tabs/tab-bar.tsx +++ b/src/components/tabs/tab-bar.tsx @@ -8,15 +8,14 @@ import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { useActiveFolder } from "@/contexts/active-folder-context" import { useTabActions, useTabStore } from "@/contexts/tab-context" import type { TabItem as TabItemData } from "@/contexts/tab-context" -import { useWorkspaceView } from "@/contexts/workspace-context" import { useWorkbenchRoute } from "@/contexts/workbench-route-context" import { useIsCoarsePointer } from "@/hooks/use-is-coarse-pointer" -import { useShortcutSettings } from "@/hooks/use-shortcut-settings" -import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" import { TabItem } from "./tab-item" -import { cn } from "@/lib/utils" -export function TabBar({ embedded = false }: { embedded?: boolean } = {}) { +// Rendered only inside the desktop conversation-column title strip (embedded). +// The old standalone mobile variant is gone — mobile shows the conversation +// detail header instead and navigates tabs from the sidebar. +export function TabBar() { const t = useTranslations("Folder.conversationCard") const tabs = useTabStore((s) => s.tabs) const activeTabId = useTabStore((s) => s.activeTabId) @@ -36,7 +35,6 @@ export function TabBar({ embedded = false }: { embedded?: boolean } = {}) { const branches = useAppWorkspaceStore((s) => s.branches) const { activeFolder } = useActiveFolder() const { openConversations } = useWorkbenchRoute() - const { mode, activePane, filesMaximized } = useWorkspaceView() // New-conversation affordance at the end of the tab strip. Mirrors the // sidebar's "New chat": return to the conversation workspace, then open a @@ -57,71 +55,18 @@ export function TabBar({ embedded = false }: { embedded?: boolean } = {}) { return map }, [allFolders]) - const { shortcuts } = useShortcutSettings() const scrollRef = useRef(null) const isCoarsePointer = useIsCoarsePointer() - const [isHovered, setIsHovered] = useState(false) const [touchSortingTabId, setTouchSortingTabId] = useState( null ) - const handleWheel = useCallback((e: React.WheelEvent) => { - if (e.deltaY !== 0 && scrollRef.current) { - e.preventDefault() - scrollRef.current.scrollLeft += e.deltaY - } - }, []) - useEffect(() => { if (!activeTabId || !scrollRef.current) return const el = scrollRef.current.querySelector(`[data-tab-id="${activeTabId}"]`) el?.scrollIntoView({ block: "nearest", inline: "nearest" }) }, [activeTabId]) - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - const shouldHandleShortcut = - mode === "conversation" || - (mode === "fusion" && activePane === "conversation" && !filesMaximized) - if (!shouldHandleShortcut) return - const isNextTab = matchShortcutEvent(event, shortcuts.next_tab) - const isPrevTab = matchShortcutEvent(event, shortcuts.prev_tab) - if (isNextTab || isPrevTab) { - if (tabs.length < 2 || !activeTabId) return - const currentIndex = tabs.findIndex((tab) => tab.id === activeTabId) - if (currentIndex === -1) return - - event.preventDefault() - const offset = isNextTab ? 1 : -1 - const nextIndex = (currentIndex + offset + tabs.length) % tabs.length - switchTab(tabs[nextIndex].id) - return - } - - if (!matchShortcutEvent(event, shortcuts.close_current_tab)) return - if (!activeTabId) return - - event.preventDefault() - closeTab(activeTabId) - } - - window.addEventListener("keydown", onKeyDown) - return () => { - window.removeEventListener("keydown", onKeyDown) - } - }, [ - activePane, - activeTabId, - closeTab, - filesMaximized, - mode, - shortcuts.close_current_tab, - shortcuts.next_tab, - shortcuts.prev_tab, - switchTab, - tabs, - ]) - const handleReorder = useCallback( (nextTabs: TabItemData[]) => { if (isCoarsePointer && !touchSortingTabId) return @@ -144,7 +89,7 @@ export function TabBar({ embedded = false }: { embedded?: boolean } = {}) { // doesn't leave a stray line poking out from under it (globals.css). const lastTabActive = activeIndex >= 0 && activeIndex === tabs.length - 1 - const group = ( + return ( setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - className={cn( - // Horizontal padding is set per-branch below (not shared here), because - // the two modes need different gutters. - "pt-1.5 flex items-stretch", - // Embedded in the title bar: fill its height, no scrollbar — the tabs - // shrink browser-style to share the row (see TabItem `embedded`), sit - // flush (`gap-0`) so their hairline separators read as dividers, and the - // strip owns no bottom border. No bottom padding, so the tabs reach the - // strip's bottom edge and the active (white) one merges into the detail - // header below. It fills the row (`flex-1`) and hosts the trailing - // new-conversation button + drag spacer as its own last children, so the - // tabs, button, and spacer all size in ONE flex line: the tabs keep their - // equal `basis-48` width until the row fills, then shrink together, and the - // button always hugs the last tab. (A nested content-sized group instead - // let the engine resolve its `flex-basis:auto` to min-content — starving - // the tabs to their label width and detaching the button from them.) - // `pl-2` only (NOT `px-2`): the first tab keeps its 0.5rem left gutter for - // the first-child seam-patch, but there is NO right padding — so the trailing - // wrapper's `ws-strip-line` reaches the group's right edge and the bottom - // hairline stays continuous into the right reserve. (A right `px-2` left an - // 8px hole in the hairline there, since the wrapper — not a tab — is the - // group's last child and gets no last-child seam-patch.) - // Standalone (mobile panel row): keep the h-10 row + border + horizontal - // scroll with a hover scrollbar and the original inter-tab gap. - embedded - ? "h-full min-w-0 flex-1 gap-0 overflow-hidden pl-2" - : [ - "h-10 gap-1.5 px-1.5 border-b border-border overflow-x-scroll", - isHovered - ? [ - "pb-0.5", - "[&::-webkit-scrollbar]:h-1", - "[&::-webkit-scrollbar-track]:bg-transparent", - "[&::-webkit-scrollbar-thumb]:rounded-full", - "[&::-webkit-scrollbar-thumb]:bg-border", - ] - : ["pb-1.5", "[&::-webkit-scrollbar]:h-0"], - ] - )} + // Fills the title-bar strip and shrinks browser-style to share the row (see + // TabItem): flush (`gap-0`) so hairline separators read as dividers, no + // scrollbar (`overflow-hidden` still scrolls programmatically), and no + // bottom border so the active (white) tab merges into the detail header + // below. It hosts the trailing new-conversation button + drag spacer as its + // own last children so the tabs, button, and spacer size in ONE flex line: + // the tabs keep their equal `basis-48` width until the row fills, then + // shrink together, and the button always hugs the last tab. `pl-2` only + // (NOT `px-2`): the first tab keeps its left gutter for the first-child + // seam-patch, but there's NO right padding so the trailing wrapper's + // `ws-strip-line` reaches the group's right edge and the bottom hairline + // stays continuous into the right reserve. + className="pt-1.5 flex h-full min-w-0 flex-1 items-stretch gap-0 overflow-hidden pl-2" > {tabs.map((tab, index) => { const folderInfo = folderIndex.get(tab.folderId) @@ -216,7 +130,7 @@ export function TabBar({ embedded = false }: { embedded?: boolean } = {}) { tab={tab} isActive={tab.id === activeTabId} isTileMode={isTileMode} - embedded={embedded} + embedded adjacentActive={adjacentActive} folderName={folderInfo?.name ?? null} folderBranch={branches.get(tab.folderId) ?? null} @@ -233,57 +147,48 @@ export function TabBar({ embedded = false }: { embedded?: boolean } = {}) { /> ) })} - {/* Title-bar strip only: the new-conversation button + drag spacer are the - Reorder.Group's own trailing children, so they share the tabs' flex line - — the button hugs the last tab and the spacer fills the leftover row as a - window-drag region. They are not Reorder.Items, so dragging a tab only - ever permutes the tabs (verified: reordering is unaffected). Wrapped in - one `flex-1` `ws-strip-line` box so the workspace-bg bottom hairline runs - unbroken under both — the short `self-center h-7` button can't carry the - line itself. NO `min-w-0`: its min-content (the shrink-0 button + the - spacer's `min-w-10`) is its floor, so under many-tab overflow the tabs - shrink to reserve it instead of it collapsing to 0 and clipping the - button. Off (no bg image): ws-strip-line is inert. */} - {embedded && ( -
+ - {/* Drag spacer, floored at `min-w-10` (40px) instead of `min-w-0`: even - when many tabs overflow and squeeze this region, a grabbable - window-drag gap always remains to the RIGHT of the new-conversation - button, so the button never reaches the strip's right edge and the - packed title bar stays draggable. */} -
-
- )} + + + {/* Drag spacer, floored at `min-w-10` (40px) instead of `min-w-0`: even + when many tabs overflow and squeeze this region, a grabbable + window-drag gap always remains to the RIGHT of the new-conversation + button, so the button never reaches the strip's right edge and the + packed title bar stays draggable. */} +
+
) - - return group } From f0bc14090bc38bf0de124d90958a965b41aef129 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 21 Jul 2026 21:19:50 +0800 Subject: [PATCH 3/7] feat(workspace): sharpen the desktop chrome borders and hover controls - Boost the persistent chrome hairlines with a foreground-tinted border so the status bar, sidebar, mobile title bar and aux tabs, and the tab-strip line and active-tab arch stay legible over a workspace background image - Frost the trailing new-conversation button's hover with a backdrop blur and a foreground tint so it reads over the transparent tab strip - Round the bottom branch selector and command launcher hover surfaces to full pills and prefix the command name with a terminal icon - Draw a divider between the last conversation tab and the new-conversation button when that tab is not active --- src/app/globals.css | 78 ++++++++++++++++++---- src/components/layout/aux-panel.tsx | 2 +- src/components/layout/branch-dropdown.tsx | 8 +-- src/components/layout/command-dropdown.tsx | 11 +-- src/components/layout/folder-title-bar.tsx | 2 +- src/components/layout/sidebar.tsx | 15 +++-- src/components/layout/status-bar.tsx | 4 +- src/components/tabs/tab-bar.tsx | 25 ++++--- 8 files changed, 104 insertions(+), 41 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index e1598bf7a..26434a6fe 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1231,12 +1231,35 @@ 连续下边框无法在激活 tab 处「开口」(透明子元素盖不住祖先画的线),故平线逐段画在激活 tab 的兄弟区域(预留区/非激活 tab/按钮/拖拽填充),激活 tab 及其祖先不画。拱门轮廓(顶 + 两侧 + 反向圆角脚)与两端 group px-2 补缝都在下方 .browser-tab-item 区块。色 = - var(--border)(纯色,比 detail header 的 border/50 略深,让透出背景图时分隔线更清晰)。 */ + var(--ws-chrome-border)(下方定义的加强色,与状态栏/header 等所有 chrome 分隔线统一)。 */ + +/* ===== 结构性 chrome 分隔线加强色 --ws-chrome-border ===== + 状态栏顶边 / 会话·文件 header 底边 / 移动端标题栏·aux 标签条底边 / 标签条平线 + 激活 tab 拱门—— + 这些持久的 chrome 细分隔线原本用主题 --border(浅色主题=0.922 灰,在浅色磨砂面上几乎看不见;深色 + 主题=白/10%)。开启背景图后(默认遮罩 0.82 把画面veil向 --background),它们常被冲淡看不清(用户 + 反馈「边框很多时候不清楚」)。这里给它们一个 foreground 染色的加强 hairline:浅色主题转深、深色主题 + 转浅,自动在两侧都有对比;alpha 低只描出一条清晰细线。仅 bg-on 生效,关图零回归。单一变量=一处调深浅。 */ +[data-workspace-bg="on"] { + --ws-chrome-border: color-mix(in oklch, var(--foreground) 13%, transparent); +} +/* 老引擎无 color-mix 时回落到主题 --border(仍随主题,至少不崩)。 */ +@supports not (background: color-mix(in oklch, white, transparent)) { + [data-workspace-bg="on"] { + --ws-chrome-border: var(--border); + } +} +/* 组合式 utility(无 base 规则):挂在已带 border-* / border-border* 的 chrome 元素上,开图时用加强色 + 覆盖其 border-color。unlayered 压过 Tailwind 的 layered border-border(与 specificity 无关)。关图无此 + 规则 → 元素保留自带 border 颜色,零回归。这些分隔线无 hover 变色,故 unlayered 覆盖不会误盖交互态。 */ +[data-workspace-bg="on"] .ws-chrome-border { + border-color: var(--ws-chrome-border); +} /* 平线:挂到激活 tab 的兄弟/非祖先区域,逐段 border-bottom 连成一条线(各兄弟 h-full, - 底都落在 strip 底 y=40)。激活 tab 不挂 → 留缺口给拱门。 */ + 底都落在 strip 底 y=40)。激活 tab 不挂 → 留缺口给拱门。色随 --ws-chrome-border(同上加强色, + 与状态栏/header 分隔线统一)。 */ [data-workspace-bg="on"] .ws-strip-line { - border-bottom: 1px solid var(--border); + border-bottom: 1px solid var(--ws-chrome-border); } /* 旧 WebKitGTK 无 color-mix 时降级为不透明(背景图仅在透明内容区透出,不崩)。 */ @@ -2017,6 +2040,33 @@ opacity: 0; } +/* Trailing separator between the last tab and the new-conversation button (the + conversation strip only — see tab-bar.tsx `tab-strip-tail`). Inter-tab + separators sit on each tab's LEFT edge, so the last tab's RIGHT edge, where + the flush-pinned button wrapper begins, has none. Same hairline geometry as + `.browser-tab-item::before` (the wrapper shares the tabs' flex box, so the + `calc(50% - 3px)` nudge centers it on the strip midline identically) and the + same `var(--border)` tone, so it reads as just another inter-tab separator. + Suppressed when the last tab is active or hovered, mirroring how a tab drops + the separators flanking the active/hovered tab. */ +.tab-strip-tail::before { + content: ""; + position: absolute; + left: 0; + top: calc(50% - 3px); + height: 1rem; + width: 1px; + transform: translateY(-50%); + background: var(--border); + opacity: 1; + transition: opacity 150ms ease; + pointer-events: none; +} +.browser-tab-item[data-active="true"] + .tab-strip-tail::before, +.browser-tab-item:hover + .tab-strip-tail::before { + opacity: 0; +} + /* Embedded tab label — no ellipsis: fade the right edge so an overflowing title dissolves instead of hard-cutting with "…". Pairs with `overflow-hidden whitespace-nowrap` on the label (replaces `truncate`). @@ -2187,7 +2237,7 @@ 0.5rem 见方的盒(left/right:-0.5rem, y32→40, box-sizing:border-box),把**对角圆角 + 相邻 两条边框**做成一段干净四分之一凹弧:左脚=右下角圆角+下/右边框,右脚=左下角圆角+下/左边框。 弧顶(tab 边 y32)竖直切线接拱门 ::after 侧边、弧底(gutter 外沿 y40)水平切线接 ws-strip-line - 平线,两端相切→过渡自然。清掉 mask 与填充色,只留描边,色 var(--border) 与拱门同深。 */ + 平线,两端相切→过渡自然。清掉 mask 与填充色,只留描边,色 var(--ws-chrome-border) 与拱门同深。 */ [data-workspace-bg="on"] .browser-tab-item[data-active="true"] .browser-tab-seat::before, @@ -2201,15 +2251,15 @@ [data-workspace-bg="on"] .browser-tab-item[data-active="true"] .browser-tab-seat::before { - border-bottom: 1px solid var(--border); - border-right: 1px solid var(--border); + border-bottom: 1px solid var(--ws-chrome-border); + border-right: 1px solid var(--ws-chrome-border); border-bottom-right-radius: 0.5rem; } [data-workspace-bg="on"] .browser-tab-item[data-active="true"] .browser-tab-seat::after { - border-bottom: 1px solid var(--border); - border-left: 1px solid var(--border); + border-bottom: 1px solid var(--ws-chrome-border); + border-left: 1px solid var(--ws-chrome-border); border-bottom-left-radius: 0.5rem; } @@ -2229,9 +2279,9 @@ left: -1px; right: -1px; bottom: 0.5rem; - border-top: 1px solid var(--border); - border-left: 1px solid var(--border); - border-right: 1px solid var(--border); + border-top: 1px solid var(--ws-chrome-border); + border-left: 1px solid var(--ws-chrome-border); + border-right: 1px solid var(--ws-chrome-border); border-top-left-radius: 0.5rem; border-top-right-radius: 0.5rem; pointer-events: none; @@ -2254,7 +2304,7 @@ bottom: 0; height: 1px; width: 0.5rem; - background-color: var(--border); + background-color: var(--ws-chrome-border); pointer-events: none; } [data-workspace-bg="on"] @@ -2291,7 +2341,7 @@ position: absolute; bottom: 0; height: 1px; - background-color: var(--border); + background-color: var(--ws-chrome-border); pointer-events: none; } [data-workspace-bg="on"] @@ -2324,6 +2374,6 @@ left: 0.5rem; right: 0; height: 1px; - background-color: var(--border); + background-color: var(--ws-chrome-border); pointer-events: none; } diff --git a/src/components/layout/aux-panel.tsx b/src/components/layout/aux-panel.tsx index 8af9c9686..e45f5936b 100644 --- a/src/components/layout/aux-panel.tsx +++ b/src/components/layout/aux-panel.tsx @@ -288,7 +288,7 @@ export function AuxPanel() { // Mobile (Sheet): unchanged — full-width underline tabs + a divider. {renderTabTriggers(false)} {/* Trailing drag region lets the empty part of the tab row move diff --git a/src/components/layout/branch-dropdown.tsx b/src/components/layout/branch-dropdown.tsx index 1ae3b6a9c..0f8acb903 100644 --- a/src/components/layout/branch-dropdown.tsx +++ b/src/components/layout/branch-dropdown.tsx @@ -744,7 +744,7 @@ export function BranchDropdown({ diff --git a/src/components/layout/command-dropdown.tsx b/src/components/layout/command-dropdown.tsx index e68bb4c19..04cb18aad 100644 --- a/src/components/layout/command-dropdown.tsx +++ b/src/components/layout/command-dropdown.tsx @@ -1,7 +1,7 @@ "use client" import { useState, useEffect, useCallback, useMemo, useRef } from "react" -import { Play, Plus, Square } from "lucide-react" +import { Play, Plus, Square, Terminal } from "lucide-react" import { useTranslations } from "next-intl" import { Button } from "@/components/ui/button" import { @@ -248,7 +248,7 @@ export function CommandDropdown() { @@ -310,7 +311,7 @@ export function CommandDropdown() { : t("runCommandTitle", { command: activeCmd?.command ?? "" }) } className={cn( - "flex h-6 items-center rounded-r-md pr-2 pl-1.5 outline-none transition-colors", + "flex h-6 items-center rounded-r-full pr-2 pl-1.5 outline-none transition-colors", isActiveCommandRunning ? "text-destructive" : "text-muted-foreground hover:text-foreground" diff --git a/src/components/layout/folder-title-bar.tsx b/src/components/layout/folder-title-bar.tsx index 27b5e0014..3d87ce500 100644 --- a/src/components/layout/folder-title-bar.tsx +++ b/src/components/layout/folder-title-bar.tsx @@ -73,7 +73,7 @@ export function FolderTitleBar() { }, [activeFolder, openChatModeTab, openNewConversationTab, openConversations]) return ( -
+
{/* macOS traffic-light inset — a window-drag region so the left cluster clears the OS-drawn lights. */} {showMacInset && ( diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 2352dae0c..75a0560f3 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -220,14 +220,17 @@ export function Sidebar() { className={cn( "flex h-10 shrink-0 items-center gap-2 pr-2", // Desktop: the fixed left window-chrome overlay (reserved below) owns - // the top-left, so drop the header's own left padding. A subtle bottom - // divider (border-border/50) matches the conversation detail header's - // bottom border so the two align as one bar across the app's top edge. - // Mobile (Sheet): keep the original title padding + a full-strength - // divider — mobile is unchanged. + // the top-left, so drop the header's own left padding. Off-image the + // divider is border-border/50, matching the conversation / file detail + // headers. But the sidebar sits on a FROSTED surface (ws-surface-sidebar) + // while those headers sit on the transparent canvas: with a workspace + // background image on, a border-border/50 hairline washes out against the + // frosted shade, so it takes the boosted `ws-chrome-border` (like the + // frosted status bar) to stay legible. Mobile (Sheet): keep the original + // title padding + a full-strength divider — mobile is unchanged. isMobile ? "border-b border-border pl-4" - : "border-b border-border/50 pl-0" + : "border-b border-border/50 ws-chrome-border pl-0" )} > {isMobile ? ( diff --git a/src/components/layout/status-bar.tsx b/src/components/layout/status-bar.tsx index 535d495b5..916c31014 100644 --- a/src/components/layout/status-bar.tsx +++ b/src/components/layout/status-bar.tsx @@ -19,7 +19,7 @@ export function StatusBar() { // (matching desktop) gives the h-6 branch/command controls room. Branch and // command self-hide in chat mode / without a repo. return ( -
+
@@ -33,7 +33,7 @@ export function StatusBar() { } return ( -
+
{/* Branch selector (moved from the aux "session details" tab). Folder diff --git a/src/components/tabs/tab-bar.tsx b/src/components/tabs/tab-bar.tsx index 8d6e3fd64..311e032da 100644 --- a/src/components/tabs/tab-bar.tsx +++ b/src/components/tabs/tab-bar.tsx @@ -158,10 +158,17 @@ export function TabBar() { so under many-tab overflow the tabs shrink to reserve it instead of it collapsing to 0 and clipping the button. */}
+ )} + + ) +}) diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index 2e1095718..aa373af29 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -6,6 +6,7 @@ import { useConversationRuntimeStore, } from "@/stores/conversation-runtime-store" import { ContentPartsRenderer } from "./content-parts-renderer" +import { CollapsibleUserMessage } from "./collapsible-user-message" import { createMessageTurnAdapter, groupGoalRuns, @@ -519,7 +520,7 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
- +
) : ( diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 3e1de9843..d00a6cefc 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "كتابة التخزين المؤقت", "duration": "المدة", "completedAt": "وقت الإنجاز", - "jumpToPreviousUserMessage": "الانتقال إلى رسالة المستخدم" + "jumpToPreviousUserMessage": "الانتقال إلى رسالة المستخدم", + "showMore": "عرض المزيد", + "showLess": "طي" }, "liveTurnStats": { "thinking": "جارٍ التفكير...", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 95edb58dd..e926ed601 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "Cache-Schreiben", "duration": "Dauer", "completedAt": "Abgeschlossen um", - "jumpToPreviousUserMessage": "Zur Benutzernachricht springen" + "jumpToPreviousUserMessage": "Zur Benutzernachricht springen", + "showMore": "Mehr anzeigen", + "showLess": "Weniger anzeigen" }, "liveTurnStats": { "thinking": "Denkt nach...", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index eced8b101..359b54268 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "Cache write", "duration": "Duration", "completedAt": "Completed at", - "jumpToPreviousUserMessage": "Jump to user message" + "jumpToPreviousUserMessage": "Jump to user message", + "showMore": "Show more", + "showLess": "Show less" }, "liveTurnStats": { "thinking": "Thinking...", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index a87a2f0da..56eddbce5 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "Escritura de caché", "duration": "Duración", "completedAt": "Completado a las", - "jumpToPreviousUserMessage": "Ir al mensaje del usuario" + "jumpToPreviousUserMessage": "Ir al mensaje del usuario", + "showMore": "Mostrar más", + "showLess": "Mostrar menos" }, "liveTurnStats": { "thinking": "Pensando...", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 1c085b863..5e888e7d6 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "Écriture du cache", "duration": "Durée", "completedAt": "Terminé à", - "jumpToPreviousUserMessage": "Aller au message utilisateur" + "jumpToPreviousUserMessage": "Aller au message utilisateur", + "showMore": "Afficher plus", + "showLess": "Afficher moins" }, "liveTurnStats": { "thinking": "Réflexion...", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 222203c67..d4e820fb0 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "キャッシュ書込", "duration": "所要時間", "completedAt": "完了時刻", - "jumpToPreviousUserMessage": "前のユーザーメッセージへ" + "jumpToPreviousUserMessage": "前のユーザーメッセージへ", + "showMore": "もっと見る", + "showLess": "折りたたむ" }, "liveTurnStats": { "thinking": "考え中...", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 28705dda8..120eed32e 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "캐시 쓰기", "duration": "소요 시간", "completedAt": "완료 시각", - "jumpToPreviousUserMessage": "이전 사용자 메시지로 이동" + "jumpToPreviousUserMessage": "이전 사용자 메시지로 이동", + "showMore": "더보기", + "showLess": "접기" }, "liveTurnStats": { "thinking": "생각 중...", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 888d0950e..20380762b 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "Escrita de cache", "duration": "Duração", "completedAt": "Concluído às", - "jumpToPreviousUserMessage": "Ir para a mensagem do usuário" + "jumpToPreviousUserMessage": "Ir para a mensagem do usuário", + "showMore": "Mostrar mais", + "showLess": "Mostrar menos" }, "liveTurnStats": { "thinking": "Pensando...", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 858a59d86..341b37d93 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "缓存写入", "duration": "耗时", "completedAt": "完成时间", - "jumpToPreviousUserMessage": "跳转到上一条用户消息" + "jumpToPreviousUserMessage": "跳转到上一条用户消息", + "showMore": "展开", + "showLess": "收起" }, "liveTurnStats": { "thinking": "思考中...", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index ed563afeb..53bee8190 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "快取寫入", "duration": "耗時", "completedAt": "完成時間", - "jumpToPreviousUserMessage": "跳轉到上一條使用者訊息" + "jumpToPreviousUserMessage": "跳轉到上一條使用者訊息", + "showMore": "展開", + "showLess": "收合" }, "liveTurnStats": { "thinking": "思考中...",