@spunto/design-system 0.27.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +35 -1
  2. package/package.json +6 -1
  3. package/src/components/sheet.tsx +7 -1
  4. package/src/components/tasks/agent-markdown.tsx +322 -0
  5. package/src/components/tasks/attachments.ts +152 -0
  6. package/src/components/tasks/command-log.tsx +102 -0
  7. package/src/components/tasks/composer.tsx +252 -0
  8. package/src/components/tasks/conversation-list.tsx +238 -0
  9. package/src/components/tasks/diff-panel.tsx +534 -0
  10. package/src/components/tasks/event-stream.tsx +860 -0
  11. package/src/components/tasks/index.ts +111 -0
  12. package/src/components/tasks/machine.tsx +455 -0
  13. package/src/components/tasks/new-task-dialog.tsx +301 -0
  14. package/src/components/tasks/pull-request.tsx +179 -0
  15. package/src/components/tasks/session-usage.tsx +165 -0
  16. package/src/components/tasks/task-attachments.tsx +475 -0
  17. package/src/components/tasks/task-cockpit-mobile.tsx +272 -0
  18. package/src/components/tasks/task-cockpit.tsx +225 -0
  19. package/src/components/tasks/task-details.tsx +239 -0
  20. package/src/components/tasks/task-list.tsx +272 -0
  21. package/src/components/tasks/task-state.tsx +180 -0
  22. package/src/components/tasks/tasks.test.tsx +205 -0
  23. package/src/components/tasks/tool-stack.tsx +692 -0
  24. package/src/components/tasks/types.ts +220 -0
  25. package/src/components/tasks/worker-link.tsx +126 -0
  26. package/src/components/workers/index.ts +5 -2
  27. package/src/components/workers/types.ts +22 -0
  28. package/src/components/workers/worker-cockpit.tsx +17 -2
  29. package/src/components/workers/worker-status-history.test.ts +85 -0
  30. package/src/components/workers/worker-status-history.tsx +166 -0
  31. package/src/index.ts +1 -1
  32. package/src/utils.ts +23 -0
@@ -0,0 +1,272 @@
1
+ "use client"
2
+
3
+ // A conversation on a phone: **its own screen**, not the desktop cockpit squeezed.
4
+ //
5
+ // Narrowed to 390 px, the two-column cockpit becomes a 150 px-tall stream with a reply box wedged
6
+ // in the middle of a scrolling page. The shape here is the one every messaging app converged on:
7
+ //
8
+ // - **The screen is the conversation.** A fixed layer sized to `visualViewport`, so the page never
9
+ // scrolls under you and the reply box stays glued above the keyboard instead of being pushed
10
+ // off-screen by it. The host's own chrome goes away with it.
11
+ // - **One primary action in the nav bar.** Accept, when there is something to accept. Everything
12
+ // that is a *fact about* the task is one tap away in a sheet (`details`, typically a
13
+ // `TaskDetails variant="sheet"`), because a phone has room for the work or the metadata.
14
+ // - **Drop lives in that sheet**, not next to Accept.
15
+ //
16
+ // Same slots as `TaskCockpit`, so an app switches between the two on its own "is this a phone?"
17
+ // signal and hands both the same pieces (the composer with `layout="compact"`).
18
+
19
+ import { useEffect, useState, type ReactNode } from "react"
20
+ import { createPortal } from "react-dom"
21
+ import { CheckCircle2Icon, ChevronLeftIcon, Loader2Icon, SlidersHorizontalIcon, XIcon } from "lucide-react"
22
+
23
+ import { cn, formatRelativeTime } from "../../utils"
24
+ import { useOverlayContainer } from "../spunto-provider"
25
+ import type { LinkRender } from "../workers/worker-card"
26
+ import { TaskPanelTabs, useCockpitView, type TaskCockpitProps } from "./task-cockpit"
27
+ import { TaskDetailsSheetContext } from "./task-details"
28
+ import { TaskStateIcon, taskPendingActionConfig, taskStateStyle, useElapsed } from "./task-state"
29
+ import type { TaskItem, TaskPendingAction } from "./types"
30
+
31
+ export interface TaskCockpitMobileProps extends Omit<TaskCockpitProps, "className"> {
32
+ /** Back, when there is no URL to go back *to* (a list on the same route). Wins over `backHref`. */
33
+ onBack?: () => void
34
+ /**
35
+ * `true` (default): portalled full-screen layer above the page, sized to the visual viewport.
36
+ * `false`: fills its parent instead — for a preview inside a phone frame, or a host that is
37
+ * already full-screen.
38
+ */
39
+ fullscreen?: boolean
40
+ className?: string
41
+ }
42
+
43
+ export interface VisualViewportState {
44
+ /** Height of the visible area, i.e. screen minus the on-screen keyboard (px). */
45
+ height: number
46
+ /** Offset of the visual viewport inside the layout viewport (px). */
47
+ offsetTop: number
48
+ /** Estimated height the soft keyboard is occupying (0 when closed). */
49
+ keyboard: number
50
+ }
51
+
52
+ /**
53
+ * Tracks `window.visualViewport`, so a fixed full-screen surface can sit exactly above the
54
+ * on-screen keyboard. On iOS Safari the keyboard does *not* change `innerHeight`; only
55
+ * `visualViewport.height` shrinks — so size off it and translate by its `offsetTop`.
56
+ */
57
+ export function useVisualViewport(enabled = true): VisualViewportState {
58
+ const [state, setState] = useState<VisualViewportState>({ height: 0, offsetTop: 0, keyboard: 0 })
59
+
60
+ useEffect(() => {
61
+ if (!enabled) return
62
+ const vv = window.visualViewport
63
+ if (!vv) {
64
+ const fallback = () => setState({ height: window.innerHeight, offsetTop: 0, keyboard: 0 })
65
+ fallback()
66
+ window.addEventListener("resize", fallback)
67
+ return () => window.removeEventListener("resize", fallback)
68
+ }
69
+ let raf = 0
70
+ const update = () => {
71
+ cancelAnimationFrame(raf)
72
+ raf = requestAnimationFrame(() => {
73
+ const keyboard = Math.max(0, window.innerHeight - vv.height - vv.offsetTop)
74
+ setState({ height: vv.height, offsetTop: vv.offsetTop, keyboard })
75
+ })
76
+ }
77
+ update()
78
+ vv.addEventListener("resize", update)
79
+ vv.addEventListener("scroll", update)
80
+ return () => {
81
+ cancelAnimationFrame(raf)
82
+ vv.removeEventListener("resize", update)
83
+ vv.removeEventListener("scroll", update)
84
+ }
85
+ }, [enabled])
86
+
87
+ return state
88
+ }
89
+
90
+ const defaultLink: LinkRender = ({ href, className, children }) => (
91
+ <a href={href} className={className}>
92
+ {children}
93
+ </a>
94
+ )
95
+
96
+ const NAV_BUTTON =
97
+ "flex h-11 w-11 shrink-0 touch-manipulation items-center justify-center rounded-xl text-muted-foreground transition-colors active:bg-muted"
98
+
99
+ /**
100
+ * The one line under the title: where the task stands, or — while something is in flight — what
101
+ * it is doing and for how long. A phone shows one status, so it shows the one about to change.
102
+ */
103
+ function NavStatus({ task, action, since }: { task: TaskItem; action: TaskPendingAction | null; since: string | null }) {
104
+ const elapsed = useElapsed(action ? since : null)
105
+ if (action) {
106
+ const style = taskPendingActionConfig[action]
107
+ return (
108
+ <p className={cn("flex items-center gap-1 text-[11px] leading-tight", style.textClass)}>
109
+ <Loader2Icon className="h-3 w-3 animate-spin" />
110
+ {style.label}…{elapsed && <span className="tabular-nums opacity-70">{elapsed}</span>}
111
+ </p>
112
+ )
113
+ }
114
+ const style = taskStateStyle(task.state)
115
+ return (
116
+ <p className="flex items-center gap-1 text-[11px] leading-tight text-muted-foreground">
117
+ <span className={style.textClass}>
118
+ <TaskStateIcon state={task.state} />
119
+ </span>
120
+ {style.label}
121
+ <span aria-hidden>·</span>
122
+ {formatRelativeTime(task.lastActivityAt ?? task.createdAt)}
123
+ </p>
124
+ )
125
+ }
126
+
127
+ /**
128
+ * The bottom sheet holding `details`. Hand-rolled rather than a portalled drawer: this screen is
129
+ * itself a layer at `z-[120]`, and a drawer portalled to the page at `z-50` would open *behind*
130
+ * the conversation. A sheet that is a child of the layer it belongs to has no such argument.
131
+ */
132
+ function DetailsSheet({ open, onClose, children }: { open: boolean; onClose: () => void; children: ReactNode }) {
133
+ useEffect(() => {
134
+ if (!open) return
135
+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose()
136
+ window.addEventListener("keydown", onKey)
137
+ return () => window.removeEventListener("keydown", onKey)
138
+ }, [open, onClose])
139
+
140
+ if (!open) return null
141
+ return (
142
+ <div className="absolute inset-0 z-10 flex flex-col justify-end">
143
+ <button type="button" aria-label="Close details" onClick={onClose} className="absolute inset-0 bg-black/40" />
144
+ <div role="dialog" aria-label="Details" className="relative flex max-h-[85%] flex-col overflow-hidden rounded-t-2xl border-t bg-background">
145
+ <div className="flex shrink-0 items-center gap-2 border-b px-3 py-2">
146
+ <span className="absolute top-1.5 left-1/2 h-1 w-10 -translate-x-1/2 rounded-full bg-muted-foreground/25" />
147
+ <h2 className="min-w-0 flex-1 truncate pt-1 text-sm font-semibold">Details</h2>
148
+ <button type="button" onClick={onClose} aria-label="Close details" className={NAV_BUTTON}>
149
+ <XIcon className="h-5 w-5" />
150
+ </button>
151
+ </div>
152
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[max(1rem,env(safe-area-inset-bottom))]">
153
+ <TaskDetailsSheetContext.Provider value={{ close: onClose }}>{children}</TaskDetailsSheetContext.Provider>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ )
158
+ }
159
+
160
+ export function TaskCockpitMobile({
161
+ task,
162
+ loading = false,
163
+ pendingAction,
164
+ pendingSince,
165
+ onAccept,
166
+ backHref,
167
+ onBack,
168
+ render,
169
+ view: viewProp,
170
+ onViewChange,
171
+ session,
172
+ diff,
173
+ composer,
174
+ details,
175
+ fullscreen = true,
176
+ className,
177
+ }: TaskCockpitMobileProps) {
178
+ const vp = useVisualViewport(fullscreen)
179
+ const container = useOverlayContainer()
180
+ const [detailsOpen, setDetailsOpen] = useState(false)
181
+ const [view, setView] = useCockpitView(viewProp, onViewChange)
182
+ const Link = render?.link ?? defaultLink
183
+
184
+ const action = task ? (pendingAction !== undefined ? pendingAction : (task.pendingAction ?? null)) : null
185
+ const since = task ? (pendingSince !== undefined ? pendingSince : (task.pendingSince ?? null)) : null
186
+ const tabs = diff ? <TaskPanelTabs view={view} onChange={setView} /> : null
187
+
188
+ const back = onBack ? (
189
+ <button type="button" onClick={onBack} aria-label="Back" className={NAV_BUTTON}>
190
+ <ChevronLeftIcon className="h-6 w-6" />
191
+ </button>
192
+ ) : backHref ? (
193
+ <Link href={backHref} className={NAV_BUTTON}>
194
+ <ChevronLeftIcon aria-label="Back" className="h-6 w-6" />
195
+ </Link>
196
+ ) : null
197
+
198
+ const screen = (
199
+ <div
200
+ className={cn(
201
+ "flex flex-col overflow-hidden overscroll-none bg-background",
202
+ fullscreen ? "fixed inset-x-0 top-0 z-[120]" : "relative h-full w-full",
203
+ className,
204
+ )}
205
+ style={
206
+ fullscreen
207
+ ? {
208
+ // The keyboard does not shrink `innerHeight` on iOS — only the visual viewport does.
209
+ height: vp.height ? `${vp.height}px` : "100dvh",
210
+ transform: vp.offsetTop ? `translateY(${vp.offsetTop}px)` : undefined,
211
+ }
212
+ : undefined
213
+ }
214
+ >
215
+ {/* ── Nav bar ── */}
216
+ <div
217
+ className="flex shrink-0 items-center gap-1 border-b border-border bg-card/60 px-1 pb-1.5 backdrop-blur-sm"
218
+ style={{ paddingTop: fullscreen ? "max(0.375rem, env(safe-area-inset-top))" : "0.375rem" }}
219
+ >
220
+ {back}
221
+ <div className="min-w-0 flex-1 pl-1">
222
+ <p className="truncate text-sm leading-tight font-semibold">{task?.title ?? "Conversation"}</p>
223
+ {task && <NavStatus task={task} action={action} since={since} />}
224
+ </div>
225
+
226
+ {task?.state === "in-review" && onAccept && (
227
+ <button
228
+ type="button"
229
+ disabled={action !== null}
230
+ onClick={onAccept}
231
+ className="flex h-9 shrink-0 touch-manipulation items-center gap-1.5 rounded-full bg-primary px-3.5 text-sm font-medium text-primary-foreground transition-transform active:scale-95 disabled:opacity-50"
232
+ >
233
+ {action === "accepting" ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <CheckCircle2Icon className="h-4 w-4" />}
234
+ {action === "accepting" ? "Accepting…" : "Accept"}
235
+ </button>
236
+ )}
237
+
238
+ {details && task && (
239
+ <button type="button" onClick={() => setDetailsOpen(true)} aria-label="Details" className={NAV_BUTTON}>
240
+ <SlidersHorizontalIcon className="h-5 w-5" />
241
+ </button>
242
+ )}
243
+ </div>
244
+
245
+ {/* ── The conversation ── */}
246
+ {!task ? (
247
+ loading ? (
248
+ <p className="p-6 text-sm text-muted-foreground">Loading…</p>
249
+ ) : (
250
+ <p className="p-6 text-sm">This conversation does not exist, or it belongs to another project.</p>
251
+ )
252
+ ) : (
253
+ <>
254
+ {view === "diff" && diff ? diff(tabs) : session(tabs)}
255
+ {composer && <div style={{ paddingBottom: fullscreen && vp.keyboard === 0 ? "env(safe-area-inset-bottom)" : 0 }}>{composer}</div>}
256
+ </>
257
+ )}
258
+
259
+ {details && (
260
+ <DetailsSheet open={detailsOpen} onClose={() => setDetailsOpen(false)}>
261
+ {details}
262
+ </DetailsSheet>
263
+ )}
264
+ </div>
265
+ )
266
+
267
+ // Portalled: the screen must escape the host's scroll container and stacking context. Into the
268
+ // SpuntoProvider's overlay container when there is one, so the theme cascades.
269
+ if (!fullscreen) return screen
270
+ if (typeof document === "undefined") return null
271
+ return createPortal(screen, container ?? document.body)
272
+ }
@@ -0,0 +1,225 @@
1
+ "use client"
2
+
3
+ // A task's cockpit: everything about one conversation, filling whatever box it is given.
4
+ //
5
+ // The row in a list answers "what is waiting for me?". This answers "what is *happening*?" — which
6
+ // wants a different shape: the session is not one section among several, it is the thing, and it
7
+ // takes the room. Everything that is a *fact about* the task moves to the side (`details`), where
8
+ // it can be glanced at without pushing the work off screen.
9
+ //
10
+ // A layout, not a data layer. Same split as `WorkerCockpit`: the panels that read the API are
11
+ // slots the app fills with the package's own components —
12
+ //
13
+ // session → `TaskEventStream` diff → `TaskDiffPanel`
14
+ // composer → `TaskComposer` details → `TaskDetails` (with a `TaskMachine` inside)
15
+ //
16
+ // — and the cockpit owns the chrome between them: the top bar and its two actions, the
17
+ // Session/Diff switch, and where each slot goes as the box narrows. `session` and `diff` are
18
+ // render functions because the switch is drawn *inside* their header strip (two strips of chrome
19
+ // above a transcript is one too many).
20
+ //
21
+ // It claims no page chrome of its own — it fills its parent — which is what lets the same code
22
+ // live at its own URL and on the right-hand side of the org-wide conversation list. On a phone,
23
+ // mount `TaskCockpitMobile` instead: a different screen, not this one squeezed.
24
+
25
+ import { useState, type ReactNode } from "react"
26
+ import { ArrowLeftIcon, CheckCircle2Icon, Loader2Icon, XCircleIcon } from "lucide-react"
27
+
28
+ import { cn } from "../../utils"
29
+ import { Button } from "../button"
30
+ import { buttonVariants } from "../button-variants"
31
+ import type { LinkRender } from "../workers/worker-card"
32
+ import { TaskStateBadge, isLiveTaskState } from "./task-state"
33
+ import type { TaskItem, TaskPendingAction } from "./types"
34
+
35
+ export type TaskCockpitView = "session" | "diff"
36
+
37
+ export interface TaskCockpitProps {
38
+ /** `undefined`/`null` → the loading line, or the not-found message once `loading` is false. */
39
+ task: TaskItem | null | undefined
40
+ loading?: boolean
41
+
42
+ /** Defaults to `task.pendingAction` — pass the app's prediction to react on the click's frame. */
43
+ pendingAction?: TaskPendingAction | null
44
+ pendingSince?: string | null
45
+ /** Accept, on a task in review. Omitted → no button. */
46
+ onAccept?: () => void
47
+ /** Drop, on any live task. Omitted → no button. */
48
+ onDrop?: () => void
49
+
50
+ /** Back arrow in the top bar. Omitted when the cockpit sits next to its own list. */
51
+ backHref?: string
52
+ render?: { link?: LinkRender }
53
+
54
+ /** Controlled Session/Diff switch. Uncontrolled (starting on `session`) when omitted. */
55
+ view?: TaskCockpitView
56
+ onViewChange?: (view: TaskCockpitView) => void
57
+
58
+ /** The session panel. Receives the Session/Diff switch to draw in its header. */
59
+ session: (tabs: ReactNode) => ReactNode
60
+ /** The diff panel. Omitted → no switch, the session alone. */
61
+ diff?: (tabs: ReactNode) => ReactNode
62
+ /** Under both panels: answering the agent is something a reviewer does *because* of the diff. */
63
+ composer?: ReactNode
64
+ /** The side column. */
65
+ details?: ReactNode
66
+ className?: string
67
+ }
68
+
69
+ const defaultLink: LinkRender = ({ href, className, children }) => (
70
+ <a href={href} className={className}>
71
+ {children}
72
+ </a>
73
+ )
74
+
75
+ /**
76
+ * The two ways of looking at a task, in the strip each panel already owns. A tab rather than a
77
+ * second column: "what it did" and "what is different now" are two frames of mind, and splitting
78
+ * the width would make both unreadable in a sidebar-sized column.
79
+ */
80
+ export function TaskPanelTabs({
81
+ view,
82
+ onChange,
83
+ className,
84
+ }: {
85
+ view: TaskCockpitView
86
+ onChange: (view: TaskCockpitView) => void
87
+ className?: string
88
+ }) {
89
+ return (
90
+ <div role="tablist" className={cn("flex shrink-0 items-center gap-0.5 rounded-md bg-muted/60 p-0.5", className)}>
91
+ {(["session", "diff"] as const).map((value) => (
92
+ <button
93
+ key={value}
94
+ type="button"
95
+ role="tab"
96
+ aria-selected={view === value}
97
+ onClick={() => onChange(value)}
98
+ className={cn(
99
+ "rounded-[5px] px-2 py-0.5 text-[11px] font-medium capitalize transition-colors",
100
+ view === value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
101
+ )}
102
+ >
103
+ {value}
104
+ </button>
105
+ ))}
106
+ </div>
107
+ )
108
+ }
109
+
110
+ /** The view state, controlled or not — shared with `TaskCockpitMobile`. @internal */
111
+ export function useCockpitView(view: TaskCockpitView | undefined, onViewChange: ((view: TaskCockpitView) => void) | undefined) {
112
+ const [local, setLocal] = useState<TaskCockpitView>("session")
113
+ const current = view ?? local
114
+ const set = (next: TaskCockpitView) => {
115
+ if (view === undefined) setLocal(next)
116
+ onViewChange?.(next)
117
+ }
118
+ return [current, set] as const
119
+ }
120
+
121
+ export function TaskCockpit({
122
+ task,
123
+ loading = false,
124
+ pendingAction,
125
+ pendingSince,
126
+ onAccept,
127
+ onDrop,
128
+ backHref,
129
+ render,
130
+ view: viewProp,
131
+ onViewChange,
132
+ session,
133
+ diff,
134
+ composer,
135
+ details,
136
+ className,
137
+ }: TaskCockpitProps) {
138
+ const [view, setView] = useCockpitView(viewProp, onViewChange)
139
+ const Link = render?.link ?? defaultLink
140
+
141
+ if (!task) {
142
+ return (
143
+ <div className={cn("p-6", className)}>
144
+ {loading ? (
145
+ <p className="text-sm text-muted-foreground">Loading…</p>
146
+ ) : (
147
+ <div className="space-y-3">
148
+ <p className="text-sm">This conversation does not exist, or it belongs to another project.</p>
149
+ {backHref && (
150
+ <Link href={backHref} className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
151
+ <ArrowLeftIcon className="h-3 w-3" /> Back
152
+ </Link>
153
+ )}
154
+ </div>
155
+ )}
156
+ </div>
157
+ )
158
+ }
159
+
160
+ const action = pendingAction !== undefined ? pendingAction : (task.pendingAction ?? null)
161
+ const since = pendingSince !== undefined ? pendingSince : (task.pendingSince ?? null)
162
+ const busy = action !== null
163
+ const live = isLiveTaskState(task.state)
164
+ const tabs = diff ? <TaskPanelTabs view={view} onChange={setView} /> : null
165
+
166
+ return (
167
+ <div className={cn("@container flex min-h-0 flex-1 flex-col overflow-hidden", className)}>
168
+ {/* ── Top bar ── */}
169
+ <div className="flex shrink-0 items-center justify-between gap-3 border-b border-border bg-card/60 px-4 py-2.5 backdrop-blur-sm @min-[48rem]:px-5">
170
+ <div className="flex min-w-0 items-center gap-2">
171
+ {backHref && (
172
+ <>
173
+ <Link href={backHref} className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "h-8 w-8 shrink-0")}>
174
+ <ArrowLeftIcon className="h-4 w-4" />
175
+ </Link>
176
+ <div className="h-4 w-px shrink-0 bg-border" />
177
+ </>
178
+ )}
179
+ <h1 className="truncate text-sm font-semibold">{task.title}</h1>
180
+ <TaskStateBadge state={task.state} pendingAction={action} pendingSince={since} />
181
+ </div>
182
+
183
+ {live && (onAccept || onDrop) && (
184
+ <div className="flex shrink-0 items-center gap-2">
185
+ {task.state === "in-review" && onAccept && (
186
+ <Button size="sm" disabled={busy} onClick={onAccept} className="h-8 gap-1.5 px-3 text-xs">
187
+ {action === "accepting" ? <Loader2Icon className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2Icon className="h-3.5 w-3.5" />}
188
+ {action === "accepting" ? "Accepting…" : "Accept"}
189
+ </Button>
190
+ )}
191
+ {onDrop && (
192
+ <Button
193
+ variant="ghost"
194
+ size="sm"
195
+ disabled={busy}
196
+ onClick={onDrop}
197
+ className="h-8 gap-1.5 px-3 text-xs text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
198
+ >
199
+ {action === "dropping" ? <Loader2Icon className="h-3.5 w-3.5 animate-spin" /> : <XCircleIcon className="h-3.5 w-3.5" />}
200
+ {action === "dropping" ? "Dropping…" : "Drop"}
201
+ </Button>
202
+ )}
203
+ </div>
204
+ )}
205
+ </div>
206
+
207
+ {/* ── Body: the session owns the room, the facts sit beside it ── */}
208
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden @min-[64rem]:flex-row">
209
+ {/* `min-w-0` is load-bearing: a flex child's default min-width is its *content*, and this
210
+ column holds long mono lines and <pre> blocks. Without it the column refuses to shrink
211
+ and pushes the details column past the edge, where `overflow-hidden` clips it. */}
212
+ <div className="flex min-h-[280px] min-w-0 flex-1 flex-col border-border @min-[64rem]:border-r">
213
+ {view === "diff" && diff ? diff(tabs) : session(tabs)}
214
+ {composer}
215
+ </div>
216
+
217
+ {details && (
218
+ <aside className="max-h-[40%] w-full shrink-0 overflow-y-auto border-t border-border bg-card/40 @min-[64rem]:max-h-none @min-[64rem]:w-80 @min-[64rem]:border-t-0 @min-[80rem]:w-96">
219
+ {details}
220
+ </aside>
221
+ )}
222
+ </div>
223
+ </div>
224
+ )
225
+ }