| author | |
| committer | |
| log | 429c625a8515f9c2e4af28c488fbf32ce7789645 |
| tree | 541399e3f79e605994c29f4681fad6496541c42e |
| parent | 0cfa113be597af3a1b2fe905a58a5b583f8089b8 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | <GitActionElapsedTime | 1881 | <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"; |
| 11 | import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; | 11 | import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; |
| 12 | import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout"; | 12 | import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout"; |
| 13 | 13 | ||
| 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 | */ | ||
| 15 | export function ChatCanvas({ | 18 | export 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 |
| 4 | 4 | ||
| 5 | export const ChatCanvasContext = createContext<{ | 5 | export 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 = canvas | 29 | 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 = canvas | 36 | 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 | <ThreadDetailsSection | 198 | <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 | <button | 97 | <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)} more | 103 | 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 card | 221 | 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 | |||
| 260 | describe("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 | } |
| 20 | 20 | ||
| 21 | const GAP = 12; | 21 | const 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. | ||
| 24 | export const DETAILS_CARD_CLEARANCE = 32; | ||
| 22 | 25 | ||
| 23 | /** Pure geometry shared by the conversation, composer, and floating preview. */ | 26 | /** Pure geometry shared by the conversation, composer, workspace card, and floating preview. */ |
| 24 | export function resolveChatCanvasLayout({ | 27 | export 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"; |
| 6 | 6 | ||
| 7 | const lane = { padding: 20, minChatWidth: 640 }; | ||
| 7 | const resolve = (width: number, height: number, previewY: number | null = null) => | 8 | const 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 | }); |
| 13 | 14 | ||
| 14 | describe("floating details card", () => { | 15 | describe("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 @@ |
| 1 | import type { PreviewMiniPlayerFrame } from "../preview/previewMiniPlayerLayout"; | 1 | import type { PreviewMiniPlayerFrame } from "../preview/previewMiniPlayerLayout"; |
| 2 | import { DETAILS_CARD_CLEARANCE } from "./chatCanvasLayout"; | ||
| 2 | 3 | ||
| 3 | export function resolveThreadDetailsCardDensity( | 4 | export 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 | } |
| 11 | 12 | ||
| 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 | */ | ||
| 13 | export function resolveThreadDetailsCardLayout({ | 17 | export 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 to | 33 | // 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. |
| 16 | const 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]`; | 16 | const 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 | 17 | ||
| 18 | const 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`; | 18 | const 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`; |
| 19 | const 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]`; | 19 | const 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]`; |
| 20 | 20 | ||
| 21 | export const THREAD_DETAILS_PANEL_ROW_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} w-full justify-start ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`; | 21 | export 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. */ |
| 33 | export 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}`; | 33 | export 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}`; |
| 34 | 34 | ||
| 35 | export 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]`; | 35 | export 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]`; |
| 36 | 36 | ||
| 37 | export const THREAD_DETAILS_PANEL_ICON_CLASS = "size-4 shrink-0 text-muted-foreground"; | 37 | export const THREAD_DETAILS_PANEL_ICON_CLASS = "size-4 shrink-0 text-muted-foreground"; |
| 38 | 38 |
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 of | 123 | /* Horizontal gutter shared by the page header and the chat column, clear of |