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 {...@@ -13,7 +13,6 @@ import {
13 resolveBranchToolbarPrBranch,13 resolveBranchToolbarPrBranch,
14 resolveBranchToolbarValue,14 resolveBranchToolbarValue,
15 resolveLockedWorkspaceLabel,15 resolveLockedWorkspaceLabel,
16 resolveWorkspaceDisplayName,
17 resolveLocalCheckoutBranchMismatch,16 resolveLocalCheckoutBranchMismatch,
18 resolvePreviousWorktreeLabel,17 resolvePreviousWorktreeLabel,
19 resolvePreviousWorktreeSeed,18 resolvePreviousWorktreeSeed,
...@@ -568,18 +567,6 @@ describe("resolveLockedWorkspaceLabel", () => {...@@ -568,18 +567,6 @@ describe("resolveLockedWorkspaceLabel", () => {
568 });567 });
569});568});
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
583describe("deriveLocalBranchNameFromRemoteRef", () => {570describe("deriveLocalBranchNameFromRemoteRef", () => {
584 it("strips the remote prefix from a remote ref", () => {571 it("strips the remote prefix from a remote ref", () => {
585 expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo");572 expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo");
apps/web/src/components/BranchToolbar.logic.ts-7
...@@ -118,13 +118,6 @@ export function resolveLockedWorkspaceLabel(...@@ -118,13 +118,6 @@ export function resolveLockedWorkspaceLabel(
118 return effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : "Local checkout";118 return effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : "Local checkout";
119}119}
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
128export interface PreviousWorktreeSeed {121export interface PreviousWorktreeSeed {
129 branch: string | null;122 branch: string | null;
130 worktreePath: string;123 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 @@...@@ -1,3 +1,6 @@
1import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
2import { readLocalApi } from "../localApi";
3import { stackedThreadToast, toastManager } from "./ui/toast";
1import { ComposerContextLabel } from "./ComposerContextLabel";4import { ComposerContextLabel } from "./ComposerContextLabel";
2import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";5import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
3import type { EnvironmentId, ThreadId } from "@t3tools/contracts";6import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
...@@ -10,6 +13,7 @@ import {...@@ -10,6 +13,7 @@ import {
10} from "lucide-react";13} from "lucide-react";
11import {14import {
12 type Ref,15 type Ref,
16 type MouseEvent as ReactMouseEvent,
13 memo,17 memo,
14 useImperativeHandle,18 useImperativeHandle,
15 useCallback,19 useCallback,
...@@ -41,6 +45,11 @@ import {...@@ -41,6 +45,11 @@ import {
41import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector";45import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector";
42import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector";46import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector";
43import { PreviousWorktreeItemContent } from "./PreviousWorktreeItemContent";47import { 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";
44import { ComposerControl } from "./chat/ComposerControl";53import { ComposerControl } from "./chat/ComposerControl";
45import {54import {
46 Menu,55 Menu,
...@@ -93,7 +102,44 @@ interface BranchToolbarProps {...@@ -93,7 +102,44 @@ interface BranchToolbarProps {
93 contextStripVisible?: boolean;102 contextStripVisible?: boolean;
94}103}
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;
97 forceNewWorktree: boolean;143 forceNewWorktree: boolean;
98 autoEnvironmentLabel?: string | undefined;144 autoEnvironmentLabel?: string | undefined;
99 onAutoEnvironment?: (() => void) | undefined;145 onAutoEnvironment?: (() => void) | undefined;
...@@ -112,7 +158,9 @@ interface MobileRunContextSelectorProps {...@@ -112,7 +158,9 @@ interface MobileRunContextSelectorProps {
112 onUsePreviousWorktree: () => void;158 onUsePreviousWorktree: () => void;
113}159}
114160
115const MobileRunContextSelector = memo(function MobileRunContextSelector({161const RunContextSelector = memo(function RunContextSelector({
162 displayMode = "toolbar",
163 workspaceRoot,
116 forceNewWorktree,164 forceNewWorktree,
117 autoEnvironmentLabel,165 autoEnvironmentLabel,
118 onAutoEnvironment,166 onAutoEnvironment,
...@@ -129,7 +177,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -129,7 +177,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
129 previousWorktreeLabel,177 previousWorktreeLabel,
130 previousWorktreeBranch,178 previousWorktreeBranch,
131 onUsePreviousWorktree,179 onUsePreviousWorktree,
132}: MobileRunContextSelectorProps) {180}: RunContextSelectorProps) {
133 const composerFloatingLayerProps = useComposerMenuProps();181 const composerFloatingLayerProps = useComposerMenuProps();
134 const activeEnvironment = useMemo(182 const activeEnvironment = useMemo(
135 () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null,183 () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null,
...@@ -148,13 +196,35 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -148,13 +196,35 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
148 : effectiveEnvMode === "worktree"196 : effectiveEnvMode === "worktree"
149 ? resolveEnvModeLabel("worktree")197 ? resolveEnvModeLabel("worktree")
150 : resolveCurrentWorkspaceLabel(activeWorktreePath);198 : 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 };
152 const workspaceIcon = (210 const workspaceIcon = (
153 <Tooltip>211 <Tooltip>
154 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>212 <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 />
156 </TooltipTrigger>220 </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>
158 </Tooltip>228 </Tooltip>
159 );229 );
160 const icon = showEnvironmentIndicator ? (230 const icon = showEnvironmentIndicator ? (
...@@ -164,11 +234,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -164,11 +234,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
164 <Tooltip>234 <Tooltip>
165 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>235 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>
166 {autoEnvironmentLabel ? (236 {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 />
168 ) : (241 ) : (
169 <EnvironmentMachineIcon242 <EnvironmentMachineIcon
170 kind={activeEnvironment?.machine ?? "server"}243 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!"}
172 />245 />
173 )}246 )}
174 </TooltipTrigger>247 </TooltipTrigger>
...@@ -182,7 +255,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -182,7 +255,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
182 const triggerContent = (255 const triggerContent = (
183 <>256 <>
184 {icon}257 {icon}
185 <ComposerContextLabel>258 <ComposerContextLabel displayMode={displayMode}>
186 {autoEnvironmentLabel ??259 {autoEnvironmentLabel ??
187 (showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)}260 (showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)}
188 </ComposerContextLabel>261 </ComposerContextLabel>
...@@ -192,7 +265,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -192,7 +265,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
192 if (isLocked) {265 if (isLocked) {
193 return (266 return (
194 <span267 <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}
196 data-composer-context-control275 data-composer-context-control
197 >276 >
198 {triggerContent}277 {triggerContent}
...@@ -203,8 +282,17 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -203,8 +282,17 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
203 return (282 return (
204 <Menu>283 <Menu>
205 <MenuTrigger284 <MenuTrigger
206 render={<ComposerControl size="xs" />}285 render={isPanel ? <ThreadDetailsControl part="select" /> : <ComposerControl size="xs" />}
207 className="min-w-0 max-w-[48%] flex-initial justify-start"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}
208 data-composer-context-control296 data-composer-context-control
209 data-composer-shortcut={[297 data-composer-shortcut={[
210 showEnvironmentPicker && !envLocked ? "composer.host" : "",298 showEnvironmentPicker && !envLocked ? "composer.host" : "",
...@@ -212,13 +300,19 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -212,13 +300,19 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
212 ].join(" ")}300 ].join(" ")}
213 >301 >
214 {triggerContent}302 {triggerContent}
215 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />303 {!isPanel ? <ChevronDownIcon className="size-3 shrink-0 opacity-50" /> : null}
216 </MenuTrigger>304 </MenuTrigger>
217 <MenuPopup305 <MenuPopup
218 align="start"306 align="start"
219 side="top"307 side={isPanel ? "bottom" : "top"}
220 className={previousWorktreeLabel ? "w-[min(21rem,calc(100vw-2rem))]" : undefined}308 className={
221 {...composerFloatingLayerProps}309 isPanel
310 ? "w-(--anchor-width)"
311 : previousWorktreeLabel
312 ? "w-[min(21rem,calc(100vw-2rem))]"
313 : undefined
314 }
315 {...(!isPanel ? composerFloatingLayerProps : {})}
222 >316 >
223 {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? (317 {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? (
224 <>318 <>
...@@ -618,13 +712,25 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -618,13 +712,25 @@ export const BranchToolbar = memo(function BranchToolbar({
618 return (712 return (
619 <div className="flex w-full flex-col" data-thread-panel-run-context>713 <div className="flex w-full flex-col" data-thread-panel-run-context>
620 {panelSection !== "branch" ? (714 {panelSection !== "branch" ? (
621 <BranchToolbarEnvModeSelector715 <RunContextSelector
622 displayMode="panel"716 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}
624 effectiveEnvMode={effectiveEnvMode}728 effectiveEnvMode={effectiveEnvMode}
625 activeWorktreePath={activeWorktreePath}729 activeWorktreePath={activeWorktreePath}
626 workspaceRoot={activeProject.workspaceRoot}
627 onEnvModeChange={onEnvModeChange}730 onEnvModeChange={onEnvModeChange}
731 previousWorktreeLabel={previousWorktreeLabel}
732 previousWorktreeBranch={previousWorktreeSeed?.branch ?? null}
733 onUsePreviousWorktree={onUsePreviousWorktree}
628 />734 />
629 ) : null}735 ) : null}
630 {panelSection !== "workspace" ? (736 {panelSection !== "workspace" ? (
...@@ -662,7 +768,7 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -662,7 +768,7 @@ export const BranchToolbar = memo(function BranchToolbar({
662 >768 >
663 {showGitControls ? (769 {showGitControls ? (
664 <div className="contents @3xl/composer-surface:hidden">770 <div className="contents @3xl/composer-surface:hidden">
665 <MobileRunContextSelector771 <RunContextSelector
666 forceNewWorktree={forceNewWorktree}772 forceNewWorktree={forceNewWorktree}
667 autoEnvironmentLabel={autoEnvironmentLabel}773 autoEnvironmentLabel={autoEnvironmentLabel}
668 onAutoEnvironment={onAutoEnvironment}774 onAutoEnvironment={onAutoEnvironment}
apps/web/src/components/BranchToolbarBranchSelector.tsx+3-10
...@@ -42,10 +42,7 @@ import { threadEnvironment } from "../state/threads";...@@ -42,10 +42,7 @@ import { threadEnvironment } from "../state/threads";
42import { useAtomCommand } from "../state/use-atom-command";42import { useAtomCommand } from "../state/use-atom-command";
43import { vcsEnvironment } from "../state/vcs";43import { vcsEnvironment } from "../state/vcs";
44import { cn } from "../lib/utils";44import { cn } from "../lib/utils";
45import {45import { THREAD_DETAILS_PANEL_ICON_CLASS } from "./chat/threadDetailsPanelStyles";
46 THREAD_DETAILS_PANEL_CHEVRON_CLASS,
47 THREAD_DETAILS_PANEL_ICON_CLASS,
48} from "./chat/threadDetailsPanelStyles";
49import { ThreadDetailsPrRows } from "./chat/ThreadDetailsPrRows";46import { ThreadDetailsPrRows } from "./chat/ThreadDetailsPrRows";
50import { parsePullRequestReference } from "../pullRequestReference";47import { parsePullRequestReference } from "../pullRequestReference";
51import { getSourceControlPresentation } from "../sourceControlPresentation";48import { getSourceControlPresentation } from "../sourceControlPresentation";
...@@ -784,13 +781,9 @@ export function BranchToolbarBranchSelector({...@@ -784,13 +781,9 @@ export function BranchToolbarBranchSelector({
784 <ComposerContextLabel displayMode={displayMode}>781 <ComposerContextLabel displayMode={displayMode}>
785 <MiddleTruncate value={triggerLabel} className="w-full" />782 <MiddleTruncate value={triggerLabel} className="w-full" />
786 </ComposerContextLabel>783 </ComposerContextLabel>
787 {displayMode === "panel" ? (784 {displayMode !== "panel" ? (
788 <span data-slot="select-icon">
789 <ChevronDownIcon className={THREAD_DETAILS_PANEL_CHEVRON_CLASS} />
790 </span>
791 ) : (
792 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />785 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />
793 )}786 ) : null}
794 </ComboboxTrigger>787 </ComboboxTrigger>
795 </span>788 </span>
796 {displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? (789 {displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? (
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+23-114
...@@ -1,20 +1,12 @@...@@ -1,20 +1,12 @@
1import { ThreadDetailsSelectControl } from "./chat/ThreadDetailsControl";1import { ComposerSelectControl } from "./chat/ComposerControl";
2import { ComposerContextLabel } from "./ComposerContextLabel";2import { ComposerContextLabel } from "./ComposerContextLabel";
3import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react";3import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react";
4import { memo, useMemo, type MouseEvent as ReactMouseEvent } from "react";4import { 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
13import {6import {
14 resolveCurrentWorkspaceLabel,7 resolveCurrentWorkspaceLabel,
15 resolveEnvModeLabel,8 resolveEnvModeLabel,
16 resolveLockedWorkspaceLabel,9 resolveLockedWorkspaceLabel,
17 resolveWorkspaceDisplayName,
18 type EnvMode,10 type EnvMode,
19} from "./BranchToolbar.logic";11} from "./BranchToolbar.logic";
20import { useComposerMenuProps } from "./chat/composerEventScope";12import { useComposerMenuProps } from "./chat/composerEventScope";
...@@ -28,7 +20,6 @@ import {...@@ -28,7 +20,6 @@ import {
28 SelectValue,20 SelectValue,
29} from "./ui/select";21} from "./ui/select";
30import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";22import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
31import { stackedThreadToast, toastManager } from "./ui/toast";
3223
33const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree";24const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree";
3425
...@@ -37,9 +28,7 @@ interface BranchToolbarEnvModeSelectorProps {...@@ -37,9 +28,7 @@ interface BranchToolbarEnvModeSelectorProps {
37 envLocked: boolean;28 envLocked: boolean;
38 effectiveEnvMode: EnvMode;29 effectiveEnvMode: EnvMode;
39 activeWorktreePath: string | null;30 activeWorktreePath: string | null;
40 workspaceRoot?: string | null;
41 onEnvModeChange: (mode: EnvMode) => void;31 onEnvModeChange: (mode: EnvMode) => void;
42 displayMode?: "toolbar" | "panel";
43 previousWorktreeLabel?: string | null;32 previousWorktreeLabel?: string | null;
44 previousWorktreeBranch?: string | null;33 previousWorktreeBranch?: string | null;
45 onUsePreviousWorktree?: () => void;34 onUsePreviousWorktree?: () => void;
...@@ -50,72 +39,27 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -50,72 +39,27 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
50 envLocked,39 envLocked,
51 effectiveEnvMode,40 effectiveEnvMode,
52 activeWorktreePath,41 activeWorktreePath,
53 workspaceRoot = null,
54 onEnvModeChange,42 onEnvModeChange,
55 displayMode = "toolbar",
56 previousWorktreeLabel,43 previousWorktreeLabel,
57 previousWorktreeBranch = null,44 previousWorktreeBranch = null,
58 onUsePreviousWorktree,45 onUsePreviousWorktree,
59}: BranchToolbarEnvModeSelectorProps) {46}: 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;
67 const composerFloatingLayerProps = useComposerMenuProps();47 const composerFloatingLayerProps = useComposerMenuProps();
68 const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree);48 const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree);
69 const envModeItems = useMemo(49 const envModeItems = useMemo(
70 () => [50 () => [
71 {51 {
72 value: "local",52 value: "local",
73 label: workspaceDisplayName ?? resolveCurrentWorkspaceLabel(activeWorktreePath),53 label: resolveCurrentWorkspaceLabel(activeWorktreePath),
74 },54 },
75 { value: "worktree", label: resolveEnvModeLabel("worktree") },55 { value: "worktree", label: resolveEnvModeLabel("worktree") },
76 ...(showPreviousWorktree && previousWorktreeLabel56 ...(showPreviousWorktree && previousWorktreeLabel
77 ? [{ value: PREVIOUS_WORKTREE_SELECT_VALUE, label: previousWorktreeLabel }]57 ? [{ value: PREVIOUS_WORKTREE_SELECT_VALUE, label: previousWorktreeLabel }]
78 : []),58 : []),
79 ],59 ],
80 [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree, workspaceDisplayName],60 [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree],
81 );61 );
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
119 const stopContextMenuMouseDown = (event: ReactMouseEvent) => {63 const stopContextMenuMouseDown = (event: ReactMouseEvent) => {
120 if (event.button !== 0 || event.ctrlKey) {64 if (event.button !== 0 || event.ctrlKey) {
121 event.stopPropagation();65 event.stopPropagation();
...@@ -125,47 +69,31 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -125,47 +69,31 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
125 if (envLocked || forceNewWorktree) {69 if (envLocked || forceNewWorktree) {
126 const lockedRow = (70 const lockedRow = (
127 <span71 <span
128 className={cn(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"
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 )}
132 data-composer-context-control73 data-composer-context-control
133 onContextMenu={handleWorkspaceContextMenu}
134 >74 >
135 {activeWorktreePath ? (75 {activeWorktreePath ? (
136 <FolderGitIcon76 <FolderGitIcon className="size-3" />
137 className={displayMode === "panel" ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3"}
138 />
139 ) : effectiveEnvMode === "worktree" ? (77 ) : effectiveEnvMode === "worktree" ? (
140 <FolderGit2Icon78 <FolderGit2Icon className="size-3" />
141 className={displayMode === "panel" ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3"}
142 />
143 ) : (79 ) : (
144 <FolderIcon80 <FolderIcon className="size-3" />
145 className={displayMode === "panel" ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3"}
146 />
147 )}81 )}
148 <ComposerContextLabel displayMode={displayMode}>82 <ComposerContextLabel>
149 {forceNewWorktree83 {forceNewWorktree
150 ? resolveEnvModeLabel("worktree")84 ? resolveEnvModeLabel("worktree")
151 : (workspaceDisplayName ??85 : resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode)}
152 resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode))}
153 </ComposerContextLabel>86 </ComposerContextLabel>
154 {displayMode === "panel" && lockedWorkspaceKind ? (
155 <span className="shrink-0 text-3xs font-normal text-muted-foreground/70">
156 {lockedWorkspaceKind}
157 </span>
158 ) : null}
159 </span>87 </span>
160 );88 );
16189
162 return (90 return (
163 <Tooltip>91 <Tooltip>
164 <TooltipTrigger render={lockedRow} />92 <TooltipTrigger render={lockedRow} />
165 <TooltipPopup side={displayMode === "panel" ? "left" : undefined}>93 <TooltipPopup>
166 {forceNewWorktree94 {forceNewWorktree
167 ? "Each model starts in its own worktree."95 ? "Each model starts in its own worktree."
168 : (workspacePath ?? resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode))}96 : resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode)}
169 </TooltipPopup>97 </TooltipPopup>
170 </Tooltip>98 </Tooltip>
171 );99 );
...@@ -187,56 +115,37 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -187,56 +115,37 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
187 <Tooltip>115 <Tooltip>
188 <TooltipTrigger116 <TooltipTrigger
189 render={117 render={
190 <ThreadDetailsSelectControl118 <ComposerSelectControl
191 panel={displayMode === "panel"}119 size="xs"
192 className="min-w-0 shrink"120 className="min-w-0 shrink"
193 aria-label="Workspace"121 aria-label="Workspace"
194 data-composer-shortcut="composer.workspace"122 data-composer-shortcut="composer.workspace"
195 data-composer-context-control123 data-composer-context-control
196 onMouseDownCapture={stopContextMenuMouseDown}124 onMouseDownCapture={stopContextMenuMouseDown}
197 onContextMenu={handleWorkspaceContextMenu}
198 />125 />
199 }126 }
200 >127 >
201 {effectiveEnvMode === "worktree" ? (128 {effectiveEnvMode === "worktree" ? (
202 <FolderGit2Icon129 <FolderGit2Icon className="size-3" />
203 className={displayMode === "panel" ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3"}
204 />
205 ) : activeWorktreePath ? (130 ) : activeWorktreePath ? (
206 <FolderGitIcon131 <FolderGitIcon className="size-3" />
207 className={displayMode === "panel" ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3"}
208 />
209 ) : (132 ) : (
210 <FolderIcon133 <FolderIcon className="size-3" />
211 className={displayMode === "panel" ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3"}
212 />
213 )}134 )}
214 <ComposerContextLabel displayMode={displayMode}>135 <ComposerContextLabel>
215 <SelectValue />136 <SelectValue />
216 </ComposerContextLabel>137 </ComposerContextLabel>
217 {displayMode === "panel" && selectWorkspaceKind ? (
218 <span className="shrink-0 text-3xs font-normal text-muted-foreground/70">
219 {selectWorkspaceKind}
220 </span>
221 ) : null}
222 </TooltipTrigger>138 </TooltipTrigger>
223 <TooltipPopup side={displayMode === "panel" ? "left" : undefined}>139 <TooltipPopup>
224 {workspacePath ??140 {effectiveEnvMode === "worktree"
225 (effectiveEnvMode === "worktree"141 ? resolveEnvModeLabel("worktree")
226 ? resolveEnvModeLabel("worktree")142 : resolveCurrentWorkspaceLabel(activeWorktreePath)}
227 : resolveCurrentWorkspaceLabel(activeWorktreePath))}
228 </TooltipPopup>143 </TooltipPopup>
229 </Tooltip>144 </Tooltip>
230 <SelectPopup145 <SelectPopup
231 alignItemWithTrigger={false}146 alignItemWithTrigger={false}
232 {...(displayMode === "toolbar" ? composerFloatingLayerProps : {})}147 {...composerFloatingLayerProps}
233 className={148 className={showPreviousWorktree ? "w-[min(21rem,calc(100vw-2rem))]" : undefined}
234 displayMode === "panel"
235 ? "w-(--anchor-width)"
236 : showPreviousWorktree
237 ? "w-[min(21rem,calc(100vw-2rem))]"
238 : undefined
239 }
240 >149 >
241 <SelectGroup>150 <SelectGroup>
242 <SelectGroupLabel>Workspace</SelectGroupLabel>151 <SelectGroupLabel>Workspace</SelectGroupLabel>
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+10-37
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { ThreadDetailsSelectControl } from "./chat/ThreadDetailsControl";1import { ComposerSelectControl } from "./chat/ComposerControl";
2import { ComposerContextLabel } from "./ComposerContextLabel";2import { ComposerContextLabel } from "./ComposerContextLabel";
3import { Tooltip, TooltipTrigger, TooltipPopup } from "./ui/tooltip";3import { Tooltip, TooltipTrigger, TooltipPopup } from "./ui/tooltip";
4import type { EnvironmentId } from "@t3tools/contracts";4import type { EnvironmentId } from "@t3tools/contracts";
...@@ -6,11 +6,6 @@ import { ScaleIcon } from "lucide-react";...@@ -6,11 +6,6 @@ import { ScaleIcon } from "lucide-react";
6import { memo, useMemo } from "react";6import { memo, useMemo } from "react";
77
8import type { EnvironmentOption } from "./BranchToolbar.logic";8import 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";
14import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";9import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
15import { useComposerMenuProps } from "./chat/composerEventScope";10import { useComposerMenuProps } from "./chat/composerEventScope";
16import {11import {
...@@ -29,7 +24,6 @@ interface BranchToolbarEnvironmentSelectorProps {...@@ -29,7 +24,6 @@ interface BranchToolbarEnvironmentSelectorProps {
29 environmentId: EnvironmentId;24 environmentId: EnvironmentId;
30 availableEnvironments: readonly EnvironmentOption[];25 availableEnvironments: readonly EnvironmentOption[];
31 onEnvironmentChange?: (environmentId: EnvironmentId) => void;26 onEnvironmentChange?: (environmentId: EnvironmentId) => void;
32 displayMode?: "toolbar" | "panel";
33}27}
3428
35export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({29export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({
...@@ -39,7 +33,6 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -39,7 +33,6 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
39 environmentId,33 environmentId,
40 availableEnvironments,34 availableEnvironments,
41 onEnvironmentChange,35 onEnvironmentChange,
42 displayMode = "toolbar",
43}: BranchToolbarEnvironmentSelectorProps) {36}: BranchToolbarEnvironmentSelectorProps) {
44 const composerFloatingLayerProps = useComposerMenuProps();37 const composerFloatingLayerProps = useComposerMenuProps();
45 const activeEnvironment = useMemo(() => {38 const activeEnvironment = useMemo(() => {
...@@ -67,19 +60,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -67,19 +60,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
67 if (envLocked || onEnvironmentChange === undefined) {60 if (envLocked || onEnvironmentChange === undefined) {
68 const lockedRow = (61 const lockedRow = (
69 <span62 <span
70 className={cn(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"
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 )}
74 data-composer-context-control64 data-composer-context-control
75 >65 >
76 <EnvironmentMachineIcon66 <EnvironmentMachineIcon
77 kind={activeEnvironment?.machine ?? "server"}67 kind={activeEnvironment?.machine ?? "server"}
78 className={displayMode === "panel" ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3 shrink-0"}68 className="size-3 shrink-0"
79 />69 />
80 <ComposerContextLabel displayMode={displayMode}>70 <ComposerContextLabel>{activeEnvironment?.label ?? "Run on"}</ComposerContextLabel>
81 {activeEnvironment?.label ?? "Run on"}
82 </ComposerContextLabel>
83 </span>71 </span>
84 );72 );
85 return (73 return (
...@@ -102,8 +90,8 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -102,8 +90,8 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
102 <Tooltip>90 <Tooltip>
103 <TooltipTrigger91 <TooltipTrigger
104 render={92 render={
105 <ThreadDetailsSelectControl93 <ComposerSelectControl
106 panel={displayMode === "panel"}94 size="xs"
107 className="min-w-0 max-w-full"95 className="min-w-0 max-w-full"
108 aria-label="Run on"96 aria-label="Run on"
109 data-composer-shortcut="composer.host"97 data-composer-shortcut="composer.host"
...@@ -112,35 +100,20 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -112,35 +100,20 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
112 }100 }
113 >101 >
114 {autoEnvironmentLabel ? (102 {autoEnvironmentLabel ? (
115 <ScaleIcon103 <ScaleIcon className="size-3 shrink-0" aria-hidden="true" />
116 className={
117 displayMode === "panel" ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3 shrink-0"
118 }
119 aria-hidden="true"
120 />
121 ) : (104 ) : (
122 <EnvironmentMachineIcon105 <EnvironmentMachineIcon
123 kind={activeEnvironment?.machine ?? "server"}106 kind={activeEnvironment?.machine ?? "server"}
124 className={107 className="size-3 shrink-0"
125 displayMode === "panel" ? THREAD_DETAILS_PANEL_ICON_CLASS : "size-3 shrink-0"
126 }
127 />108 />
128 )}109 )}
129 <ComposerContextLabel displayMode={displayMode}>110 <ComposerContextLabel>
130 <SelectValue />111 <SelectValue />
131 </ComposerContextLabel>112 </ComposerContextLabel>
132 </TooltipTrigger>113 </TooltipTrigger>
133 <TooltipPopup>{autoEnvironmentLabel ?? activeEnvironment?.label ?? "Run on"}</TooltipPopup>114 <TooltipPopup>{autoEnvironmentLabel ?? activeEnvironment?.label ?? "Run on"}</TooltipPopup>
134 </Tooltip>115 </Tooltip>
135 <SelectPopup116 <SelectPopup alignItemWithTrigger={false} {...composerFloatingLayerProps}>
136 alignItemWithTrigger={false}
137 {...(displayMode === "toolbar" ? composerFloatingLayerProps : {})}
138 {...(displayMode === "panel"
139 ? {
140 className: "w-(--anchor-width)",
141 }
142 : {})}
143 >
144 <SelectGroup>117 <SelectGroup>
145 <SelectGroupLabel>Run on</SelectGroupLabel>118 <SelectGroupLabel>Run on</SelectGroupLabel>
146 {onAutoEnvironment && (119 {onAutoEnvironment && (
apps/web/src/components/ChatView.tsx-16
...@@ -3069,11 +3069,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -3069,11 +3069,6 @@ export default function ChatView(props: ChatViewProps) {
3069 hasMultipleRegisteredEnvironments && activeThread3069 hasMultipleRegisteredEnvironments && activeThread
3070 ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server`3070 ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server`
3071 : "server";3071 : "server";
3072 const handleDismissVersionMismatch = useCallback(() => {
3073 if (!versionMismatchDismissKey) return;
3074 dismissVersionMismatch(versionMismatchDismissKey);
3075 setDismissedVersionMismatchKey(versionMismatchDismissKey);
3076 }, [setDismissedVersionMismatchKey, versionMismatchDismissKey]);
3077 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;3072 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;
3078 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);3073 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
3079 const versionMismatchInstallation = serverConfig?.environment.capabilities.serverInstallation;3074 const versionMismatchInstallation = serverConfig?.environment.capabilities.serverInstallation;
...@@ -11078,15 +11073,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -11078,15 +11073,6 @@ export default function ChatView(props: ChatViewProps) {
11078 : {}),11073 : {}),
11079 onComposerFocusRequest: scheduleComposerFocus,11074 onComposerFocusRequest: scheduleComposerFocus,
11080 ...(isServerThread && isGitRepo ? { onOpenChanges: openChangesFromThreadPanel } : {}),11075 ...(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,
11090 onRunProjectScript: runProjectScript,11076 onRunProjectScript: runProjectScript,
11091 onAddProjectScript: saveProjectScript,11077 onAddProjectScript: saveProjectScript,
11092 onUpdateProjectScript: updateProjectScript,11078 onUpdateProjectScript: updateProjectScript,
...@@ -11100,8 +11086,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -11100,8 +11086,6 @@ export default function ChatView(props: ChatViewProps) {
11100 threadPanelPresentation,11086 threadPanelPresentation,
11101 threadPanelPopoverHandle,11087 threadPanelPopoverHandle,
11102 threadPanelShortcutLabel: shortcutLabelForCommand(keybindings, "threadPanel.toggle"),11088 threadPanelShortcutLabel: shortcutLabelForCommand(keybindings, "threadPanel.toggle"),
11103 threadPanelHasAttention:
11104 activeEnvironmentUnavailableState !== null || showVersionMismatchBanner,
11105 rightPanelAvailable: activeProject !== null,11089 rightPanelAvailable: activeProject !== null,
11106 rightPanelOpen,11090 rightPanelOpen,
11107 rightPanelShortcutLabel: shortcutLabelForCommand(keybindings, "rightPanel.toggle"),11091 rightPanelShortcutLabel: shortcutLabelForCommand(keybindings, "rightPanel.toggle"),
apps/web/src/components/chat/PanelLayoutControls.tsx-8
...@@ -19,7 +19,6 @@ export interface PanelLayoutControlsProps {...@@ -19,7 +19,6 @@ export interface PanelLayoutControlsProps {
19 threadPanelPresentation: ThreadPanelPresentation;19 threadPanelPresentation: ThreadPanelPresentation;
20 threadPanelPopoverHandle?: ReturnType<typeof PopoverCreateHandle>;20 threadPanelPopoverHandle?: ReturnType<typeof PopoverCreateHandle>;
21 threadPanelShortcutLabel: string | null;21 threadPanelShortcutLabel: string | null;
22 threadPanelHasAttention: boolean;
23 rightPanelAvailable: boolean;22 rightPanelAvailable: boolean;
24 rightPanelOpen: boolean;23 rightPanelOpen: boolean;
25 rightPanelShortcutLabel: string | null;24 rightPanelShortcutLabel: string | null;
...@@ -40,7 +39,6 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -40,7 +39,6 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
40 threadPanelPresentation,39 threadPanelPresentation,
41 threadPanelPopoverHandle,40 threadPanelPopoverHandle,
42 threadPanelShortcutLabel,41 threadPanelShortcutLabel,
43 threadPanelHasAttention,
44 rightPanelAvailable,42 rightPanelAvailable,
45 rightPanelOpen,43 rightPanelOpen,
46 rightPanelShortcutLabel,44 rightPanelShortcutLabel,
...@@ -58,12 +56,6 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -58,12 +56,6 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
58 size="sm"56 size="sm"
59 >57 >
60 <SquareMenuIcon className="size-4" />58 <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}
67 </Toggle>59 </Toggle>
68 );60 );
69 const threadPanelTooltip = (trigger: ReactElement) => (61 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 @@...@@ -1,17 +1,14 @@
1import { mergeProps } from "@base-ui/react/merge-props";1import { mergeProps } from "@base-ui/react/merge-props";
2import { Select as SelectPrimitive } from "@base-ui/react/select";
3import { useRender } from "@base-ui/react/use-render";2import { useRender } from "@base-ui/react/use-render";
4import type { ComponentProps } from "react";3import type { ComponentProps } from "react";
54
6import { cn } from "~/lib/utils";5import { cn } from "~/lib/utils";
7import { Button } from "../ui/button";6import { Button } from "../ui/button";
8import { ComposerSelectControl } from "./ComposerControl";
9import {7import {
10 THREAD_DETAILS_PANEL_ROW_CLASS,8 THREAD_DETAILS_PANEL_ROW_CLASS,
11 THREAD_DETAILS_PANEL_SELECT_ROW_CLASS,9 THREAD_DETAILS_PANEL_SELECT_ROW_CLASS,
12 THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS,10 THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS,
13 THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS,11 THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS,
14 THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS,
15 THREAD_DETAILS_PANEL_ICON_ACTION_CLASS,12 THREAD_DETAILS_PANEL_ICON_ACTION_CLASS,
16 THREAD_DETAILS_PANEL_META_ACTION_CLASS,13 THREAD_DETAILS_PANEL_META_ACTION_CLASS,
17 THREAD_DETAILS_PANEL_CHEVRON_CLASS,14 THREAD_DETAILS_PANEL_CHEVRON_CLASS,
...@@ -20,11 +17,9 @@ import { ChevronDownIcon } from "lucide-react";...@@ -20,11 +17,9 @@ import { ChevronDownIcon } from "lucide-react";
2017
21const parts = {18const parts = {
22 row: THREAD_DETAILS_PANEL_ROW_CLASS,19 row: THREAD_DETAILS_PANEL_ROW_CLASS,
23 "group-row": `${THREAD_DETAILS_PANEL_ROW_CLASS} hover:!bg-transparent dark:hover:!bg-transparent`,
24 select: THREAD_DETAILS_PANEL_SELECT_ROW_CLASS,20 select: THREAD_DETAILS_PANEL_SELECT_ROW_CLASS,
25 primary: THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS,21 primary: THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS,
26 secondary: THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS,22 secondary: THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS,
27 "link-primary": THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS,
28 icon: THREAD_DETAILS_PANEL_ICON_ACTION_CLASS,23 icon: THREAD_DETAILS_PANEL_ICON_ACTION_CLASS,
29 meta: THREAD_DETAILS_PANEL_META_ACTION_CLASS,24 meta: THREAD_DETAILS_PANEL_META_ACTION_CLASS,
30};25};
...@@ -36,6 +31,7 @@ export function ThreadDetailsControl({...@@ -36,6 +31,7 @@ export function ThreadDetailsControl({
36 multiline = false,31 multiline = false,
37 tone = "default",32 tone = "default",
38 className,33 className,
34 children,
39 size = "default",35 size = "default",
40 variant = "default",36 variant = "default",
41 render,37 render,
...@@ -64,6 +60,19 @@ export function ThreadDetailsControl({...@@ -64,6 +60,19 @@ export function ThreadDetailsControl({
64 ),60 ),
65 },61 },
66 props,62 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 },
67 ),76 ),
68 });77 });
69 if (!panel) {78 if (!panel) {
...@@ -74,34 +83,10 @@ export function ThreadDetailsControl({...@@ -74,34 +83,10 @@ export function ThreadDetailsControl({
74 size={multiline ? "sm-multiline" : size}83 size={multiline ? "sm-multiline" : size}
75 variant={variant}84 variant={variant}
76 className={className}85 className={className}
77 />86 >
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}>
92 {children}87 {children}
93 </ComposerSelectControl>88 </Button>
94 );89 );
95 }90 }
96 return (91 return control;
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 );
107}92}
apps/web/src/components/chat/ThreadDetailsPanel.test.tsx-2
...@@ -74,8 +74,6 @@ describe("ThreadDetailsPanel", () => {...@@ -74,8 +74,6 @@ describe("ThreadDetailsPanel", () => {
74 startFromOrigin: false,74 startFromOrigin: false,
75 onStartFromOriginChange: vi.fn(),75 onStartFromOriginChange: vi.fn(),
76 onComposerFocusRequest: vi.fn(),76 onComposerFocusRequest: vi.fn(),
77 versionMismatch: null,
78 onDismissVersionMismatch: vi.fn(),
79 onRunProjectScript: vi.fn(),77 onRunProjectScript: vi.fn(),
80 onAddProjectScript: vi.fn() as ThreadDetailsPanelProps["onAddProjectScript"],78 onAddProjectScript: vi.fn() as ThreadDetailsPanelProps["onAddProjectScript"],
81 onUpdateProjectScript: vi.fn() as ThreadDetailsPanelProps["onUpdateProjectScript"],79 onUpdateProjectScript: vi.fn() as ThreadDetailsPanelProps["onUpdateProjectScript"],
apps/web/src/components/chat/ThreadDetailsPanel.tsx+8-58
...@@ -5,23 +5,16 @@ import type {...@@ -5,23 +5,16 @@ import type {
5 ResolvedKeybindingsConfig,5 ResolvedKeybindingsConfig,
6 ThreadId,6 ThreadId,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { AlertTriangleIcon, XIcon } from "lucide-react";
98
10import type { DraftId } from "../../composerDraftStore";9import type { DraftId } from "../../composerDraftStore";
11import { useT3ProjectFileScripts } from "../../hooks/useT3ProjectFileScripts";10import { useT3ProjectFileScripts } from "../../hooks/useT3ProjectFileScripts";
12import {11import { type EnvMode, type EnvironmentOption } from "../BranchToolbar.logic";
13 shouldShowEnvironmentIndicator,
14 type EnvMode,
15 type EnvironmentOption,
16} from "../BranchToolbar.logic";
17import { BranchToolbar } from "../BranchToolbar";12import { BranchToolbar } from "../BranchToolbar";
18import { BranchToolbarEnvironmentSelector } from "../BranchToolbarEnvironmentSelector";
19import GitActionsControl from "../GitActionsControl";13import GitActionsControl from "../GitActionsControl";
20import ProjectScriptsControl, {14import ProjectScriptsControl, {
21 type NewProjectScriptInput,15 type NewProjectScriptInput,
22 type ProjectScriptActionResult,16 type ProjectScriptActionResult,
23} from "../ProjectScriptsControl";17} from "../ProjectScriptsControl";
24import { Button } from "../ui/button";
25import type { ComponentProps } from "react";18import type { ComponentProps } from "react";
26import { ThreadDetailsCard } from "./ThreadDetailsCard";19import { ThreadDetailsCard } from "./ThreadDetailsCard";
27import { OpenInPicker } from "./OpenInPicker";20import { OpenInPicker } from "./OpenInPicker";
...@@ -29,12 +22,6 @@ import { ThreadDetailsSection } from "./ThreadDetailsSection";...@@ -29,12 +22,6 @@ import { ThreadDetailsSection } from "./ThreadDetailsSection";
29import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel";22import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel";
30import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl";23import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl";
3124
32interface VersionMismatchIssue {
33 readonly clientVersion: string;
34 readonly serverVersion: string;
35 readonly serverLabel: string;
36}
37
38export interface ThreadDetailsPanelProps extends Pick<25export interface ThreadDetailsPanelProps extends Pick<
39 ComponentProps<typeof ThreadDetailsCard>,26 ComponentProps<typeof ThreadDetailsCard>,
40 "anchor" | "handle" | "onPresentationChange"27 "anchor" | "handle" | "onPresentationChange"
...@@ -66,8 +53,6 @@ export interface ThreadDetailsPanelProps extends Pick<...@@ -66,8 +53,6 @@ export interface ThreadDetailsPanelProps extends Pick<
66 onCheckoutPullRequestRequest?: (reference: string) => void;53 onCheckoutPullRequestRequest?: (reference: string) => void;
67 onComposerFocusRequest: () => void;54 onComposerFocusRequest: () => void;
68 onOpenChanges?: () => void;55 onOpenChanges?: () => void;
69 versionMismatch: VersionMismatchIssue | null;
70 onDismissVersionMismatch: () => void;
71 onRunProjectScript: (script: ProjectScript) => void;56 onRunProjectScript: (script: ProjectScript) => void;
72 onAddProjectScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>;57 onAddProjectScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>;
73 onUpdateProjectScript: (58 onUpdateProjectScript: (
...@@ -82,14 +67,6 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {...@@ -82,14 +67,6 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
82 props.environmentId,67 props.environmentId,
83 props.activeProjectScripts ? props.gitCwd : null,68 props.activeProjectScripts ? props.gitCwd : null,
84 );69 );
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 });
93 const branchToolbarProps = {70 const branchToolbarProps = {
94 showGitControls: props.isGitRepo,71 showGitControls: props.isGitRepo,
95 environmentId: props.environmentId,72 environmentId: props.environmentId,
...@@ -128,46 +105,19 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {...@@ -128,46 +105,19 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
128 separated={false}105 separated={false}
129 showHeading={false}106 showHeading={false}
130 >107 >
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
152 <div className="flex flex-col">108 <div className="flex flex-col">
153 {density === "full" && showEnvironment ? (109 {density === "full" ? (
154 <BranchToolbarEnvironmentSelector110 <BranchToolbar
155 displayMode="panel"111 layout="panel"
112 panelSection="workspace"
113 availableEnvironments={props.availableEnvironments}
114 onEnvironmentChange={props.onEnvironmentChange}
156 autoEnvironmentLabel={props.autoEnvironmentLabel}115 autoEnvironmentLabel={props.autoEnvironmentLabel}
157 onAutoEnvironment={props.onAutoEnvironment}116 onAutoEnvironment={props.onAutoEnvironment}
158 envLocked={props.envLocked}117 {...branchToolbarProps}
159 environmentId={props.environmentId}
160 availableEnvironments={props.availableEnvironments}
161 {...(canPickEnvironment
162 ? { onEnvironmentChange: props.onEnvironmentChange }
163 : {})}
164 />118 />
165 ) : null}119 ) : null}
166120
167 {density === "full" ? (
168 <BranchToolbar layout="panel" panelSection="workspace" {...branchToolbarProps} />
169 ) : null}
170
171 {density !== "essential" && props.showOpenInPicker ? (121 {density !== "essential" && props.showOpenInPicker ? (
172 <OpenInPicker122 <OpenInPicker
173 keybindings={props.keybindings}123 keybindings={props.keybindings}
apps/web/src/components/chat/ThreadDetailsPrRow.tsx+4-4
...@@ -75,7 +75,7 @@ import { Button } from "../ui/button";...@@ -75,7 +75,7 @@ import { Button } from "../ui/button";
75import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";75import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
76import {76import {
77 THREAD_DETAILS_PANEL_ICON_CLASS,77 THREAD_DETAILS_PANEL_ICON_CLASS,
78 THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS,78 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
79 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,79 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
80} from "./threadDetailsPanelStyles";80} from "./threadDetailsPanelStyles";
8181
...@@ -383,7 +383,7 @@ export function ThreadDetailsPrRow({...@@ -383,7 +383,7 @@ export function ThreadDetailsPrRow({
383 </Tooltip>383 </Tooltip>
384 ) : null;384 ) : null;
385385
386 const openRow = (text: string, part: "row" | "link-primary") => (386 const openRow = (text: string, part: "row" | "primary") => (
387 <Tooltip>387 <Tooltip>
388 <TooltipTrigger388 <TooltipTrigger
389 render={389 render={
...@@ -450,8 +450,8 @@ export function ThreadDetailsPrRow({...@@ -450,8 +450,8 @@ export function ThreadDetailsPrRow({
450 </div>450 </div>
451 </div>451 </div>
452 ) : watching ? (452 ) : watching ? (
453 <div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}>453 <div className={THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS}>
454 {openRow(label, "link-primary")}454 {openRow(label, "primary")}
455 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />455 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
456 {watchControl("secondary")}456 {watchControl("secondary")}
457 </div>457 </div>
apps/web/src/components/chat/ThreadRelationshipsControl.tsx+5-8
...@@ -59,7 +59,7 @@ import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";...@@ -59,7 +59,7 @@ import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
59import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";59import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
60import { toastManager } from "../ui/toast";60import { toastManager } from "../ui/toast";
61import {61import {
62 THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS,62 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
63 THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS,63 THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS,
64 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,64 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
65} from "./threadDetailsPanelStyles";65} from "./threadDetailsPanelStyles";
...@@ -461,12 +461,9 @@ export function ThreadRelationshipsPanel(props: {...@@ -461,12 +461,9 @@ export function ThreadRelationshipsPanel(props: {
461 </>461 </>
462 );462 );
463 return (463 return (
464 <li464 <li key={threadId} className="group relative flex h-8 items-center rounded-lg">
465 key={threadId}
466 className={`group relative flex h-8 items-center rounded-lg ${canStop ? THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS : ""}`}
467 >
468 {isMergeTarget ? (465 {isMergeTarget ? (
469 <div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}>466 <div className={THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS}>
470 <Tooltip>467 <Tooltip>
471 <TooltipTrigger468 <TooltipTrigger
472 delay={200}469 delay={200}
...@@ -474,7 +471,7 @@ export function ThreadRelationshipsPanel(props: {...@@ -474,7 +471,7 @@ export function ThreadRelationshipsPanel(props: {
474 <ThreadDetailsControl471 <ThreadDetailsControl
475 size="sm"472 size="sm"
476 variant="ghost"473 variant="ghost"
477 part="link-primary"474 part="primary"
478 aria-label={`${threadTitle} ${threadRelationshipStatusLabel(status)}`}475 aria-label={`${threadTitle} ${threadRelationshipStatusLabel(status)}`}
479 disabled={node?.missing === true}476 disabled={node?.missing === true}
480 onClick={() => openThread(threadId)}477 onClick={() => openThread(threadId)}
...@@ -534,7 +531,7 @@ export function ThreadRelationshipsPanel(props: {...@@ -534,7 +531,7 @@ export function ThreadRelationshipsPanel(props: {
534 variant="ghost"531 variant="ghost"
535 disabled={node?.missing === true}532 disabled={node?.missing === true}
536 onClick={() => openThread(threadId)}533 onClick={() => openThread(threadId)}
537 part={canStop ? "group-row" : "row"}534 part="row"
538 />535 />
539 }536 }
540 >537 >
apps/web/src/components/chat/threadDetailsPanelStyles.ts+4-13
...@@ -6,29 +6,20 @@...@@ -6,29 +6,20 @@
6const THREAD_DETAILS_PANEL_RESTING_BUTTON_SURFACE_CLASS = "bg-transparent shadow-none";6const THREAD_DETAILS_PANEL_RESTING_BUTTON_SURFACE_CLASS = "bg-transparent shadow-none";
77
8const THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS =8const 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
11const THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_RESTING_BUTTON_SURFACE_CLASS} ${THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS}`;11const 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
13export const THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS = "gap-2.5 px-2.5 text-left";15export 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
18const 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`;17const 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
21export const THREAD_DETAILS_PANEL_ROW_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} w-full justify-start ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`;19export const THREAD_DETAILS_PANEL_ROW_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} w-full justify-start ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`;
2220
23export 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`;21export 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
32export 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]`;23export 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
34export const THREAD_DETAILS_PANEL_ICON_CLASS = "size-4 shrink-0 text-muted-foreground";25export 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...@@ -40,7 +31,7 @@ export const THREAD_DETAILS_PANEL_ICON_ACTION_CLASS = `size-6 justify-center rou
40/** A small text control on a row's second line, such as a pull request's checks or next action. */31/** A small text control on a row's second line, such as a pull request's checks or next action. */
41export 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}`;32export 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
45export 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}`;36export 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() {...@@ -1843,7 +1843,6 @@ function PullRequestsRouteView() {
1843 threadPanelOpen={false}1843 threadPanelOpen={false}
1844 threadPanelPresentation="inline"1844 threadPanelPresentation="inline"
1845 threadPanelShortcutLabel={null}1845 threadPanelShortcutLabel={null}
1846 threadPanelHasAttention={false}
1847 onToggleThreadPanel={() => undefined}1846 onToggleThreadPanel={() => undefined}
1848 rightPanelAvailable={rightPanelAvailable}1847 rightPanelAvailable={rightPanelAvailable}
1849 rightPanelOpen={rightPanelState.isOpen}1848 rightPanelOpen={rightPanelState.isOpen}