authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-03 13:31:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 13:31:44-07:00
log429c625a8515f9c2e4af28c488fbf32ce7789645
tree541399e3f79e605994c29f4681fad6496541c42e
parent0cfa113be597af3a1b2fe905a58a5b583f8089b8
signature Signed by PGP key B5690EEEBB952194

fix(web): workspace card docks beside chat when the window is narrow (#14992)

Chat stays centered while the workspace card fits beside it with 32px to spare. When it does not fit, chat moves left only as far as needed, narrows only after it reaches the left padding, and the card becomes a popover below a 640px chat. The card is lighter: 280px wide, 32px rows, no section labels, no "Project folder" hint. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

14 files changed, 123 insertions(+), 40 deletions(-)

apps/web/src/components/BranchToolbarEnvModeSelector.tsx+9-5
......@@ -59,7 +59,11 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
5959}: BranchToolbarEnvModeSelectorProps) {
6060 const workspacePath = displayMode === "panel" ? (activeWorktreePath ?? workspaceRoot) : null;
6161 const workspaceDisplayName = resolveWorkspaceDisplayName(workspacePath);
62 const workspaceKind = activeWorktreePath ? "Worktree" : "Project folder";
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;
6367 const composerFloatingLayerProps = useComposerMenuProps();
6468 const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree);
6569 const envModeItems = useMemo(
......@@ -147,9 +151,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
147151 : (workspaceDisplayName ??
148152 resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode))}
149153 </ComposerContextLabel>
150 {displayMode === "panel" ? (
154 {displayMode === "panel" && lockedWorkspaceKind ? (
151155 <span className="shrink-0 text-3xs font-normal text-muted-foreground/70">
152 {forceNewWorktree ? "Worktree" : workspaceKind}
156 {lockedWorkspaceKind}
153157 </span>
154158 ) : null}
155159 </span>
......@@ -210,9 +214,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
210214 <ComposerContextLabel displayMode={displayMode}>
211215 <SelectValue />
212216 </ComposerContextLabel>
213 {displayMode === "panel" ? (
217 {displayMode === "panel" && selectWorkspaceKind ? (
214218 <span className="shrink-0 text-3xs font-normal text-muted-foreground/70">
215 {effectiveEnvMode === "worktree" && !activeWorktreePath ? "Create" : workspaceKind}
219 {selectWorkspaceKind}
216220 </span>
217221 ) : null}
218222 </TooltipTrigger>
apps/web/src/components/GitActionsControl.tsx+1-1
......@@ -1880,7 +1880,7 @@ export default function GitActionsControl({
18801880 // the output row expands below.
18811881 <GitActionElapsedTime
18821882 startedAtMs={gitActionProgress.startedAtMs}
1883 className="flex h-9 shrink-0 items-center self-start pe-2.5 text-2xs font-normal tabular-nums text-muted-foreground"
1883 className="flex h-8 shrink-0 items-center self-start pe-2.5 text-2xs font-normal tabular-nums text-muted-foreground"
18841884 />
18851885 ) : (
18861886 <>
apps/web/src/components/chat/ChatCanvas.tsx+5-1
......@@ -11,7 +11,10 @@ import { ChatCanvasContext } from "./ChatCanvasContext";
1111import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout";
1212import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout";
1313
14/** Owns the available conversation space. Floating cards never reserve it themselves. */
14/**
15 * Owns the available conversation space. Cards only report where they sit; the
16 * canvas decides when chat moves over to make room for them.
17 */
1518export function ChatCanvas({
1619 composerOverlayElement,
1720 children,
......@@ -96,6 +99,7 @@ export function ChatCanvas({
9699 const container = { width: measurements.width, height: measurements.height };
97100 return {
98101 container,
102 lane: { padding: measurements.padding, minChatWidth: measurements.minChatWidth },
99103 layout: resolveChatCanvasLayout({ ...measurements, container, preview, detailsCard }),
100104 previewKey: preview?.key ?? null,
101105 reportPreview,
apps/web/src/components/chat/ChatCanvasContext.ts+1
......@@ -4,6 +4,7 @@ import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLay
44
55export const ChatCanvasContext = createContext<{
66 container: { width: number; height: number };
7 lane: { padding: number; minChatWidth: number };
78 layout: ReturnType<typeof resolveChatCanvasLayout>;
89 previewKey: string | null;
910 reportPreview: (preview: ChatCanvasPreview) => void;
apps/web/src/components/chat/ThreadDetailsCard.tsx+7-2
......@@ -29,12 +29,17 @@ export function ThreadDetailsCard({
2929 const preferredPlacement = canvas
3030 ? resolveThreadDetailsCardLayout({
3131 container: canvas.container,
32 chat: canvas.layout.chat,
32 lane: canvas.lane,
3333 frame: null,
3434 })
3535 : null;
3636 const placement = canvas
37 ? resolveThreadDetailsCardLayout({ container: canvas.container, ...canvas.layout })
37 ? resolveThreadDetailsCardLayout({
38 container: canvas.container,
39 lane: canvas.lane,
40 frame: canvas.layout.frame,
41 overlapsDetailsCard: canvas.layout.overlapsDetailsCard,
42 })
3843 : null;
3944 const mode = placement ? "inline" : "popover";
4045 const inlineOpen = useRightPanelStore((state) =>
apps/web/src/components/chat/ThreadDetailsControl.tsx+1-1
......@@ -56,7 +56,7 @@ export function ThreadDetailsControl({
5656 className: cn(
5757 "relative inline-flex shrink-0 cursor-pointer items-center whitespace-nowrap border outline-none transition-[box-shadow,scale] [&:active:not([aria-haspopup])]:scale-[0.97] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 disabled:pointer-events-none disabled:opacity-64 aria-disabled:cursor-not-allowed aria-disabled:opacity-64 [&_svg]:shrink-0",
5858 parts[part],
59 multiline && "h-auto min-h-9 py-0.75 disabled:opacity-100 sm:h-auto",
59 multiline && "h-auto min-h-8 py-0.75 disabled:opacity-100 sm:h-auto",
6060 tone === "muted" && "text-muted-foreground/70 hover:text-foreground/80",
6161 tone === "destructive" &&
6262 "text-destructive hover:text-destructive data-pressed:text-destructive",
apps/web/src/components/chat/ThreadDetailsPanel.tsx+2-2
......@@ -126,7 +126,7 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
126126 headingId="thread-details-workspace-heading"
127127 title="Workspace"
128128 separated={false}
129 showHeading={density === "full"}
129 showHeading={false}
130130 >
131131 {props.versionMismatch ? (
132132 <div className="mx-1 mb-2 flex gap-2 rounded-xl border border-warning/30 bg-warning/6 p-3">
......@@ -198,7 +198,7 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
198198 <ThreadDetailsSection
199199 headingId="thread-details-version-control-heading"
200200 title="Version Control"
201 showHeading={density === "full"}
201 showHeading={false}
202202 separated={density === "full"}
203203 >
204204 <div className="flex flex-col">
apps/web/src/components/chat/ThreadRelationshipsControl.tsx+2-2
......@@ -97,7 +97,7 @@ export function ThreadLineageRowList(props: {
9797 <button
9898 type="button"
9999 onClick={props.onShowMore}
100 className={`flex h-9 w-full cursor-pointer items-center rounded-lg ${THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS} text-sm font-medium text-muted-foreground/70 hover:bg-black/[0.055] hover:text-foreground/80 dark:hover:bg-white/[0.075]`}
100 className={`flex h-8 w-full cursor-pointer items-center rounded-lg ${THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS} text-sm font-medium text-muted-foreground/70 hover:bg-black/[0.055] hover:text-foreground/80 dark:hover:bg-white/[0.075]`}
101101 >
102102 <PlusIcon aria-hidden className="size-4 shrink-0" />
103103 Show {Math.min(props.hiddenCount, THREAD_LINEAGE_PAGE_COUNT)} more
......@@ -394,7 +394,7 @@ export function ThreadRelationshipsPanel(props: {
394394 </>
395395 );
396396 return (
397 <li key={threadId} className="group flex h-9 items-center rounded-lg">
397 <li key={threadId} className="group flex h-8 items-center rounded-lg">
398398 {isMergeTarget ? (
399399 <div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}>
400400 <Tooltip>
apps/web/src/components/chat/chatCanvasLayout.test.ts+45-1
......@@ -212,7 +212,12 @@ describe("chat canvas layout", () => {
212212 });
213213 };
214214 const density = (result: ReturnType<typeof layout>) => {
215 const card = resolveThreadDetailsCardLayout({ container, ...result });
215 const card = resolveThreadDetailsCardLayout({
216 container,
217 lane: { padding: 20, minChatWidth: 640 },
218 frame: result.frame,
219 overlapsDetailsCard: result.overlapsDetailsCard,
220 });
216221 return card
217222 ? resolveThreadDetailsCardDensity(card.height, { full: 327, compact: 182 })
218223 : "hidden";
......@@ -251,3 +256,42 @@ describe("chat canvas layout", () => {
251256 expect(result.frame).toEqual({ x: 272, y: 396, width: 1300, height: 400 });
252257 });
253258});
259
260describe("workspace card beside chat", () => {
261 const withCard = (width: number, player: ChatCanvasPreview | null = null, maxChatWidth = 736) =>
262 resolveChatCanvasLayout({
263 container: { width, height: 900 },
264 maxChatWidth,
265 composerHeight: 180,
266 detailsCard: { left: width - 292, right: width - 12, bottom: 400 },
267 preview: player,
268 });
269 it("keeps chat centered while the card fits beside it", () => {
270 expect(withCard(1384).chat).toEqual({ left: 324, width: 736, insetStart: 0, insetEnd: 0 });
271 });
272 it("moves chat left only as far as the card requires", () => {
273 expect(withCard(1383).chat).toEqual({ left: 323, width: 736, insetStart: 0, insetEnd: 1 });
274 expect(withCard(1147).chat).toEqual({ left: 87, width: 736, insetStart: 0, insetEnd: 237 });
275 });
276 it("narrows chat only after it reaches the left padding", () => {
277 expect(withCard(1080).chat).toMatchObject({ left: 20, width: 736 });
278 expect(withCard(1000).chat).toEqual({ left: 20, width: 656, insetStart: 0, insetEnd: 304 });
279 expect(withCard(984).chat).toMatchObject({ left: 20, width: 640 });
280 });
281 it("keeps a full-width chat clear of the card", () => {
282 expect(withCard(1147, null, 10_000).chat).toEqual({
283 left: 20,
284 width: 803,
285 insetStart: 0,
286 insetEnd: 304,
287 });
288 });
289 it("keeps centered chat in place when a new preview opens below the card", () => {
290 expect(withCard(1440, preview)).toEqual({
291 chat: { left: 352, width: 736, insetStart: 0, insetEnd: 0 },
292 frame: { x: 1108, y: 688, width: 320, height: 200 },
293 overlapsChat: false,
294 overlapsDetailsCard: false,
295 });
296 });
297});
apps/web/src/components/chat/chatCanvasLayout.ts+22-4
......@@ -19,8 +19,11 @@ export interface ChatCanvasPreview {
1919}
2020
2121const GAP = 12;
22// Minimum space between chat and the workspace card. Chat stays centered while
23// the card fits beside it with this much room.
24export const DETAILS_CARD_CLEARANCE = 32;
2225
23/** Pure geometry shared by the conversation, composer, and floating preview. */
26/** Pure geometry shared by the conversation, composer, workspace card, and floating preview. */
2427export function resolveChatCanvasLayout({
2528 container,
2629 preview,
......@@ -38,9 +41,24 @@ export function resolveChatCanvasLayout({
3841 composerHeight?: number;
3942 detailsCard?: PreviewMiniPlayerObstacles["detailsCard"];
4043}) {
41 const normalWidth = Math.max(0, Math.min(maxChatWidth, container.width - padding * 2));
42 const normalLeft = (container.width - normalWidth) / 2;
43 let chat = { left: normalLeft, width: normalWidth, insetStart: 0, insetEnd: 0 };
44 const centeredWidth = Math.max(0, Math.min(maxChatWidth, container.width - padding * 2));
45 // A workspace card that does not fit beside the centered chat first moves
46 // chat left, only as far as it needs. Chat narrows only after it reaches the
47 // left padding.
48 const laneRight = detailsCard
49 ? detailsCard.left - DETAILS_CARD_CLEARANCE
50 : container.width - padding;
51 const normalWidth = Math.max(0, Math.min(centeredWidth, laneRight - padding));
52 const normalLeft = Math.max(
53 padding,
54 Math.min((container.width - normalWidth) / 2, laneRight - normalWidth),
55 );
56 let chat = {
57 left: normalLeft,
58 width: normalWidth,
59 insetStart: 0,
60 insetEnd: Math.max(0, container.width - normalLeft * 2 - normalWidth),
61 };
4462 let frame: PreviewMiniPlayerFrame | null = null;
4563 let overlapsChat = false;
4664 if (preview && container.width > 0 && container.height > 0) {
apps/web/src/components/chat/threadDetailsCardLayout.test.ts+15-13
......@@ -4,37 +4,39 @@ import {
44 resolveThreadDetailsCardLayout,
55} from "./threadDetailsCardLayout";
66
7const lane = { padding: 20, minChatWidth: 640 };
78const resolve = (width: number, height: number, previewY: number | null = null) =>
89 resolveThreadDetailsCardLayout({
910 container: { width, height },
10 chat: { left: (width - 736) / 2, width: 736 },
11 lane,
1112 frame: previewY === null ? null : { x: width - 332, y: previewY, width: 320, height: 240 },
1213 });
1314
14describe("floating details card", () => {
15 it("uses the right margin without reserving chat space", () => {
15describe("workspace card", () => {
16 it("pins to the top right at a fixed width", () => {
1617 expect(resolve(1600, 900)).toEqual({
17 x: 1276,
18 x: 1308,
1819 y: 12,
19 width: 312,
20 width: 280,
2021 height: 876,
2122 });
22 expect(resolve(1344, 900)?.width).toBe(280);
23 expect(resolve(1344, 900)).toMatchObject({ x: 1052, width: 280 });
2324 });
24 it("hides when the margin cannot hold readable controls", () => {
25 expect(resolve(1200, 900)).toBeNull();
25 it("hides when a readable chat lane cannot fit beside it", () => {
26 expect(resolve(984, 900)).toMatchObject({ x: 692 });
27 expect(resolve(983, 900)).toBeNull();
2628 });
2729 it("keeps the card at the top right while the preview is freely dragged vertically", () => {
2830 for (const y of [12, 170, 250, 400, 648]) {
29 expect(resolve(1600, 900, y)).toEqual({ x: 1276, y: 12, width: 312, height: 876 });
31 expect(resolve(1600, 900, y)).toEqual({ x: 1308, y: 12, width: 280, height: 876 });
3032 }
3133 });
32 it("keeps width and height independent", () => {
34 it("keeps full height while a preview stays clear of the card", () => {
3335 expect(resolve(1344, 900, 600)).toMatchObject({ width: 280, height: 876 });
3436 expect(
3537 resolveThreadDetailsCardLayout({
3638 container: { width: 1600, height: 900 },
37 chat: { left: 432, width: 736 },
39 lane,
3840 frame: { x: 12, y: 100, width: 320, height: 240 },
3941 })?.height,
4042 ).toBe(876);
......@@ -45,14 +47,14 @@ describe("card content fitting", () => {
4547 const place = (previewY: number, previewHeight = 365, overlapsDetailsCard = false) =>
4648 resolveThreadDetailsCardLayout({
4749 container: { width: 1584, height: 988 },
48 chat: { left: 424, width: 736 },
50 lane,
4951 frame: { x: 1260, y: previewY, width: 240, height: previewHeight },
5052 overlapsDetailsCard,
5153 });
5254 it("does not fold in response to a drag while the full card can be kept clear", () => {
5355 for (const y of [12, 170, 225, 240, 340, 380, 611]) {
5456 const placement = place(y)!;
55 expect(placement).toMatchObject({ x: 1260, y: 12, height: 964 });
57 expect(placement).toMatchObject({ x: 1292, y: 12, height: 964 });
5658 expect(resolveThreadDetailsCardDensity(placement.height, { full: 327, compact: 182 })).toBe(
5759 "full",
5860 );
apps/web/src/components/chat/threadDetailsCardLayout.ts+10-5
......@@ -1,4 +1,5 @@
11import type { PreviewMiniPlayerFrame } from "../preview/previewMiniPlayerLayout";
2import { DETAILS_CARD_CLEARANCE } from "./chatCanvasLayout";
23
34export function resolveThreadDetailsCardDensity(
45 height: number,
......@@ -9,22 +10,26 @@ export function resolveThreadDetailsCardDensity(
910 return "essential";
1011}
1112
12/** The card uses leftover space. It never changes the conversation's width. */
13/**
14 * The card pins to the top right while a readable chat lane fits beside it.
15 * The chat canvas decides whether chat moves over to make room.
16 */
1317export function resolveThreadDetailsCardLayout({
1418 container,
15 chat,
19 lane,
1620 frame,
1721 overlapsDetailsCard = false,
1822}: {
1923 container: { width: number; height: number };
20 chat: { left: number; width: number };
24 lane: { padding: number; minChatWidth: number };
2125 frame: PreviewMiniPlayerFrame | null;
2226 overlapsDetailsCard?: boolean;
2327}) {
2428 const gap = 12;
25 const width = Math.min(312, container.width - chat.left - chat.width - gap * 2);
26 if (width < 240) return null;
29 // Keep in sync with --thread-details-panel-width, which sizes the popover.
30 const width = 280;
2731 const x = container.width - width - gap;
32 if (x - DETAILS_CARD_CLEARANCE - lane.padding < lane.minChatWidth) return null;
2833 // Resizing consumes the height above the player. Dragging first tries to
2934 // clear the full card and folds it only when there is no readable placement.
3035 const height =
apps/web/src/components/chat/threadDetailsPanelStyles.ts+2-2
......@@ -15,7 +15,7 @@ export const THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS = "gap-2.5 px-2.5 text-left"
1515// The row supplies the first tint; the hovered or open segment adds a second tint.
1616const 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]`;
1717
18const THREAD_DETAILS_PANEL_CONTROL_CLASS = `h-9 min-w-0 rounded-lg border-transparent ${THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS} text-[13px] font-medium text-foreground/80`;
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`;
1919const 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]`;
2020
2121export const THREAD_DETAILS_PANEL_ROW_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} w-full justify-start ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`;
......@@ -32,7 +32,7 @@ export const THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS = `${THREAD_DETAILS_P
3232/** The trailing half of a link split row when it carries a word ("Merge") rather than an icon. */
3333export const THREAD_DETAILS_PANEL_LINK_SPLIT_ACTION_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} shrink-0 justify-center rounded-s-none text-primary ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`;
3434
35export const THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS = `h-9 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-9 sm:text-[13px]`;
35export 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]`;
3636
3737export const THREAD_DETAILS_PANEL_ICON_CLASS = "size-4 shrink-0 text-muted-foreground";
3838
apps/web/src/index.css+1-1
......@@ -117,7 +117,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
117117 --workspace-titlebar-control-gap: 0.75rem;
118118 --workspace-titlebar-scroll-fade-height: 1.5rem;
119119 --chat-content-max-width: 46rem;
120 --thread-details-panel-width: 19.5rem;
120 --thread-details-panel-width: 17.5rem;
121121 /* The prompt on phones: iOS zooms into any input under 16px on focus. */
122122 --font-size-prompt-touch: max(var(--font-size-prompt, 1rem), 16px);
123123 /* Horizontal gutter shared by the page header and the chat column, clear of