@spunto/design-system 0.28.0 → 0.30.1
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.
- package/README.md +35 -1
- package/package.json +6 -1
- package/src/components/sheet.tsx +7 -1
- package/src/components/tasks/agent-markdown.tsx +322 -0
- package/src/components/tasks/attachments.ts +152 -0
- package/src/components/tasks/command-log.tsx +102 -0
- package/src/components/tasks/composer.tsx +252 -0
- package/src/components/tasks/conversation-list.tsx +238 -0
- package/src/components/tasks/diff-panel.tsx +534 -0
- package/src/components/tasks/event-stream.tsx +860 -0
- package/src/components/tasks/index.ts +111 -0
- package/src/components/tasks/machine.tsx +455 -0
- package/src/components/tasks/new-task-dialog.tsx +301 -0
- package/src/components/tasks/pull-request.tsx +179 -0
- package/src/components/tasks/session-usage.tsx +165 -0
- package/src/components/tasks/task-attachments.tsx +475 -0
- package/src/components/tasks/task-cockpit-mobile.tsx +272 -0
- package/src/components/tasks/task-cockpit.tsx +229 -0
- package/src/components/tasks/task-details.tsx +239 -0
- package/src/components/tasks/task-list.tsx +272 -0
- package/src/components/tasks/task-state.tsx +180 -0
- package/src/components/tasks/tasks.test.tsx +205 -0
- package/src/components/tasks/tool-stack.tsx +692 -0
- package/src/components/tasks/types.ts +220 -0
- package/src/components/tasks/worker-link.tsx +126 -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,229 @@
|
|
|
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
|
+
Side by side from 42rem of *cockpit* width, not 64rem: next to the org-wide list on a
|
|
209
|
+
laptop (1512 px screen − app sidebar − 320 px list ≈ 936 px) the cockpit is well under
|
|
210
|
+
64rem, and stacking there dropped the facts under the fold on the most common desktop.
|
|
211
|
+
The column narrows instead (288 → 320 → 384 px), so the session keeps ≥ 384 px. */}
|
|
212
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden @min-[42rem]:flex-row">
|
|
213
|
+
{/* `min-w-0` is load-bearing: a flex child's default min-width is its *content*, and this
|
|
214
|
+
column holds long mono lines and <pre> blocks. Without it the column refuses to shrink
|
|
215
|
+
and pushes the details column past the edge, where `overflow-hidden` clips it. */}
|
|
216
|
+
<div className="flex min-h-[280px] min-w-0 flex-1 flex-col border-border @min-[42rem]:border-r">
|
|
217
|
+
{view === "diff" && diff ? diff(tabs) : session(tabs)}
|
|
218
|
+
{composer}
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
{details && (
|
|
222
|
+
<aside className="max-h-[40%] w-full shrink-0 overflow-y-auto border-t border-border bg-card/40 @min-[42rem]:max-h-none @min-[42rem]:w-72 @min-[42rem]:border-t-0 @min-[64rem]:w-80 @min-[80rem]:w-96">
|
|
223
|
+
{details}
|
|
224
|
+
</aside>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
)
|
|
229
|
+
}
|