authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-10-08 00:47:37+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 14:47:37-07:00
log7f3628e4153d5be4a241c4d54f73a6d7087014ce
tree17d83b919493113808f1bd676262451ec9b51edd
parent26285ab80513260d1ff29d2c3fbe18f21990cb62
signature Signed by PGP key B5690EEEBB952194

fix(web): simplify workspace card rows (#16823)


17 files changed, 376 insertions(+), 343 deletions(-)

apps/web/src/components/BranchToolbar.logic.test.ts-13
......@@ -13,7 +13,6 @@ import {
1313 resolveBranchToolbarPrBranch,
1414 resolveBranchToolbarValue,
1515 resolveLockedWorkspaceLabel,
16 resolveWorkspaceDisplayName,
1716 resolveLocalCheckoutBranchMismatch,
1817 resolvePreviousWorktreeLabel,
1918 resolvePreviousWorktreeSeed,
......@@ -568,18 +567,6 @@ describe("resolveLockedWorkspaceLabel", () => {
568567 });
569568});
570569
571describe("resolveWorkspaceDisplayName", () => {
572 it("returns the final folder for POSIX and Windows paths", () => {
573 expect(resolveWorkspaceDisplayName("/repo/.t3/worktrees/feature-a")).toBe("feature-a");
574 expect(resolveWorkspaceDisplayName("C:\\code\\project\\feature-b\\")).toBe("feature-b");
575 });
576
577 it("handles missing and root paths", () => {
578 expect(resolveWorkspaceDisplayName(null)).toBeNull();
579 expect(resolveWorkspaceDisplayName("/")).toBe("/");
580 });
581});
582
583570describe("deriveLocalBranchNameFromRemoteRef", () => {
584571 it("strips the remote prefix from a remote ref", () => {
585572 expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo");
apps/web/src/components/BranchToolbar.logic.ts-7
......@@ -118,13 +118,6 @@ export function resolveLockedWorkspaceLabel(
118118 return effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : "Local checkout";
119119}
120120
121export function resolveWorkspaceDisplayName(path: string | null): string | null {
122 if (!path) return null;
123 const normalizedPath = path.replace(/[\\/]+$/, "");
124 if (normalizedPath.length === 0) return path;
125 return normalizedPath.split(/[\\/]/).at(-1) ?? normalizedPath;
126}
127
128121export interface PreviousWorktreeSeed {
129122 branch: string | null;
130123 worktreePath: string;
apps/web/src/components/BranchToolbar.panel.test.tsx created+138
......@@ -0,0 +1,138 @@
1// @vitest-environment jsdom
2
3import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
4import { act } from "react";
5import { createRoot } from "react-dom/client";
6import { expect, it, vi } from "vite-plus/test";
7
8const state = vi.hoisted(() => ({
9 worktreePath: "/tmp/worktree" as string | null,
10 showContextMenu: vi.fn().mockResolvedValue("copy-path"),
11 writeTextToClipboard: vi.fn().mockResolvedValue(true),
12}));
13
14vi.mock("../localApi", () => ({
15 readLocalApi: () => ({ contextMenu: { show: state.showContextMenu } }),
16}));
17vi.mock("../hooks/useCopyToClipboard", () => ({
18 writeTextToClipboard: state.writeTextToClipboard,
19}));
20vi.mock("./BranchToolbarBranchSelector", () => ({ BranchToolbarBranchSelector: () => null }));
21vi.mock("../composerDraftStore", () => ({
22 useComposerDraftStore: (select: (store: unknown) => unknown) =>
23 select({ getDraftThreadByRef: () => null, setDraftThreadContext: vi.fn() }),
24}));
25vi.mock("../state/entities", () => ({
26 useThreadShell: () => ({
27 environmentId: "local",
28 projectId: "project",
29 worktreePath: state.worktreePath,
30 }),
31 useProject: () => ({ workspaceRoot: "/tmp/project" }),
32 useThreadShellsForProjectRefs: () => [],
33}));
34
35import { BranchToolbar } from "./BranchToolbar";
36
37it("keeps machine choices usable when the combined row's workspace is locked", async () => {
38 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
39 const container = document.createElement("div");
40 document.body.append(container);
41 const root = createRoot(container);
42 const onEnvironmentChange = vi.fn();
43 try {
44 await act(async () => {
45 root.render(
46 <BranchToolbar
47 layout="panel"
48 panelSection="workspace"
49 environmentId={EnvironmentId.make("local")}
50 threadId={ThreadId.make("thread")}
51 showGitControls
52 envMode="local"
53 envLocked={false}
54 startFromOrigin={false}
55 onStartFromOriginChange={vi.fn()}
56 onEnvModeChange={vi.fn()}
57 onEnvironmentChange={onEnvironmentChange}
58 availableEnvironments={["local", "remote"].map((id) => ({
59 environmentId: EnvironmentId.make(id),
60 projectId: ProjectId.make("project"),
61 label: id,
62 isPrimary: id === "local",
63 machine: "server",
64 }))}
65 />,
66 );
67 });
68 const trigger = container.querySelector("button")!;
69 expect(trigger.textContent).toBe("local");
70 expect(container.querySelectorAll("button")).toHaveLength(1);
71 await act(async () => {
72 trigger.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, ctrlKey: true }));
73 });
74 expect(document.querySelector('[role="menu"]')).toBeNull();
75 await act(async () => trigger.click());
76 const items = [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')];
77 expect(
78 items.find((item) => item.textContent === "New worktree")?.getAttribute("aria-disabled"),
79 ).toBe("true");
80 await act(async () => items.find((item) => item.textContent === "remote")!.click());
81 expect(onEnvironmentChange).toHaveBeenCalledWith("remote");
82 } finally {
83 await act(async () => root.unmount());
84 container.remove();
85 vi.unstubAllGlobals();
86 }
87});
88
89it.each([
90 ["local", null, "/tmp/project"],
91 ["worktree", null, null],
92 ["worktree", "/tmp/worktree", "/tmp/worktree"],
93] as const)(
94 "copies only an existing workspace path with mode %s and worktree %s",
95 async (envMode, worktreePath, copiedPath) => {
96 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
97 state.worktreePath = worktreePath;
98 state.showContextMenu.mockClear();
99 state.writeTextToClipboard.mockClear();
100 const container = document.createElement("div");
101 document.body.append(container);
102 const root = createRoot(container);
103 try {
104 await act(async () => {
105 root.render(
106 <BranchToolbar
107 layout="panel"
108 panelSection="workspace"
109 environmentId={EnvironmentId.make("local")}
110 threadId={ThreadId.make("thread")}
111 showGitControls
112 envMode={envMode}
113 envLocked={false}
114 startFromOrigin={false}
115 onStartFromOriginChange={vi.fn()}
116 onEnvModeChange={vi.fn()}
117 />,
118 );
119 });
120 const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
121 await act(async () => {
122 container.querySelector('[aria-label="Run context"]')!.dispatchEvent(event);
123 });
124 expect(event.defaultPrevented).toBe(copiedPath !== null);
125 if (copiedPath === null) {
126 expect(state.showContextMenu).not.toHaveBeenCalled();
127 expect(state.writeTextToClipboard).not.toHaveBeenCalled();
128 } else {
129 expect(state.writeTextToClipboard).toHaveBeenCalledWith(copiedPath, "workspace path");
130 }
131 } finally {
132 await act(async () => root.unmount());
133 container.remove();
134 state.worktreePath = "/tmp/worktree";
135 vi.unstubAllGlobals();
136 }
137 },
138);
apps/web/src/components/BranchToolbar.tsx+126-20
......@@ -1,3 +1,6 @@
1import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
2import { readLocalApi } from "../localApi";
3import { stackedThreadToast, toastManager } from "./ui/toast";
14import { ComposerContextLabel } from "./ComposerContextLabel";
25import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
36import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
......@@ -10,6 +13,7 @@ import {
1013} from "lucide-react";
1114import {
1215 type Ref,
16 type MouseEvent as ReactMouseEvent,
1317 memo,
1418 useImperativeHandle,
1519 useCallback,
......@@ -41,6 +45,11 @@ import {
4145import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector";
4246import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector";
4347import { PreviousWorktreeItemContent } from "./PreviousWorktreeItemContent";
48import { ThreadDetailsControl } from "./chat/ThreadDetailsControl";
49import {
50 THREAD_DETAILS_PANEL_ICON_CLASS,
51 THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS,
52} from "./chat/threadDetailsPanelStyles";
4453import { ComposerControl } from "./chat/ComposerControl";
4554import {
4655 Menu,
......@@ -93,7 +102,44 @@ interface BranchToolbarProps {
93102 contextStripVisible?: boolean;
94103}
95104
96interface MobileRunContextSelectorProps {
105function showWorkspaceContextMenu(event: ReactMouseEvent, workspacePath: string) {
106 const api = readLocalApi();
107 if (!api) return;
108 event.preventDefault();
109 event.stopPropagation();
110 void api.contextMenu
111 .show([{ id: "copy-path", label: "Copy full path", icon: "copy" }], {
112 x: event.clientX,
113 y: event.clientY,
114 })
115 .then((action) => {
116 if (action !== "copy-path") return;
117 void writeTextToClipboard(workspacePath, "workspace path").then(
118 (didCopy) => {
119 if (didCopy) {
120 toastManager.add({
121 type: "success",
122 title: "Path copied",
123 description: workspacePath,
124 });
125 }
126 },
127 (error: unknown) => {
128 toastManager.add(
129 stackedThreadToast({
130 type: "error",
131 title: "Failed to copy path",
132 description: error instanceof Error ? error.message : "An error occurred.",
133 }),
134 );
135 },
136 );
137 });
138}
139
140interface RunContextSelectorProps {
141 displayMode?: "toolbar" | "panel";
142 workspaceRoot?: string;
97143 forceNewWorktree: boolean;
98144 autoEnvironmentLabel?: string | undefined;
99145 onAutoEnvironment?: (() => void) | undefined;
......@@ -112,7 +158,9 @@ interface MobileRunContextSelectorProps {
112158 onUsePreviousWorktree: () => void;
113159}
114160
115const MobileRunContextSelector = memo(function MobileRunContextSelector({
161const RunContextSelector = memo(function RunContextSelector({
162 displayMode = "toolbar",
163 workspaceRoot,
116164 forceNewWorktree,
117165 autoEnvironmentLabel,
118166 onAutoEnvironment,
......@@ -129,7 +177,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
129177 previousWorktreeLabel,
130178 previousWorktreeBranch,
131179 onUsePreviousWorktree,
132}: MobileRunContextSelectorProps) {
180}: RunContextSelectorProps) {
133181 const composerFloatingLayerProps = useComposerMenuProps();
134182 const activeEnvironment = useMemo(
135183 () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null,
......@@ -148,13 +196,35 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
148196 : effectiveEnvMode === "worktree"
149197 ? resolveEnvModeLabel("worktree")
150198 : resolveCurrentWorkspaceLabel(activeWorktreePath);
151 const isLocked = envLocked || envModeLocked;
199 const isPanel = displayMode === "panel";
200 const isLocked = envLocked || (envModeLocked && (!isPanel || !showEnvironmentPicker));
201 const workspacePath =
202 forceNewWorktree || (effectiveEnvMode === "worktree" && !activeWorktreePath)
203 ? null
204 : (activeWorktreePath ?? workspaceRoot);
205 const handleContextMenu = (event: ReactMouseEvent) => {
206 if (isPanel && workspacePath) {
207 showWorkspaceContextMenu(event, workspacePath);
208 }
209 };
152210 const workspaceIcon = (
153211 <Tooltip>
154212 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>
155 <WorkspaceIcon className={cn("size-3 shrink-0", showEnvironmentIndicator && "mx-0!")} />
213 <WorkspaceIcon
214 className={
215 isPanel
216 ? THREAD_DETAILS_PANEL_ICON_CLASS
217 : cn("size-3 shrink-0", showEnvironmentIndicator && "mx-0!")
218 }
219 />
156220 </TooltipTrigger>
157 <TooltipPopup>{workspaceLabel}</TooltipPopup>
221 <TooltipPopup>
222 {isPanel
223 ? forceNewWorktree
224 ? "Each model starts in its own worktree."
225 : (workspacePath ?? workspaceLabel)
226 : workspaceLabel}
227 </TooltipPopup>
158228 </Tooltip>
159229 );
160230 const icon = showEnvironmentIndicator ? (
......@@ -164,11 +234,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
164234 <Tooltip>
165235 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>
166236 {autoEnvironmentLabel ? (
167 <ScaleIcon className="size-3 shrink-0 mx-0!" aria-hidden="true" />
237 <ScaleIcon
238 className={isPanel ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3 shrink-0 mx-0!"}
239 aria-hidden="true"
240 />
168241 ) : (
169242 <EnvironmentMachineIcon
170243 kind={activeEnvironment?.machine ?? "server"}
171 className="size-3 shrink-0 mx-0!"
244 className={isPanel ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3 shrink-0 mx-0!"}
172245 />
173246 )}
174247 </TooltipTrigger>
......@@ -182,7 +255,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
182255 const triggerContent = (
183256 <>
184257 {icon}
185 <ComposerContextLabel>
258 <ComposerContextLabel displayMode={displayMode}>
186259 {autoEnvironmentLabel ??
187260 (showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)}
188261 </ComposerContextLabel>
......@@ -192,7 +265,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
192265 if (isLocked) {
193266 return (
194267 <span
195 className="inline-flex h-7 min-w-0 max-w-[48%] flex-initial items-center justify-start gap-1 rounded-md border border-transparent px-1.75 font-normal text-muted-foreground/70 text-xs sm:h-6"
268 className={cn(
269 "inline-flex h-7 min-w-0 max-w-[48%] flex-initial items-center justify-start gap-1 rounded-md border border-transparent px-1.75 font-normal text-muted-foreground/70 text-xs sm:h-6",
270 isPanel && "max-w-full",
271 isPanel && THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS,
272 )}
273 onContextMenu={handleContextMenu}
274 aria-label={isPanel ? "Run context" : undefined}
196275 data-composer-context-control
197276 >
198277 {triggerContent}
......@@ -203,8 +282,17 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
203282 return (
204283 <Menu>
205284 <MenuTrigger
206 render={<ComposerControl size="xs" />}
207 className="min-w-0 max-w-[48%] flex-initial justify-start"
285 render={isPanel ? <ThreadDetailsControl part="select" /> : <ComposerControl size="xs" />}
286 className={isPanel ? undefined : "min-w-0 max-w-[48%] flex-initial justify-start"}
287 onContextMenu={handleContextMenu}
288 onMouseDownCapture={
289 isPanel
290 ? (event) => {
291 if (event.button !== 0 || event.ctrlKey) event.stopPropagation();
292 }
293 : undefined
294 }
295 aria-label={isPanel ? "Run context" : undefined}
208296 data-composer-context-control
209297 data-composer-shortcut={[
210298 showEnvironmentPicker && !envLocked ? "composer.host" : "",
......@@ -212,13 +300,19 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
212300 ].join(" ")}
213301 >
214302 {triggerContent}
215 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />
303 {!isPanel ? <ChevronDownIcon className="size-3 shrink-0 opacity-50" /> : null}
216304 </MenuTrigger>
217305 <MenuPopup
218306 align="start"
219 side="top"
220 className={previousWorktreeLabel ? "w-[min(21rem,calc(100vw-2rem))]" : undefined}
221 {...composerFloatingLayerProps}
307 side={isPanel ? "bottom" : "top"}
308 className={
309 isPanel
310 ? "w-(--anchor-width)"
311 : previousWorktreeLabel
312 ? "w-[min(21rem,calc(100vw-2rem))]"
313 : undefined
314 }
315 {...(!isPanel ? composerFloatingLayerProps : {})}
222316 >
223317 {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? (
224318 <>
......@@ -618,13 +712,25 @@ export const BranchToolbar = memo(function BranchToolbar({
618712 return (
619713 <div className="flex w-full flex-col" data-thread-panel-run-context>
620714 {panelSection !== "branch" ? (
621 <BranchToolbarEnvModeSelector
715 <RunContextSelector
622716 displayMode="panel"
623 envLocked={envModeLocked}
717 workspaceRoot={activeProject.workspaceRoot}
718 forceNewWorktree={forceNewWorktree}
719 autoEnvironmentLabel={autoEnvironmentLabel}
720 onAutoEnvironment={onAutoEnvironment}
721 envLocked={envLocked}
722 envModeLocked={envModeLocked}
723 environmentId={environmentId}
724 availableEnvironments={availableEnvironments}
725 showEnvironmentPicker={showEnvironmentPicker}
726 showEnvironmentIndicator={activeEnvironmentOption !== null}
727 onEnvironmentChange={onEnvironmentChange}
624728 effectiveEnvMode={effectiveEnvMode}
625729 activeWorktreePath={activeWorktreePath}
626 workspaceRoot={activeProject.workspaceRoot}
627730 onEnvModeChange={onEnvModeChange}
731 previousWorktreeLabel={previousWorktreeLabel}
732 previousWorktreeBranch={previousWorktreeSeed?.branch ?? null}
733 onUsePreviousWorktree={onUsePreviousWorktree}
628734 />
629735 ) : null}
630736 {panelSection !== "workspace" ? (
......@@ -662,7 +768,7 @@ export const BranchToolbar = memo(function BranchToolbar({
662768 >
663769 {showGitControls ? (
664770 <div className="contents @3xl/composer-surface:hidden">
665 <MobileRunContextSelector
771 <RunContextSelector
666772 forceNewWorktree={forceNewWorktree}
667773 autoEnvironmentLabel={autoEnvironmentLabel}
668774 onAutoEnvironment={onAutoEnvironment}
apps/web/src/components/BranchToolbarBranchSelector.tsx+3-10
......@@ -42,10 +42,7 @@ import { threadEnvironment } from "../state/threads";
4242import { useAtomCommand } from "../state/use-atom-command";
4343import { vcsEnvironment } from "../state/vcs";
4444import { cn } from "../lib/utils";
45import {
46 THREAD_DETAILS_PANEL_CHEVRON_CLASS,
47 THREAD_DETAILS_PANEL_ICON_CLASS,
48} from "./chat/threadDetailsPanelStyles";
45import { THREAD_DETAILS_PANEL_ICON_CLASS } from "./chat/threadDetailsPanelStyles";
4946import { ThreadDetailsPrRows } from "./chat/ThreadDetailsPrRows";
5047import { parsePullRequestReference } from "../pullRequestReference";
5148import { getSourceControlPresentation } from "../sourceControlPresentation";
......@@ -784,13 +781,9 @@ export function BranchToolbarBranchSelector({
784781 <ComposerContextLabel displayMode={displayMode}>
785782 <MiddleTruncate value={triggerLabel} className="w-full" />
786783 </ComposerContextLabel>
787 {displayMode === "panel" ? (
788 <span data-slot="select-icon">
789 <ChevronDownIcon className={THREAD_DETAILS_PANEL_CHEVRON_CLASS} />
790 </span>
791 ) : (
784 {displayMode !== "panel" ? (
792785 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />
793 )}
786 ) : null}
794787 </ComboboxTrigger>
795788 </span>
796789 {displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? (
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+23-114
......@@ -1,20 +1,12 @@
1import { ThreadDetailsSelectControl } from "./chat/ThreadDetailsControl";
1import { ComposerSelectControl } from "./chat/ComposerControl";
22import { ComposerContextLabel } from "./ComposerContextLabel";
33import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react";
44import { memo, useMemo, type MouseEvent as ReactMouseEvent } from "react";
5import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
6import { readLocalApi } from "../localApi";
7import { cn } from "../lib/utils";
8import {
9 THREAD_DETAILS_PANEL_ICON_CLASS,
10 THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS,
11} from "./chat/threadDetailsPanelStyles";
125
136import {
147 resolveCurrentWorkspaceLabel,
158 resolveEnvModeLabel,
169 resolveLockedWorkspaceLabel,
17 resolveWorkspaceDisplayName,
1810 type EnvMode,
1911} from "./BranchToolbar.logic";
2012import { useComposerMenuProps } from "./chat/composerEventScope";
......@@ -28,7 +20,6 @@ import {
2820 SelectValue,
2921} from "./ui/select";
3022import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
31import { stackedThreadToast, toastManager } from "./ui/toast";
3223
3324const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree";
3425
......@@ -37,9 +28,7 @@ interface BranchToolbarEnvModeSelectorProps {
3728 envLocked: boolean;
3829 effectiveEnvMode: EnvMode;
3930 activeWorktreePath: string | null;
40 workspaceRoot?: string | null;
4131 onEnvModeChange: (mode: EnvMode) => void;
42 displayMode?: "toolbar" | "panel";
4332 previousWorktreeLabel?: string | null;
4433 previousWorktreeBranch?: string | null;
4534 onUsePreviousWorktree?: () => void;
......@@ -50,72 +39,27 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
5039 envLocked,
5140 effectiveEnvMode,
5241 activeWorktreePath,
53 workspaceRoot = null,
5442 onEnvModeChange,
55 displayMode = "toolbar",
5643 previousWorktreeLabel,
5744 previousWorktreeBranch = null,
5845 onUsePreviousWorktree,
5946}: BranchToolbarEnvModeSelectorProps) {
60 const workspacePath = displayMode === "panel" ? (activeWorktreePath ?? workspaceRoot) : null;
61 const workspaceDisplayName = resolveWorkspaceDisplayName(workspacePath);
62 // The panel names the workspace kind only when it is not the project folder.
63 const workspaceKind = activeWorktreePath ? "Worktree" : null;
64 const lockedWorkspaceKind = forceNewWorktree ? "Worktree" : workspaceKind;
65 const selectWorkspaceKind =
66 effectiveEnvMode === "worktree" && !activeWorktreePath ? "Create" : workspaceKind;
6747 const composerFloatingLayerProps = useComposerMenuProps();
6848 const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree);
6949 const envModeItems = useMemo(
7050 () => [
7151 {
7252 value: "local",
73 label: workspaceDisplayName ?? resolveCurrentWorkspaceLabel(activeWorktreePath),
53 label: resolveCurrentWorkspaceLabel(activeWorktreePath),
7454 },
7555 { value: "worktree", label: resolveEnvModeLabel("worktree") },
7656 ...(showPreviousWorktree && previousWorktreeLabel
7757 ? [{ value: PREVIOUS_WORKTREE_SELECT_VALUE, label: previousWorktreeLabel }]
7858 : []),
7959 ],
80 [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree, workspaceDisplayName],
60 [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree],
8161 );
8262
83 const handleWorkspaceContextMenu = (event: ReactMouseEvent) => {
84 if (!workspacePath || forceNewWorktree) return;
85 const api = readLocalApi();
86 if (!api) return;
87 event.preventDefault();
88 event.stopPropagation();
89 void api.contextMenu
90 .show([{ id: "copy-path", label: "Copy full path", icon: "copy" }], {
91 x: event.clientX,
92 y: event.clientY,
93 })
94 .then((action) => {
95 if (action !== "copy-path") return;
96 void writeTextToClipboard(workspacePath, "workspace path").then(
97 (didCopy) => {
98 if (didCopy) {
99 toastManager.add({
100 type: "success",
101 title: "Path copied",
102 description: workspacePath,
103 });
104 }
105 },
106 (error: unknown) => {
107 toastManager.add(
108 stackedThreadToast({
109 type: "error",
110 title: "Failed to copy path",
111 description: error instanceof Error ? error.message : "An error occurred.",
112 }),
113 );
114 },
115 );
116 });
117 };
118
11963 const stopContextMenuMouseDown = (event: ReactMouseEvent) => {
12064 if (event.button !== 0 || event.ctrlKey) {
12165 event.stopPropagation();
......@@ -125,47 +69,31 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
12569 if (envLocked || forceNewWorktree) {
12670 const lockedRow = (
12771 <span
128 className={cn(
129 "inline-flex h-7 min-w-0 items-center gap-1 border border-transparent px-1.75 font-normal text-muted-foreground/70 text-xs sm:h-6",
130 displayMode === "panel" && THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS,
131 )}
72 className="inline-flex h-7 min-w-0 items-center gap-1 border border-transparent px-1.75 font-normal text-muted-foreground/70 text-xs sm:h-6"
13273 data-composer-context-control
133 onContextMenu={handleWorkspaceContextMenu}
13474 >
13575 {activeWorktreePath ? (
136 <FolderGitIcon
137 className={displayMode === "panel" ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3"}
138 />
76 <FolderGitIcon className="size-3" />
13977 ) : effectiveEnvMode === "worktree" ? (
140 <FolderGit2Icon
141 className={displayMode === "panel" ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3"}
142 />
78 <FolderGit2Icon className="size-3" />
14379 ) : (
144 <FolderIcon
145 className={displayMode === "panel" ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3"}
146 />
80 <FolderIcon className="size-3" />
14781 )}
148 <ComposerContextLabel displayMode={displayMode}>
82 <ComposerContextLabel>
14983 {forceNewWorktree
15084 ? resolveEnvModeLabel("worktree")
151 : (workspaceDisplayName ??
152 resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode))}
85 : resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode)}
15386 </ComposerContextLabel>
154 {displayMode === "panel" && lockedWorkspaceKind ? (
155 <span className="shrink-0 text-3xs font-normal text-muted-foreground/70">
156 {lockedWorkspaceKind}
157 </span>
158 ) : null}
15987 </span>
16088 );
16189
16290 return (
16391 <Tooltip>
16492 <TooltipTrigger render={lockedRow} />
165 <TooltipPopup side={displayMode === "panel" ? "left" : undefined}>
93 <TooltipPopup>
16694 {forceNewWorktree
16795 ? "Each model starts in its own worktree."
168 : (workspacePath ?? resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode))}
96 : resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode)}
16997 </TooltipPopup>
17098 </Tooltip>
17199 );
......@@ -187,56 +115,37 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
187115 <Tooltip>
188116 <TooltipTrigger
189117 render={
190 <ThreadDetailsSelectControl
191 panel={displayMode === "panel"}
118 <ComposerSelectControl
119 size="xs"
192120 className="min-w-0 shrink"
193121 aria-label="Workspace"
194122 data-composer-shortcut="composer.workspace"
195123 data-composer-context-control
196124 onMouseDownCapture={stopContextMenuMouseDown}
197 onContextMenu={handleWorkspaceContextMenu}
198125 />
199126 }
200127 >
201128 {effectiveEnvMode === "worktree" ? (
202 <FolderGit2Icon
203 className={displayMode === "panel" ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3"}
204 />
129 <FolderGit2Icon className="size-3" />
205130 ) : activeWorktreePath ? (
206 <FolderGitIcon
207 className={displayMode === "panel" ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3"}
208 />
131 <FolderGitIcon className="size-3" />
209132 ) : (
210 <FolderIcon
211 className={displayMode === "panel" ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3"}
212 />
133 <FolderIcon className="size-3" />
213134 )}
214 <ComposerContextLabel displayMode={displayMode}>
135 <ComposerContextLabel>
215136 <SelectValue />
216137 </ComposerContextLabel>
217 {displayMode === "panel" && selectWorkspaceKind ? (
218 <span className="shrink-0 text-3xs font-normal text-muted-foreground/70">
219 {selectWorkspaceKind}
220 </span>
221 ) : null}
222138 </TooltipTrigger>
223 <TooltipPopup side={displayMode === "panel" ? "left" : undefined}>
224 {workspacePath ??
225 (effectiveEnvMode === "worktree"
226 ? resolveEnvModeLabel("worktree")
227 : resolveCurrentWorkspaceLabel(activeWorktreePath))}
139 <TooltipPopup>
140 {effectiveEnvMode === "worktree"
141 ? resolveEnvModeLabel("worktree")
142 : resolveCurrentWorkspaceLabel(activeWorktreePath)}
228143 </TooltipPopup>
229144 </Tooltip>
230145 <SelectPopup
231146 alignItemWithTrigger={false}
232 {...(displayMode === "toolbar" ? composerFloatingLayerProps : {})}
233 className={
234 displayMode === "panel"
235 ? "w-(--anchor-width)"
236 : showPreviousWorktree
237 ? "w-[min(21rem,calc(100vw-2rem))]"
238 : undefined
239 }
147 {...composerFloatingLayerProps}
148 className={showPreviousWorktree ? "w-[min(21rem,calc(100vw-2rem))]" : undefined}
240149 >
241150 <SelectGroup>
242151 <SelectGroupLabel>Workspace</SelectGroupLabel>
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+10-37
......@@ -1,4 +1,4 @@
1import { ThreadDetailsSelectControl } from "./chat/ThreadDetailsControl";
1import { ComposerSelectControl } from "./chat/ComposerControl";
22import { ComposerContextLabel } from "./ComposerContextLabel";
33import { Tooltip, TooltipTrigger, TooltipPopup } from "./ui/tooltip";
44import type { EnvironmentId } from "@t3tools/contracts";
......@@ -6,11 +6,6 @@ import { ScaleIcon } from "lucide-react";
66import { memo, useMemo } from "react";
77
88import type { EnvironmentOption } from "./BranchToolbar.logic";
9import { cn } from "../lib/utils";
10import {
11 THREAD_DETAILS_PANEL_ICON_CLASS,
12 THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS,
13} from "./chat/threadDetailsPanelStyles";
149import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
1510import { useComposerMenuProps } from "./chat/composerEventScope";
1611import {
......@@ -29,7 +24,6 @@ interface BranchToolbarEnvironmentSelectorProps {
2924 environmentId: EnvironmentId;
3025 availableEnvironments: readonly EnvironmentOption[];
3126 onEnvironmentChange?: (environmentId: EnvironmentId) => void;
32 displayMode?: "toolbar" | "panel";
3327}
3428
3529export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({
......@@ -39,7 +33,6 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
3933 environmentId,
4034 availableEnvironments,
4135 onEnvironmentChange,
42 displayMode = "toolbar",
4336}: BranchToolbarEnvironmentSelectorProps) {
4437 const composerFloatingLayerProps = useComposerMenuProps();
4538 const activeEnvironment = useMemo(() => {
......@@ -67,19 +60,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
6760 if (envLocked || onEnvironmentChange === undefined) {
6861 const lockedRow = (
6962 <span
70 className={cn(
71 "inline-flex h-7 min-w-0 max-w-full items-center gap-1 border border-transparent px-1.75 font-normal text-muted-foreground/70 text-xs sm:h-6",
72 displayMode === "panel" && THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS,
73 )}
63 className="inline-flex h-7 min-w-0 max-w-full items-center gap-1 border border-transparent px-1.75 font-normal text-muted-foreground/70 text-xs sm:h-6"
7464 data-composer-context-control
7565 >
7666 <EnvironmentMachineIcon
7767 kind={activeEnvironment?.machine ?? "server"}
78 className={displayMode === "panel" ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3 shrink-0"}
68 className="size-3 shrink-0"
7969 />
80 <ComposerContextLabel displayMode={displayMode}>
81 {activeEnvironment?.label ?? "Run on"}
82 </ComposerContextLabel>
70 <ComposerContextLabel>{activeEnvironment?.label ?? "Run on"}</ComposerContextLabel>
8371 </span>
8472 );
8573 return (
......@@ -102,8 +90,8 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
10290 <Tooltip>
10391 <TooltipTrigger
10492 render={
105 <ThreadDetailsSelectControl
106 panel={displayMode === "panel"}
93 <ComposerSelectControl
94 size="xs"
10795 className="min-w-0 max-w-full"
10896 aria-label="Run on"
10997 data-composer-shortcut="composer.host"
......@@ -112,35 +100,20 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
112100 }
113101 >
114102 {autoEnvironmentLabel ? (
115 <ScaleIcon
116 className={
117 displayMode === "panel" ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3 shrink-0"
118 }
119 aria-hidden="true"
120 />
103 <ScaleIcon className="size-3 shrink-0" aria-hidden="true" />
121104 ) : (
122105 <EnvironmentMachineIcon
123106 kind={activeEnvironment?.machine ?? "server"}
124 className={
125 displayMode === "panel" ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3 shrink-0"
126 }
107 className="size-3 shrink-0"
127108 />
128109 )}
129 <ComposerContextLabel displayMode={displayMode}>
110 <ComposerContextLabel>
130111 <SelectValue />
131112 </ComposerContextLabel>
132113 </TooltipTrigger>
133114 <TooltipPopup>{autoEnvironmentLabel ?? activeEnvironment?.label ?? "Run on"}</TooltipPopup>
134115 </Tooltip>
135 <SelectPopup
136 alignItemWithTrigger={false}
137 {...(displayMode === "toolbar" ? composerFloatingLayerProps : {})}
138 {...(displayMode === "panel"
139 ? {
140 className: "w-(--anchor-width)",
141 }
142 : {})}
143 >
116 <SelectPopup alignItemWithTrigger={false} {...composerFloatingLayerProps}>
144117 <SelectGroup>
145118 <SelectGroupLabel>Run on</SelectGroupLabel>
146119 {onAutoEnvironment && (
apps/web/src/components/ChatView.tsx-16
......@@ -3069,11 +3069,6 @@ export default function ChatView(props: ChatViewProps) {
30693069 hasMultipleRegisteredEnvironments && activeThread
30703070 ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server`
30713071 : "server";
3072 const handleDismissVersionMismatch = useCallback(() => {
3073 if (!versionMismatchDismissKey) return;
3074 dismissVersionMismatch(versionMismatchDismissKey);
3075 setDismissedVersionMismatchKey(versionMismatchDismissKey);
3076 }, [setDismissedVersionMismatchKey, versionMismatchDismissKey]);
30773072 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;
30783073 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
30793074 const versionMismatchInstallation = serverConfig?.environment.capabilities.serverInstallation;
......@@ -11078,15 +11073,6 @@ export default function ChatView(props: ChatViewProps) {
1107811073 : {}),
1107911074 onComposerFocusRequest: scheduleComposerFocus,
1108011075 ...(isServerThread && isGitRepo ? { onOpenChanges: openChangesFromThreadPanel } : {}),
11081 versionMismatch:
11082 showVersionMismatchBanner && versionMismatch
11083 ? {
11084 clientVersion: versionMismatch.clientVersion,
11085 serverVersion: versionMismatch.serverVersion,
11086 serverLabel: versionMismatchServerLabel,
11087 }
11088 : null,
11089 onDismissVersionMismatch: handleDismissVersionMismatch,
1109011076 onRunProjectScript: runProjectScript,
1109111077 onAddProjectScript: saveProjectScript,
1109211078 onUpdateProjectScript: updateProjectScript,
......@@ -11100,8 +11086,6 @@ export default function ChatView(props: ChatViewProps) {
1110011086 threadPanelPresentation,
1110111087 threadPanelPopoverHandle,
1110211088 threadPanelShortcutLabel: shortcutLabelForCommand(keybindings, "threadPanel.toggle"),
11103 threadPanelHasAttention:
11104 activeEnvironmentUnavailableState !== null || showVersionMismatchBanner,
1110511089 rightPanelAvailable: activeProject !== null,
1110611090 rightPanelOpen,
1110711091 rightPanelShortcutLabel: shortcutLabelForCommand(keybindings, "rightPanel.toggle"),
apps/web/src/components/chat/PanelLayoutControls.tsx-8
......@@ -19,7 +19,6 @@ export interface PanelLayoutControlsProps {
1919 threadPanelPresentation: ThreadPanelPresentation;
2020 threadPanelPopoverHandle?: ReturnType<typeof PopoverCreateHandle>;
2121 threadPanelShortcutLabel: string | null;
22 threadPanelHasAttention: boolean;
2322 rightPanelAvailable: boolean;
2423 rightPanelOpen: boolean;
2524 rightPanelShortcutLabel: string | null;
......@@ -40,7 +39,6 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
4039 threadPanelPresentation,
4140 threadPanelPopoverHandle,
4241 threadPanelShortcutLabel,
43 threadPanelHasAttention,
4442 rightPanelAvailable,
4543 rightPanelOpen,
4644 rightPanelShortcutLabel,
......@@ -58,12 +56,6 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
5856 size="sm"
5957 >
6058 <SquareMenuIcon className="size-4" />
61 {threadPanelHasAttention ? (
62 <span
63 className="absolute right-1 top-1 size-1.5 rounded-full bg-warning ring-2 ring-background"
64 aria-hidden="true"
65 />
66 ) : null}
6759 </Toggle>
6860 );
6961 const threadPanelTooltip = (trigger: ReactElement) => (
apps/web/src/components/chat/ThreadDetailsControl.test.tsx created+38
......@@ -0,0 +1,38 @@
1// @vitest-environment jsdom
2
3import { act } from "react";
4import { createRoot } from "react-dom/client";
5import { expect, it, vi } from "vite-plus/test";
6
7import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
8import { ThreadDetailsControl } from "./ThreadDetailsControl";
9
10it("keeps the row label and one chevron when used as a menu trigger", async () => {
11 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
12 const container = document.createElement("div");
13 document.body.append(container);
14 const root = createRoot(container);
15 try {
16 await act(async () => {
17 root.render(
18 <Menu>
19 <MenuTrigger render={<ThreadDetailsControl part="select" />}>
20 Run on this machine
21 </MenuTrigger>
22 <MenuPopup>
23 <MenuItem>New worktree</MenuItem>
24 </MenuPopup>
25 </Menu>,
26 );
27 });
28 const trigger = container.querySelector("button")!;
29 expect(trigger.textContent).toBe("Run on this machine");
30 expect(trigger.querySelectorAll(".lucide-chevron-down")).toHaveLength(1);
31 await act(async () => trigger.click());
32 expect(document.querySelector('[role="menuitem"]')?.textContent).toBe("New worktree");
33 } finally {
34 await act(async () => root.unmount());
35 container.remove();
36 vi.unstubAllGlobals();
37 }
38});
apps/web/src/components/chat/ThreadDetailsControl.tsx+17-32
......@@ -1,17 +1,14 @@
11import { mergeProps } from "@base-ui/react/merge-props";
2import { Select as SelectPrimitive } from "@base-ui/react/select";
32import { useRender } from "@base-ui/react/use-render";
43import type { ComponentProps } from "react";
54
65import { cn } from "~/lib/utils";
76import { Button } from "../ui/button";
8import { ComposerSelectControl } from "./ComposerControl";
97import {
108 THREAD_DETAILS_PANEL_ROW_CLASS,
119 THREAD_DETAILS_PANEL_SELECT_ROW_CLASS,
1210 THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS,
1311 THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS,
14 THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS,
1512 THREAD_DETAILS_PANEL_ICON_ACTION_CLASS,
1613 THREAD_DETAILS_PANEL_META_ACTION_CLASS,
1714 THREAD_DETAILS_PANEL_CHEVRON_CLASS,
......@@ -20,11 +17,9 @@ import { ChevronDownIcon } from "lucide-react";
2017
2118const parts = {
2219 row: THREAD_DETAILS_PANEL_ROW_CLASS,
23 "group-row": `${THREAD_DETAILS_PANEL_ROW_CLASS} hover:!bg-transparent dark:hover:!bg-transparent`,
2420 select: THREAD_DETAILS_PANEL_SELECT_ROW_CLASS,
2521 primary: THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS,
2622 secondary: THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS,
27 "link-primary": THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS,
2823 icon: THREAD_DETAILS_PANEL_ICON_ACTION_CLASS,
2924 meta: THREAD_DETAILS_PANEL_META_ACTION_CLASS,
3025};
......@@ -36,6 +31,7 @@ export function ThreadDetailsControl({
3631 multiline = false,
3732 tone = "default",
3833 className,
34 children,
3935 size = "default",
4036 variant = "default",
4137 render,
......@@ -64,6 +60,19 @@ export function ThreadDetailsControl({
6460 ),
6561 },
6662 props,
63 {
64 children:
65 part === "select" ? (
66 <>
67 {children}
68 <span data-slot="select-icon">
69 <ChevronDownIcon className={THREAD_DETAILS_PANEL_CHEVRON_CLASS} />
70 </span>
71 </>
72 ) : (
73 children
74 ),
75 },
6776 ),
6877 });
6978 if (!panel) {
......@@ -74,34 +83,10 @@ export function ThreadDetailsControl({
7483 size={multiline ? "sm-multiline" : size}
7584 variant={variant}
7685 className={className}
77 />
78 );
79 }
80 return control;
81}
82
83export function ThreadDetailsSelectControl({
84 panel,
85 children,
86 className,
87 ...props
88}: Omit<SelectPrimitive.Trigger.Props, "className"> & { panel: boolean; className?: string }) {
89 if (!panel) {
90 return (
91 <ComposerSelectControl {...props} size="xs" className={className}>
86 >
9287 {children}
93 </ComposerSelectControl>
88 </Button>
9489 );
9590 }
96 return (
97 <SelectPrimitive.Trigger
98 {...props}
99 render={<ThreadDetailsControl part="select" className={className} />}
100 >
101 {children}
102 <SelectPrimitive.Icon data-slot="select-icon">
103 <ChevronDownIcon className={THREAD_DETAILS_PANEL_CHEVRON_CLASS} />
104 </SelectPrimitive.Icon>
105 </SelectPrimitive.Trigger>
106 );
91 return control;
10792}
apps/web/src/components/chat/ThreadDetailsPanel.test.tsx-2
......@@ -74,8 +74,6 @@ describe("ThreadDetailsPanel", () => {
7474 startFromOrigin: false,
7575 onStartFromOriginChange: vi.fn(),
7676 onComposerFocusRequest: vi.fn(),
77 versionMismatch: null,
78 onDismissVersionMismatch: vi.fn(),
7977 onRunProjectScript: vi.fn(),
8078 onAddProjectScript: vi.fn() as ThreadDetailsPanelProps["onAddProjectScript"],
8179 onUpdateProjectScript: vi.fn() as ThreadDetailsPanelProps["onUpdateProjectScript"],
apps/web/src/components/chat/ThreadDetailsPanel.tsx+8-58
......@@ -5,23 +5,16 @@ import type {
55 ResolvedKeybindingsConfig,
66 ThreadId,
77} from "@t3tools/contracts";
8import { AlertTriangleIcon, XIcon } from "lucide-react";
98
109import type { DraftId } from "../../composerDraftStore";
1110import { useT3ProjectFileScripts } from "../../hooks/useT3ProjectFileScripts";
12import {
13 shouldShowEnvironmentIndicator,
14 type EnvMode,
15 type EnvironmentOption,
16} from "../BranchToolbar.logic";
11import { type EnvMode, type EnvironmentOption } from "../BranchToolbar.logic";
1712import { BranchToolbar } from "../BranchToolbar";
18import { BranchToolbarEnvironmentSelector } from "../BranchToolbarEnvironmentSelector";
1913import GitActionsControl from "../GitActionsControl";
2014import ProjectScriptsControl, {
2115 type NewProjectScriptInput,
2216 type ProjectScriptActionResult,
2317} from "../ProjectScriptsControl";
24import { Button } from "../ui/button";
2518import type { ComponentProps } from "react";
2619import { ThreadDetailsCard } from "./ThreadDetailsCard";
2720import { OpenInPicker } from "./OpenInPicker";
......@@ -29,12 +22,6 @@ import { ThreadDetailsSection } from "./ThreadDetailsSection";
2922import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel";
3023import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl";
3124
32interface VersionMismatchIssue {
33 readonly clientVersion: string;
34 readonly serverVersion: string;
35 readonly serverLabel: string;
36}
37
3825export interface ThreadDetailsPanelProps extends Pick<
3926 ComponentProps<typeof ThreadDetailsCard>,
4027 "anchor" | "handle" | "onPresentationChange"
......@@ -66,8 +53,6 @@ export interface ThreadDetailsPanelProps extends Pick<
6653 onCheckoutPullRequestRequest?: (reference: string) => void;
6754 onComposerFocusRequest: () => void;
6855 onOpenChanges?: () => void;
69 versionMismatch: VersionMismatchIssue | null;
70 onDismissVersionMismatch: () => void;
7156 onRunProjectScript: (script: ProjectScript) => void;
7257 onAddProjectScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>;
7358 onUpdateProjectScript: (
......@@ -82,14 +67,6 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
8267 props.environmentId,
8368 props.activeProjectScripts ? props.gitCwd : null,
8469 );
85 // Same rule as the composer strip: a lone remote machine still gets a row,
86 // shown as a static label because there is nothing to pick.
87 const canPickEnvironment = props.availableEnvironments.length > 1;
88 const showEnvironment = shouldShowEnvironmentIndicator({
89 activeEnvironment:
90 props.availableEnvironments.find((env) => env.environmentId === props.environmentId) ?? null,
91 canPickEnvironment,
92 });
9370 const branchToolbarProps = {
9471 showGitControls: props.isGitRepo,
9572 environmentId: props.environmentId,
......@@ -128,46 +105,19 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
128105 separated={false}
129106 showHeading={false}
130107 >
131 {props.versionMismatch ? (
132 <div className="mx-1 mb-2 flex gap-2 rounded-xl border border-warning/30 bg-warning/6 p-3">
133 <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0 text-warning" />
134 <div className="min-w-0 flex-1">
135 <p className="text-xs font-medium">Client and server versions differ</p>
136 <p className="mt-1 text-2xs leading-relaxed text-muted-foreground">
137 Client {props.versionMismatch.clientVersion} ·{" "}
138 {props.versionMismatch.serverLabel} {props.versionMismatch.serverVersion}
139 </p>
140 </div>
141 <Button
142 size="icon-xs"
143 variant="ghost"
144 aria-label="Dismiss version mismatch warning"
145 onClick={props.onDismissVersionMismatch}
146 >
147 <XIcon className="size-3.5" />
148 </Button>
149 </div>
150 ) : null}
151
152108 <div className="flex flex-col">
153 {density === "full" && showEnvironment ? (
154 <BranchToolbarEnvironmentSelector
155 displayMode="panel"
109 {density === "full" ? (
110 <BranchToolbar
111 layout="panel"
112 panelSection="workspace"
113 availableEnvironments={props.availableEnvironments}
114 onEnvironmentChange={props.onEnvironmentChange}
156115 autoEnvironmentLabel={props.autoEnvironmentLabel}
157116 onAutoEnvironment={props.onAutoEnvironment}
158 envLocked={props.envLocked}
159 environmentId={props.environmentId}
160 availableEnvironments={props.availableEnvironments}
161 {...(canPickEnvironment
162 ? { onEnvironmentChange: props.onEnvironmentChange }
163 : {})}
117 {...branchToolbarProps}
164118 />
165119 ) : null}
166120
167 {density === "full" ? (
168 <BranchToolbar layout="panel" panelSection="workspace" {...branchToolbarProps} />
169 ) : null}
170
171121 {density !== "essential" && props.showOpenInPicker ? (
172122 <OpenInPicker
173123 keybindings={props.keybindings}
apps/web/src/components/chat/ThreadDetailsPrRow.tsx+4-4
......@@ -75,7 +75,7 @@ import { Button } from "../ui/button";
7575import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
7676import {
7777 THREAD_DETAILS_PANEL_ICON_CLASS,
78 THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS,
78 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
7979 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
8080} from "./threadDetailsPanelStyles";
8181
......@@ -383,7 +383,7 @@ export function ThreadDetailsPrRow({
383383 </Tooltip>
384384 ) : null;
385385
386 const openRow = (text: string, part: "row" | "link-primary") => (
386 const openRow = (text: string, part: "row" | "primary") => (
387387 <Tooltip>
388388 <TooltipTrigger
389389 render={
......@@ -450,8 +450,8 @@ export function ThreadDetailsPrRow({
450450 </div>
451451 </div>
452452 ) : watching ? (
453 <div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}>
454 {openRow(label, "link-primary")}
453 <div className={THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS}>
454 {openRow(label, "primary")}
455455 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
456456 {watchControl("secondary")}
457457 </div>
apps/web/src/components/chat/ThreadRelationshipsControl.tsx+5-8
......@@ -59,7 +59,7 @@ import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
5959import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
6060import { toastManager } from "../ui/toast";
6161import {
62 THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS,
62 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
6363 THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS,
6464 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
6565} from "./threadDetailsPanelStyles";
......@@ -461,12 +461,9 @@ export function ThreadRelationshipsPanel(props: {
461461 </>
462462 );
463463 return (
464 <li
465 key={threadId}
466 className={`group relative flex h-8 items-center rounded-lg ${canStop ? THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS : ""}`}
467 >
464 <li key={threadId} className="group relative flex h-8 items-center rounded-lg">
468465 {isMergeTarget ? (
469 <div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}>
466 <div className={THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS}>
470467 <Tooltip>
471468 <TooltipTrigger
472469 delay={200}
......@@ -474,7 +471,7 @@ export function ThreadRelationshipsPanel(props: {
474471 <ThreadDetailsControl
475472 size="sm"
476473 variant="ghost"
477 part="link-primary"
474 part="primary"
478475 aria-label={`${threadTitle} ${threadRelationshipStatusLabel(status)}`}
479476 disabled={node?.missing === true}
480477 onClick={() => openThread(threadId)}
......@@ -534,7 +531,7 @@ export function ThreadRelationshipsPanel(props: {
534531 variant="ghost"
535532 disabled={node?.missing === true}
536533 onClick={() => openThread(threadId)}
537 part={canStop ? "group-row" : "row"}
534 part="row"
538535 />
539536 }
540537 >
apps/web/src/components/chat/threadDetailsPanelStyles.ts+4-13
......@@ -6,29 +6,20 @@
66const THREAD_DETAILS_PANEL_RESTING_BUTTON_SURFACE_CLASS = "bg-transparent shadow-none";
77
88const THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS =
9 "hover:!bg-black/[0.055] data-pressed:!bg-black/[0.055] dark:hover:!bg-white/[0.075] dark:data-pressed:!bg-white/[0.075]";
9 "hover:!bg-black/[0.035] data-pressed:!bg-black/[0.035] dark:hover:!bg-white/[0.08] dark:data-pressed:!bg-white/[0.08]";
1010
1111const THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_RESTING_BUTTON_SURFACE_CLASS} ${THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS}`;
12const THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS} data-popup-open:!bg-black/[0.035] dark:data-popup-open:!bg-white/[0.08]`;
13const THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS} has-[[data-popup-open]]:bg-black/[0.035] dark:has-[[data-popup-open]]:bg-white/[0.08]`;
1214
1315export const THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS = "gap-2.5 px-2.5 text-left";
1416
15// The row supplies the first tint; the hovered or open segment adds a second tint.
16const THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS} data-popup-open:!bg-black/[0.055] dark:data-popup-open:!bg-white/[0.075]`;
17
1817const THREAD_DETAILS_PANEL_CONTROL_CLASS = `h-8 min-w-0 rounded-lg border-transparent ${THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS} text-[13px] font-medium text-foreground/80`;
19const THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS} has-[[data-popup-open]]:bg-black/[0.055] dark:has-[[data-popup-open]]:bg-white/[0.075]`;
2018
2119export const THREAD_DETAILS_PANEL_ROW_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} w-full justify-start ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`;
2220
2321export const THREAD_DETAILS_PANEL_SELECT_ROW_CLASS = `${THREAD_DETAILS_PANEL_ROW_CLASS} pe-0 [&_[data-slot=select-icon]]:-me-px [&_[data-slot=select-icon]]:relative [&_[data-slot=select-icon]]:flex [&_[data-slot=select-icon]]:h-full [&_[data-slot=select-icon]]:w-8 [&_[data-slot=select-icon]]:shrink-0 [&_[data-slot=select-icon]]:items-center [&_[data-slot=select-icon]]:justify-center [&_[data-slot=select-icon]]:before:absolute [&_[data-slot=select-icon]]:before:-left-px [&_[data-slot=select-icon]]:before:top-1/2 [&_[data-slot=select-icon]]:before:h-4 [&_[data-slot=select-icon]]:before:w-px [&_[data-slot=select-icon]]:before:-translate-y-1/2 [&_[data-slot=select-icon]]:before:bg-border/65 [&_[data-slot=select-icon]>svg]:me-0 [&_[data-slot=select-icon]>svg]:size-4 [&_[data-slot=select-icon]>svg]:text-muted-foreground [&_[data-slot=select-icon]>svg]:opacity-100`;
2422
25// No transition on the group: the halves are Buttons whose background snaps (their base only
26// transitions shadow), so an eased group tint would land frames later and the hover would
27// visibly commit in two steps.
28export const THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS = `group/thread-details-link flex w-full items-center rounded-lg ${THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS}`;
29
30export const THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} flex-1 justify-start rounded-e-none ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`;
31
3223export const THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS = `h-8 w-full min-w-0 justify-start rounded-lg border border-transparent ${THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS} text-[13px] font-medium text-foreground/80 sm:h-8 sm:text-[13px]`;
3324
3425export const THREAD_DETAILS_PANEL_ICON_CLASS = "size-4 shrink-0 text-muted-foreground";
......@@ -40,7 +31,7 @@ export const THREAD_DETAILS_PANEL_ICON_ACTION_CLASS = `size-6 justify-center rou
4031/** A small text control on a row's second line, such as a pull request's checks or next action. */
4132export const THREAD_DETAILS_PANEL_META_ACTION_CLASS = `h-6 gap-1 rounded-md border-transparent px-1.5 text-xs font-medium text-muted-foreground ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`;
4233
43export const THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS = `group/thread-details-action flex w-full items-center rounded-lg ${THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS}`;
34export const THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS = `flex w-full items-center rounded-lg ${THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS}`;
4435
4536export const THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} flex-1 justify-start rounded-e-none ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`;
4637
apps/web/src/routes/_chat.pull-requests.tsx-1
......@@ -1843,7 +1843,6 @@ function PullRequestsRouteView() {
18431843 threadPanelOpen={false}
18441844 threadPanelPresentation="inline"
18451845 threadPanelShortcutLabel={null}
1846 threadPanelHasAttention={false}
18471846 onToggleThreadPanel={() => undefined}
18481847 rightPanelAvailable={rightPanelAvailable}
18491848 rightPanelOpen={rightPanelState.isOpen}