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...@@ -59,7 +59,11 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
59}: BranchToolbarEnvModeSelectorProps) {59}: BranchToolbarEnvModeSelectorProps) {
60 const workspacePath = displayMode === "panel" ? (activeWorktreePath ?? workspaceRoot) : null;60 const workspacePath = displayMode === "panel" ? (activeWorktreePath ?? workspaceRoot) : null;
61 const workspaceDisplayName = resolveWorkspaceDisplayName(workspacePath);61 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;
63 const composerFloatingLayerProps = useComposerMenuProps();67 const composerFloatingLayerProps = useComposerMenuProps();
64 const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree);68 const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree);
65 const envModeItems = useMemo(69 const envModeItems = useMemo(
...@@ -147,9 +151,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -147,9 +151,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
147 : (workspaceDisplayName ??151 : (workspaceDisplayName ??
148 resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode))}152 resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode))}
149 </ComposerContextLabel>153 </ComposerContextLabel>
150 {displayMode === "panel" ? (154 {displayMode === "panel" && lockedWorkspaceKind ? (
151 <span className="shrink-0 text-3xs font-normal text-muted-foreground/70">155 <span className="shrink-0 text-3xs font-normal text-muted-foreground/70">
152 {forceNewWorktree ? "Worktree" : workspaceKind}156 {lockedWorkspaceKind}
153 </span>157 </span>
154 ) : null}158 ) : null}
155 </span>159 </span>
...@@ -210,9 +214,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -210,9 +214,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
210 <ComposerContextLabel displayMode={displayMode}>214 <ComposerContextLabel displayMode={displayMode}>
211 <SelectValue />215 <SelectValue />
212 </ComposerContextLabel>216 </ComposerContextLabel>
213 {displayMode === "panel" ? (217 {displayMode === "panel" && selectWorkspaceKind ? (
214 <span className="shrink-0 text-3xs font-normal text-muted-foreground/70">218 <span className="shrink-0 text-3xs font-normal text-muted-foreground/70">
215 {effectiveEnvMode === "worktree" && !activeWorktreePath ? "Create" : workspaceKind}219 {selectWorkspaceKind}
216 </span>220 </span>
217 ) : null}221 ) : null}
218 </TooltipTrigger>222 </TooltipTrigger>
apps/web/src/components/GitActionsControl.tsx+1-1
...@@ -1880,7 +1880,7 @@ export default function GitActionsControl({...@@ -1880,7 +1880,7 @@ export default function GitActionsControl({
1880 // the output row expands below.1880 // the output row expands below.
1881 <GitActionElapsedTime1881 <GitActionElapsedTime
1882 startedAtMs={gitActionProgress.startedAtMs}1882 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"
1884 />1884 />
1885 ) : (1885 ) : (
1886 <>1886 <>
apps/web/src/components/chat/ChatCanvas.tsx+5-1
...@@ -11,7 +11,10 @@ import { ChatCanvasContext } from "./ChatCanvasContext";...@@ -11,7 +11,10 @@ import { ChatCanvasContext } from "./ChatCanvasContext";
11import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout";11import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout";
12import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout";12import 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 */
15export function ChatCanvas({18export function ChatCanvas({
16 composerOverlayElement,19 composerOverlayElement,
17 children,20 children,
...@@ -96,6 +99,7 @@ export function ChatCanvas({...@@ -96,6 +99,7 @@ export function ChatCanvas({
96 const container = { width: measurements.width, height: measurements.height };99 const container = { width: measurements.width, height: measurements.height };
97 return {100 return {
98 container,101 container,
102 lane: { padding: measurements.padding, minChatWidth: measurements.minChatWidth },
99 layout: resolveChatCanvasLayout({ ...measurements, container, preview, detailsCard }),103 layout: resolveChatCanvasLayout({ ...measurements, container, preview, detailsCard }),
100 previewKey: preview?.key ?? null,104 previewKey: preview?.key ?? null,
101 reportPreview,105 reportPreview,
apps/web/src/components/chat/ChatCanvasContext.ts+1
...@@ -4,6 +4,7 @@ import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLay...@@ -4,6 +4,7 @@ import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLay
44
5export const ChatCanvasContext = createContext<{5export const ChatCanvasContext = createContext<{
6 container: { width: number; height: number };6 container: { width: number; height: number };
7 lane: { padding: number; minChatWidth: number };
7 layout: ReturnType<typeof resolveChatCanvasLayout>;8 layout: ReturnType<typeof resolveChatCanvasLayout>;
8 previewKey: string | null;9 previewKey: string | null;
9 reportPreview: (preview: ChatCanvasPreview) => void;10 reportPreview: (preview: ChatCanvasPreview) => void;
apps/web/src/components/chat/ThreadDetailsCard.tsx+7-2
...@@ -29,12 +29,17 @@ export function ThreadDetailsCard({...@@ -29,12 +29,17 @@ export function ThreadDetailsCard({
29 const preferredPlacement = canvas29 const preferredPlacement = canvas
30 ? resolveThreadDetailsCardLayout({30 ? resolveThreadDetailsCardLayout({
31 container: canvas.container,31 container: canvas.container,
32 chat: canvas.layout.chat,32 lane: canvas.lane,
33 frame: null,33 frame: null,
34 })34 })
35 : null;35 : null;
36 const placement = canvas36 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 })
38 : null;43 : null;
39 const mode = placement ? "inline" : "popover";44 const mode = placement ? "inline" : "popover";
40 const inlineOpen = useRightPanelStore((state) =>45 const inlineOpen = useRightPanelStore((state) =>
apps/web/src/components/chat/ThreadDetailsControl.tsx+1-1
...@@ -56,7 +56,7 @@ export function ThreadDetailsControl({...@@ -56,7 +56,7 @@ export function ThreadDetailsControl({
56 className: cn(56 className: cn(
57 "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",57 "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",
58 parts[part],58 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",
60 tone === "muted" && "text-muted-foreground/70 hover:text-foreground/80",60 tone === "muted" && "text-muted-foreground/70 hover:text-foreground/80",
61 tone === "destructive" &&61 tone === "destructive" &&
62 "text-destructive hover:text-destructive data-pressed:text-destructive",62 "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) {...@@ -126,7 +126,7 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
126 headingId="thread-details-workspace-heading"126 headingId="thread-details-workspace-heading"
127 title="Workspace"127 title="Workspace"
128 separated={false}128 separated={false}
129 showHeading={density === "full"}129 showHeading={false}
130 >130 >
131 {props.versionMismatch ? (131 {props.versionMismatch ? (
132 <div className="mx-1 mb-2 flex gap-2 rounded-xl border border-warning/30 bg-warning/6 p-3">132 <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) {...@@ -198,7 +198,7 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
198 <ThreadDetailsSection198 <ThreadDetailsSection
199 headingId="thread-details-version-control-heading"199 headingId="thread-details-version-control-heading"
200 title="Version Control"200 title="Version Control"
201 showHeading={density === "full"}201 showHeading={false}
202 separated={density === "full"}202 separated={density === "full"}
203 >203 >
204 <div className="flex flex-col">204 <div className="flex flex-col">
apps/web/src/components/chat/ThreadRelationshipsControl.tsx+2-2
...@@ -97,7 +97,7 @@ export function ThreadLineageRowList(props: {...@@ -97,7 +97,7 @@ export function ThreadLineageRowList(props: {
97 <button97 <button
98 type="button"98 type="button"
99 onClick={props.onShowMore}99 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]`}
101 >101 >
102 <PlusIcon aria-hidden className="size-4 shrink-0" />102 <PlusIcon aria-hidden className="size-4 shrink-0" />
103 Show {Math.min(props.hiddenCount, THREAD_LINEAGE_PAGE_COUNT)} more103 Show {Math.min(props.hiddenCount, THREAD_LINEAGE_PAGE_COUNT)} more
...@@ -394,7 +394,7 @@ export function ThreadRelationshipsPanel(props: {...@@ -394,7 +394,7 @@ export function ThreadRelationshipsPanel(props: {
394 </>394 </>
395 );395 );
396 return (396 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">
398 {isMergeTarget ? (398 {isMergeTarget ? (
399 <div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}>399 <div className={THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS}>
400 <Tooltip>400 <Tooltip>
apps/web/src/components/chat/chatCanvasLayout.test.ts+45-1
...@@ -212,7 +212,12 @@ describe("chat canvas layout", () => {...@@ -212,7 +212,12 @@ describe("chat canvas layout", () => {
212 });212 });
213 };213 };
214 const density = (result: ReturnType<typeof layout>) => {214 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 });
216 return card221 return card
217 ? resolveThreadDetailsCardDensity(card.height, { full: 327, compact: 182 })222 ? resolveThreadDetailsCardDensity(card.height, { full: 327, compact: 182 })
218 : "hidden";223 : "hidden";
...@@ -251,3 +256,42 @@ describe("chat canvas layout", () => {...@@ -251,3 +256,42 @@ describe("chat canvas layout", () => {
251 expect(result.frame).toEqual({ x: 272, y: 396, width: 1300, height: 400 });256 expect(result.frame).toEqual({ x: 272, y: 396, width: 1300, height: 400 });
252 });257 });
253});258});
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 {...@@ -19,8 +19,11 @@ export interface ChatCanvasPreview {
19}19}
2020
21const GAP = 12;21const 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. */
24export function resolveChatCanvasLayout({27export function resolveChatCanvasLayout({
25 container,28 container,
26 preview,29 preview,
...@@ -38,9 +41,24 @@ export function resolveChatCanvasLayout({...@@ -38,9 +41,24 @@ export function resolveChatCanvasLayout({
38 composerHeight?: number;41 composerHeight?: number;
39 detailsCard?: PreviewMiniPlayerObstacles["detailsCard"];42 detailsCard?: PreviewMiniPlayerObstacles["detailsCard"];
40}) {43}) {
41 const normalWidth = Math.max(0, Math.min(maxChatWidth, container.width - padding * 2));44 const centeredWidth = Math.max(0, Math.min(maxChatWidth, container.width - padding * 2));
42 const normalLeft = (container.width - normalWidth) / 2;45 // A workspace card that does not fit beside the centered chat first moves
43 let chat = { left: normalLeft, width: normalWidth, insetStart: 0, insetEnd: 0 };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 };
44 let frame: PreviewMiniPlayerFrame | null = null;62 let frame: PreviewMiniPlayerFrame | null = null;
45 let overlapsChat = false;63 let overlapsChat = false;
46 if (preview && container.width > 0 && container.height > 0) {64 if (preview && container.width > 0 && container.height > 0) {
apps/web/src/components/chat/threadDetailsCardLayout.test.ts+15-13
...@@ -4,37 +4,39 @@ import {...@@ -4,37 +4,39 @@ import {
4 resolveThreadDetailsCardLayout,4 resolveThreadDetailsCardLayout,
5} from "./threadDetailsCardLayout";5} from "./threadDetailsCardLayout";
66
7const lane = { padding: 20, minChatWidth: 640 };
7const resolve = (width: number, height: number, previewY: number | null = null) =>8const resolve = (width: number, height: number, previewY: number | null = null) =>
8 resolveThreadDetailsCardLayout({9 resolveThreadDetailsCardLayout({
9 container: { width, height },10 container: { width, height },
10 chat: { left: (width - 736) / 2, width: 736 },11 lane,
11 frame: previewY === null ? null : { x: width - 332, y: previewY, width: 320, height: 240 },12 frame: previewY === null ? null : { x: width - 332, y: previewY, width: 320, height: 240 },
12 });13 });
1314
14describe("floating details card", () => {15describe("workspace card", () => {
15 it("uses the right margin without reserving chat space", () => {16 it("pins to the top right at a fixed width", () => {
16 expect(resolve(1600, 900)).toEqual({17 expect(resolve(1600, 900)).toEqual({
17 x: 1276,18 x: 1308,
18 y: 12,19 y: 12,
19 width: 312,20 width: 280,
20 height: 876,21 height: 876,
21 });22 });
22 expect(resolve(1344, 900)?.width).toBe(280);23 expect(resolve(1344, 900)).toMatchObject({ x: 1052, width: 280 });
23 });24 });
24 it("hides when the margin cannot hold readable controls", () => {25 it("hides when a readable chat lane cannot fit beside it", () => {
25 expect(resolve(1200, 900)).toBeNull();26 expect(resolve(984, 900)).toMatchObject({ x: 692 });
27 expect(resolve(983, 900)).toBeNull();
26 });28 });
27 it("keeps the card at the top right while the preview is freely dragged vertically", () => {29 it("keeps the card at the top right while the preview is freely dragged vertically", () => {
28 for (const y of [12, 170, 250, 400, 648]) {30 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 });
30 }32 }
31 });33 });
32 it("keeps width and height independent", () => {34 it("keeps full height while a preview stays clear of the card", () => {
33 expect(resolve(1344, 900, 600)).toMatchObject({ width: 280, height: 876 });35 expect(resolve(1344, 900, 600)).toMatchObject({ width: 280, height: 876 });
34 expect(36 expect(
35 resolveThreadDetailsCardLayout({37 resolveThreadDetailsCardLayout({
36 container: { width: 1600, height: 900 },38 container: { width: 1600, height: 900 },
37 chat: { left: 432, width: 736 },39 lane,
38 frame: { x: 12, y: 100, width: 320, height: 240 },40 frame: { x: 12, y: 100, width: 320, height: 240 },
39 })?.height,41 })?.height,
40 ).toBe(876);42 ).toBe(876);
...@@ -45,14 +47,14 @@ describe("card content fitting", () => {...@@ -45,14 +47,14 @@ describe("card content fitting", () => {
45 const place = (previewY: number, previewHeight = 365, overlapsDetailsCard = false) =>47 const place = (previewY: number, previewHeight = 365, overlapsDetailsCard = false) =>
46 resolveThreadDetailsCardLayout({48 resolveThreadDetailsCardLayout({
47 container: { width: 1584, height: 988 },49 container: { width: 1584, height: 988 },
48 chat: { left: 424, width: 736 },50 lane,
49 frame: { x: 1260, y: previewY, width: 240, height: previewHeight },51 frame: { x: 1260, y: previewY, width: 240, height: previewHeight },
50 overlapsDetailsCard,52 overlapsDetailsCard,
51 });53 });
52 it("does not fold in response to a drag while the full card can be kept clear", () => {54 it("does not fold in response to a drag while the full card can be kept clear", () => {
53 for (const y of [12, 170, 225, 240, 340, 380, 611]) {55 for (const y of [12, 170, 225, 240, 340, 380, 611]) {
54 const placement = place(y)!;56 const placement = place(y)!;
55 expect(placement).toMatchObject({ x: 1260, y: 12, height: 964 });57 expect(placement).toMatchObject({ x: 1292, y: 12, height: 964 });
56 expect(resolveThreadDetailsCardDensity(placement.height, { full: 327, compact: 182 })).toBe(58 expect(resolveThreadDetailsCardDensity(placement.height, { full: 327, compact: 182 })).toBe(
57 "full",59 "full",
58 );60 );
apps/web/src/components/chat/threadDetailsCardLayout.ts+10-5
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { PreviewMiniPlayerFrame } from "../preview/previewMiniPlayerLayout";1import type { PreviewMiniPlayerFrame } from "../preview/previewMiniPlayerLayout";
2import { DETAILS_CARD_CLEARANCE } from "./chatCanvasLayout";
23
3export function resolveThreadDetailsCardDensity(4export function resolveThreadDetailsCardDensity(
4 height: number,5 height: number,
...@@ -9,22 +10,26 @@ export function resolveThreadDetailsCardDensity(...@@ -9,22 +10,26 @@ export function resolveThreadDetailsCardDensity(
9 return "essential";10 return "essential";
10}11}
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 */
13export function resolveThreadDetailsCardLayout({17export function resolveThreadDetailsCardLayout({
14 container,18 container,
15 chat,19 lane,
16 frame,20 frame,
17 overlapsDetailsCard = false,21 overlapsDetailsCard = false,
18}: {22}: {
19 container: { width: number; height: number };23 container: { width: number; height: number };
20 chat: { left: number; width: number };24 lane: { padding: number; minChatWidth: number };
21 frame: PreviewMiniPlayerFrame | null;25 frame: PreviewMiniPlayerFrame | null;
22 overlapsDetailsCard?: boolean;26 overlapsDetailsCard?: boolean;
23}) {27}) {
24 const gap = 12;28 const gap = 12;
25 const width = Math.min(312, container.width - chat.left - chat.width - gap * 2);29 // Keep in sync with --thread-details-panel-width, which sizes the popover.
26 if (width < 240) return null;30 const width = 280;
27 const x = container.width - width - gap;31 const x = container.width - width - gap;
32 if (x - DETAILS_CARD_CLEARANCE - lane.padding < lane.minChatWidth) return null;
28 // Resizing consumes the height above the player. Dragging first tries to33 // Resizing consumes the height above the player. Dragging first tries to
29 // clear the full card and folds it only when there is no readable placement.34 // clear the full card and folds it only when there is no readable placement.
30 const height =35 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"...@@ -15,7 +15,7 @@ export const THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS = "gap-2.5 px-2.5 text-left"
15// The row supplies the first tint; the hovered or open segment adds a second tint.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]`;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]`;
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`;
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]`;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]`;
2020
21export const THREAD_DETAILS_PANEL_ROW_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} w-full justify-start ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`;21export 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...@@ -32,7 +32,7 @@ export const THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS = `${THREAD_DETAILS_P
32/** The trailing half of a link split row when it carries a word ("Merge") rather than an icon. */32/** The trailing half of a link split row when it carries a word ("Merge") rather than an icon. */
33export 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}`;33export 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
37export const THREAD_DETAILS_PANEL_ICON_CLASS = "size-4 shrink-0 text-muted-foreground";37export 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...@@ -117,7 +117,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
117 --workspace-titlebar-control-gap: 0.75rem;117 --workspace-titlebar-control-gap: 0.75rem;
118 --workspace-titlebar-scroll-fade-height: 1.5rem;118 --workspace-titlebar-scroll-fade-height: 1.5rem;
119 --chat-content-max-width: 46rem;119 --chat-content-max-width: 46rem;
120 --thread-details-panel-width: 19.5rem;120 --thread-details-panel-width: 17.5rem;
121 /* The prompt on phones: iOS zooms into any input under 16px on focus. */121 /* The prompt on phones: iOS zooms into any input under 16px on focus. */
122 --font-size-prompt-touch: max(var(--font-size-prompt, 1rem), 16px);122 --font-size-prompt-touch: max(var(--font-size-prompt, 1rem), 16px);
123 /* Horizontal gutter shared by the page header and the chat column, clear of123 /* Horizontal gutter shared by the page header and the chat column, clear of