@opengeni/react 0.6.1 → 0.6.3

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 (42) hide show
  1. package/dist/{chunk-DEW2ZNF2.js → chunk-5C7RGAWA.js} +70 -37
  2. package/dist/chunk-5C7RGAWA.js.map +1 -0
  3. package/dist/index.d.ts +29 -5
  4. package/dist/index.js +1061 -618
  5. package/dist/index.js.map +1 -1
  6. package/dist/{machines-BD6h9P_s.d.ts → machines-Bv9tG7qZ.d.ts} +1 -1
  7. package/dist/machines.d.ts +1 -1
  8. package/dist/machines.js +1 -1
  9. package/package.json +2 -2
  10. package/src/components/chat-composer.tsx +127 -60
  11. package/src/components/code-editor.tsx +15 -15
  12. package/src/components/desktop-viewer.tsx +40 -36
  13. package/src/components/diff-view.tsx +22 -22
  14. package/src/components/enrollment-consent.tsx +13 -13
  15. package/src/components/file-browser.tsx +74 -67
  16. package/src/components/fleet-tile.tsx +3 -3
  17. package/src/components/machine-card.tsx +6 -6
  18. package/src/components/machine-status-pill.tsx +3 -3
  19. package/src/components/machines-dashboard.tsx +28 -14
  20. package/src/components/markdown.tsx +6 -6
  21. package/src/components/message-timeline.tsx +75 -4
  22. package/src/components/pierre-diff.tsx +9 -10
  23. package/src/components/pierre-file.tsx +8 -8
  24. package/src/components/sandbox-files.tsx +37 -40
  25. package/src/components/sandbox-terminal.tsx +9 -11
  26. package/src/components/session-status.tsx +2 -2
  27. package/src/components/workspace-dock.tsx +153 -49
  28. package/src/hooks/use-file-attachments.ts +45 -15
  29. package/src/index.ts +2 -2
  30. package/src/lib/format.ts +32 -0
  31. package/src/lib/xterm-theme.ts +8 -11
  32. package/src/timeline/activity-rail.tsx +1 -1
  33. package/src/timeline/parsers.ts +104 -0
  34. package/src/timeline/projection.ts +135 -3
  35. package/src/timeline/screenshot-lightbox.tsx +1 -1
  36. package/src/timeline/shared.tsx +4 -1
  37. package/src/timeline/tool-diff.tsx +1 -1
  38. package/src/timeline/tool-renderers.tsx +46 -7
  39. package/src/timeline/turn-summary.tsx +21 -3
  40. package/src/timeline/types.ts +2 -0
  41. package/styles/tokens.css +8 -0
  42. package/dist/chunk-DEW2ZNF2.js.map +0 -1
@@ -99,12 +99,12 @@ export function PierreFile({
99
99
  // Same shadow-DOM override vars as PierreDiff: pin the base background to the
100
100
  // dock surface so the viewer reads as part of the panel, not a black seam.
101
101
  const pierreVars = {
102
- "--diffs-dark-bg": "var(--og-color-bg, #0d0d0d)",
103
- "--diffs-light-bg": "var(--og-color-bg, #ffffff)",
104
- "--diffs-bg-buffer-override": "var(--og-color-surface-1, #161616)",
105
- "--diffs-bg-separator-override": "var(--og-color-surface-1, #161616)",
106
- "--diffs-font-size": "12.5px",
107
- "--diffs-line-height": "20px",
102
+ "--diffs-dark-bg": "var(--og-color-bg)",
103
+ "--diffs-light-bg": "var(--og-color-bg)",
104
+ "--diffs-bg-buffer-override": "var(--og-color-surface-1)",
105
+ "--diffs-bg-separator-override": "var(--og-color-surface-1)",
106
+ "--diffs-font-size": "var(--og-code-font-size)",
107
+ "--diffs-line-height": "var(--og-code-line-height)",
108
108
  } as CSSProperties;
109
109
 
110
110
  return (
@@ -125,7 +125,7 @@ export function PierreFile({
125
125
  function PlainFile({ name, contents }: { name: string; contents: string }) {
126
126
  return (
127
127
  <pre
128
- className="overflow-auto whitespace-pre p-2 font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[12px] leading-[18px] text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
128
+ className="overflow-auto whitespace-pre p-2 font-og-mono text-og-sm text-og-fg"
129
129
  data-file={name}
130
130
  >
131
131
  {contents}
@@ -135,7 +135,7 @@ function PlainFile({ name, contents }: { name: string; contents: string }) {
135
135
 
136
136
  function FileSkeleton() {
137
137
  return (
138
- <div className="p-3 text-xs text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
138
+ <div className="p-3 text-og-sm text-og-fg-subtle">
139
139
  Loading file…
140
140
  </div>
141
141
  );
@@ -30,15 +30,15 @@ export type SandboxFilesProps = {
30
30
  };
31
31
 
32
32
  const STATUS_TINT: Record<GitFileDiff["status"], string> = {
33
- added: "text-[color:var(--og-color-status-idle,var(--color-success,#3fb950))]",
34
- modified: "text-[color:var(--og-color-status-running,var(--color-warning,#d29922))]",
35
- deleted: "text-[color:var(--og-color-danger,var(--color-danger,#f85149))]",
36
- renamed: "text-[color:var(--og-color-accent,var(--color-info,#58a6ff))]",
37
- copied: "text-[color:var(--og-color-accent,var(--color-info,#58a6ff))]",
38
- untracked: "text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]",
39
- ignored: "text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]",
40
- conflicted: "text-[color:var(--og-color-danger,var(--color-danger,#f85149))]",
41
- typechange: "text-[color:var(--og-color-status-running,var(--color-warning,#d29922))]",
33
+ added: "text-og-status-idle",
34
+ modified: "text-og-status-running",
35
+ deleted: "text-og-status-failed",
36
+ renamed: "text-og-accent",
37
+ copied: "text-og-accent",
38
+ untracked: "text-og-fg-subtle",
39
+ ignored: "text-og-fg-subtle",
40
+ conflicted: "text-og-status-failed",
41
+ typechange: "text-og-status-running",
42
42
  };
43
43
 
44
44
  const STATUS_LETTER: Record<GitFileDiff["status"], string> = {
@@ -158,13 +158,13 @@ export function SandboxFiles({
158
158
  className={cn(
159
159
  "min-h-0 overflow-auto",
160
160
  wide
161
- ? "w-[280px] shrink-0 border-r border-[color:var(--og-color-border,var(--color-border,#2a2a2a))]"
161
+ ? "w-[280px] shrink-0 border-r border-og-border"
162
162
  : "flex-1",
163
163
  )}
164
164
  >
165
165
  {changed.length > 0 && (
166
- <div className="border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))]">
167
- <div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
166
+ <div className="border-b border-og-border">
167
+ <div className="px-2 py-1 text-og-xs font-medium uppercase tracking-wide text-og-fg-subtle">
168
168
  Changes · {changed.length}
169
169
  </div>
170
170
  <ul className="pb-1">
@@ -174,14 +174,13 @@ export function SandboxFiles({
174
174
  type="button"
175
175
  onClick={() => selectFile(file.path)}
176
176
  className={cn(
177
- "flex w-full items-center gap-1.5 truncate px-2 py-0.5 text-left text-xs hover:bg-[color:var(--og-color-surface-2,var(--color-bg-subtle,#1c1c1c))]",
178
- file.path === effectiveSelected &&
179
- "bg-[color:var(--og-color-surface-2,var(--color-bg-subtle,#1c1c1c))]",
177
+ "flex w-full items-center gap-1.5 truncate px-2 py-0.5 text-left text-og-sm hover:bg-og-surface-2 pointer-coarse:min-h-10",
178
+ file.path === effectiveSelected && "bg-og-surface-2",
180
179
  )}
181
180
  >
182
181
  <span
183
182
  className={cn(
184
- "w-3 shrink-0 text-center font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[10px]",
183
+ "w-3 shrink-0 text-center font-og-mono text-og-xs",
185
184
  STATUS_TINT[file.status],
186
185
  )}
187
186
  >
@@ -189,11 +188,11 @@ export function SandboxFiles({
189
188
  </span>
190
189
  <FileIcon className="size-3.5 shrink-0 opacity-70" />
191
190
  <span className="truncate">{file.path}</span>
192
- <span className="ml-auto flex shrink-0 items-center gap-1.5 pl-2 text-[10px]">
193
- <span className="text-[color:var(--og-color-status-idle,var(--color-success,#3fb950))]">
191
+ <span className="ml-auto flex shrink-0 items-center gap-1.5 pl-2 text-og-xs">
192
+ <span className="text-og-status-idle">
194
193
  +{file.additions}
195
194
  </span>
196
- <span className="text-[color:var(--og-color-danger,var(--color-danger,#f85149))]">
195
+ <span className="text-og-status-failed">
197
196
  −{file.deletions}
198
197
  </span>
199
198
  </span>
@@ -204,14 +203,14 @@ export function SandboxFiles({
204
203
  </div>
205
204
  )}
206
205
 
207
- <div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
206
+ <div className="px-2 py-1 text-og-xs font-medium uppercase tracking-wide text-og-fg-subtle">
208
207
  Files
209
208
  </div>
210
209
  <FileBrowser
211
210
  result={files}
212
211
  selectedPath={effectiveSelected ?? undefined}
213
212
  onSelectFile={selectFile}
214
- emptyState="No files."
213
+ emptyState="This directory is empty"
215
214
  className="min-w-0"
216
215
  />
217
216
  </div>
@@ -223,14 +222,14 @@ export function SandboxFiles({
223
222
  "flex min-h-0 min-w-0 flex-col",
224
223
  wide
225
224
  ? "flex-1"
226
- : "flex-[1.4] border-t border-[color:var(--og-color-border,var(--color-border,#2a2a2a))]",
225
+ : "flex-[1.4] border-t border-og-border",
227
226
  )}
228
227
  >
229
- <div className="flex shrink-0 items-center justify-between gap-2 border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-surface-1,var(--color-surface,#161616))] px-2 py-1">
230
- <span className="truncate font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[11px] text-[color:var(--og-color-fg-muted,var(--color-fg-muted,#aaa))]">
228
+ <div className="flex shrink-0 items-center justify-between gap-2 border-b border-og-border bg-og-surface-1 px-2 py-1">
229
+ <span className="min-w-0 truncate font-og-mono text-og-xs text-og-fg-muted">
231
230
  {effectiveSelected ?? "No file selected"}
232
231
  </span>
233
- <div className="flex shrink-0 items-center gap-1">
232
+ <div className="flex min-w-0 shrink-0 flex-wrap items-center justify-end gap-1">
234
233
  {/* The Working/Staged + Unified/Split toggles only apply to a diff.
235
234
  Hide them in the read-only viewer (a non-changed tree file). */}
236
235
  {selectedDiff && stagedGit && (
@@ -306,7 +305,7 @@ export function SandboxFiles({
306
305
  ) : fileView.content !== null ? (
307
306
  <>
308
307
  {fileView.truncated && (
309
- <div className="border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-surface-1,var(--color-surface,#161616))] px-2 py-1 text-[10px] text-[color:var(--og-color-status-running,var(--color-warning,#d29922))]">
308
+ <div className="border-b border-og-border bg-og-surface-1 px-2 py-1 text-og-xs text-og-status-running">
310
309
  Large file — showing a truncated preview ({fileView.sizeBytes ?? 0} bytes loaded). Editing is disabled to avoid corrupting the file.
311
310
  </div>
312
311
  )}
@@ -316,14 +315,14 @@ export function SandboxFiles({
316
315
  contents={fileView.content}
317
316
  themeType={resolvedTheme}
318
317
  fallback={
319
- <pre className="overflow-auto whitespace-pre p-2 font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[12px] leading-[18px]">
318
+ <pre className="overflow-auto whitespace-pre p-2 font-og-mono text-og-sm text-og-fg">
320
319
  {fileView.content}
321
320
  </pre>
322
321
  }
323
322
  className="p-1"
324
323
  />
325
324
  ) : (
326
- <pre className="overflow-auto whitespace-pre p-2 font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[12px] leading-[18px]">
325
+ <pre className="overflow-auto whitespace-pre p-2 font-og-mono text-og-sm text-og-fg">
327
326
  {fileView.content}
328
327
  </pre>
329
328
  )}
@@ -350,27 +349,25 @@ export function SandboxFiles({
350
349
  function GitHeader({ git, dirtyCount }: { git: UseSandboxGitResult; dirtyCount: number }) {
351
350
  const dirty = dirtyCount > 0;
352
351
  return (
353
- <div className="flex shrink-0 items-center gap-2 border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-surface-1,var(--color-surface,#161616))] px-2 py-1 text-xs">
352
+ <div className="flex shrink-0 items-center gap-2 border-b border-og-border bg-og-surface-1 px-2 py-1 text-og-sm">
354
353
  <span
355
354
  className={cn(
356
355
  "size-2 shrink-0 rounded-full",
357
- dirty
358
- ? "bg-[color:var(--og-color-status-running,var(--color-warning,#d29922))]"
359
- : "bg-[color:var(--og-color-status-idle,var(--color-success,#3fb950))]",
356
+ dirty ? "bg-og-status-running" : "bg-og-status-idle",
360
357
  )}
361
358
  title={dirty ? `${dirtyCount} changed` : "clean"}
362
359
  />
363
- <span className="truncate font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]">
360
+ <span className="truncate font-og-mono text-og-fg">
364
361
  {git.branch ?? (git.isRepo ? "(detached)" : "no repo")}
365
362
  </span>
366
363
  {(git.ahead > 0 || git.behind > 0) && (
367
- <span className="flex shrink-0 items-center gap-1.5 text-[10px] text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
364
+ <span className="flex shrink-0 items-center gap-1.5 text-og-xs text-og-fg-subtle">
368
365
  {git.ahead > 0 && <span>↑{git.ahead}</span>}
369
366
  {git.behind > 0 && <span>↓{git.behind}</span>}
370
367
  </span>
371
368
  )}
372
369
  {dirty && (
373
- <span className="ml-auto shrink-0 text-[10px] text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
370
+ <span className="ml-auto shrink-0 text-og-xs text-og-fg-subtle">
374
371
  {dirtyCount} changed
375
372
  </span>
376
373
  )}
@@ -388,17 +385,17 @@ function Segmented({
388
385
  onChange: (value: string) => void;
389
386
  }) {
390
387
  return (
391
- <div className="flex items-center rounded-[var(--og-radius-sm,4px)] border border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] p-0.5">
388
+ <div className="flex flex-wrap items-center rounded-og-sm border border-og-border p-0.5">
392
389
  {options.map((opt) => (
393
390
  <button
394
391
  key={opt.value}
395
392
  type="button"
396
393
  onClick={() => onChange(opt.value)}
397
394
  className={cn(
398
- "rounded-[var(--og-radius-xs,3px)] px-1.5 py-0.5 text-[10px]",
395
+ "rounded-og-xs px-1.5 py-0.5 text-og-xs pointer-coarse:min-h-10",
399
396
  opt.value === value
400
- ? "bg-[color:var(--og-color-accent-soft,var(--color-surface-2,#222))] text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
401
- : "text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]",
397
+ ? "bg-og-accent-soft text-og-fg"
398
+ : "text-og-fg-subtle hover:text-og-fg",
402
399
  )}
403
400
  >
404
401
  {opt.label}
@@ -499,7 +496,7 @@ function Notice({ children, className }: { children: ReactNode; className?: stri
499
496
  return (
500
497
  <div
501
498
  className={cn(
502
- "flex h-full items-center justify-center p-4 text-center text-xs text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]",
499
+ "flex h-full items-center justify-center p-4 text-center text-og-sm text-og-fg-subtle",
503
500
  className,
504
501
  )}
505
502
  >
@@ -181,7 +181,7 @@ export function SandboxTerminal({
181
181
  convertEol: true,
182
182
  disableStdin: !interactive,
183
183
  cursorBlink: interactive,
184
- fontFamily: fontFamily ?? "var(--og-font-mono, var(--font-mono, monospace))",
184
+ fontFamily: fontFamily ?? "var(--og-font-mono)",
185
185
  fontSize: fontSize ?? 13,
186
186
  lineHeight: 1.25,
187
187
  // A little breathing room from the panel edge so output isn't flush to
@@ -318,23 +318,21 @@ export function SandboxTerminal({
318
318
  return (
319
319
  <div className={cn("relative flex h-full w-full flex-col overflow-hidden", className)}>
320
320
  {showHeader && (
321
- <div className="flex shrink-0 items-center justify-between gap-2 border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] px-2 py-1 text-[11px] text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
321
+ <div className="flex shrink-0 items-center justify-between gap-2 border-b border-og-border px-2 py-1 text-og-xs text-og-fg-subtle">
322
322
  <span className="flex min-w-0 items-center gap-1.5">
323
323
  <span
324
324
  className={cn(
325
325
  "size-1.5 shrink-0 rounded-full",
326
- result.running
327
- ? "bg-[color:var(--og-color-status-running,var(--color-status-running,#d29922))]"
328
- : "bg-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]",
326
+ result.running ? "bg-og-status-running" : "bg-og-fg-subtle",
329
327
  )}
330
328
  />
331
- <span className="truncate font-[family-name:var(--og-font-mono,var(--font-mono,monospace))]">
329
+ <span className="truncate font-og-mono">
332
330
  pty: {shell ?? "shell"}
333
331
  </span>
334
332
  </span>
335
333
  <span className="flex shrink-0 items-center gap-2">
336
334
  {!interactive && (
337
- <span className="rounded-[var(--og-radius-sm,4px)] bg-[color:var(--og-color-surface-2,var(--color-surface-2,#161616))] px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
335
+ <span className="rounded-og-sm bg-og-surface-2 px-1.5 py-0.5 text-og-xs uppercase tracking-wide">
338
336
  read-only
339
337
  </span>
340
338
  )}
@@ -344,7 +342,7 @@ export function SandboxTerminal({
344
342
  termRef.current?.clear();
345
343
  writtenRef.current = new Set();
346
344
  }}
347
- className="rounded-[var(--og-radius-sm,4px)] px-1.5 py-0.5 text-[10px] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
345
+ className="rounded-og-sm px-1.5 py-0.5 text-og-xs hover:text-og-fg pointer-coarse:min-h-10"
348
346
  >
349
347
  Clear
350
348
  </button>
@@ -355,7 +353,7 @@ export function SandboxTerminal({
355
353
  with the terminal surface (capture so they win even though xterm's own
356
354
  listeners stop propagation). The host warms the box for pty-ws here. */}
357
355
  <div
358
- className="relative min-h-0 flex-1 bg-[color:var(--og-color-bg,var(--color-bg,#0d0d0d))] px-2 py-1.5"
356
+ className="relative min-h-0 flex-1 bg-og-bg px-2 py-1.5"
359
357
  onPointerDownCapture={onActivate}
360
358
  onFocusCapture={onActivate}
361
359
  >
@@ -379,7 +377,7 @@ const XTERM_BASE_CSS = `
379
377
  .xterm.focus,.xterm:focus{outline:none}
380
378
  .xterm .xterm-helpers{position:absolute;top:0;z-index:5}
381
379
  .xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}
382
- .xterm .composition-view{background:#000;color:#FFF;display:none;position:absolute;white-space:nowrap;z-index:1}
380
+ .xterm .composition-view{background:var(--og-color-bg);color:var(--og-color-fg);display:none;position:absolute;white-space:nowrap;z-index:1}
383
381
  .xterm .composition-view.active{display:block}
384
382
  .xterm .xterm-viewport{background-color:transparent;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}
385
383
  .xterm .xterm-screen{position:relative}
@@ -418,7 +416,7 @@ function ensureXtermBaseCss(): void {
418
416
 
419
417
  function TerminalPlaceholder() {
420
418
  return (
421
- <div className="absolute inset-0 flex items-center justify-center text-xs text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
419
+ <div className="absolute inset-0 flex items-center justify-center text-og-sm text-og-fg-subtle">
422
420
  Loading terminal…
423
421
  </div>
424
422
  );
@@ -30,7 +30,7 @@ export const SESSION_STATUS_META: Record<SessionStatusValue, SessionStatusMeta>
30
30
  pulse: false,
31
31
  },
32
32
  requires_action: {
33
- label: "Needs you",
33
+ label: "Waiting on you",
34
34
  dotClassName: "bg-og-status-waiting",
35
35
  badgeClassName: "text-og-status-waiting border-og-status-waiting/35 bg-og-status-waiting/10",
36
36
  pulse: true,
@@ -65,7 +65,7 @@ export function SessionStatus({ status, label, size = "md", className }: Session
65
65
  data-status={status}
66
66
  className={cn(
67
67
  "og-root inline-flex shrink-0 items-center rounded-full border font-medium",
68
- size === "sm" ? "gap-1 px-1.5 py-px text-[10px]" : "gap-1.5 px-2 py-0.5 text-xs",
68
+ size === "sm" ? "gap-1 px-1.5 py-px text-og-xs" : "gap-1.5 px-2 py-0.5 text-xs",
69
69
  meta.badgeClassName,
70
70
  className,
71
71
  )}
@@ -4,6 +4,7 @@ import {
4
4
  Maximize2Icon,
5
5
  Minimize2Icon,
6
6
  PanelRightCloseIcon,
7
+ XIcon,
7
8
  } from "lucide-react";
8
9
  import {
9
10
  Group,
@@ -49,9 +50,43 @@ export type WorkspaceDockProps = {
49
50
  * restore button returns). Layout persists via `useDefaultLayout` keyed on
50
51
  * `autoSaveId`. Maximize is a mode ABOVE the Group (a `fixed inset-0` overlay) —
51
52
  * pushing a Panel to ~100% still fights min sizes and leaves a chat sliver.
53
+ *
54
+ * Below {@link DOCK_OVERLAY_BREAKPOINT} the side-by-side split can't work on a
55
+ * phone-width viewport, so the resizable panels are dropped entirely: the
56
+ * primary pane goes full-width and the dock becomes a full-screen overlay driven
57
+ * by the same `collapsed` / `onCollapsedChange` contract (collapsed → hidden).
58
+ * No drag splitter renders below the breakpoint.
52
59
  */
53
60
  const useDockLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
54
61
 
62
+ /**
63
+ * The dock stops being a side column at this width and becomes a full-screen
64
+ * overlay — matches the app's rail-drawer breakpoint (the single `isMobile`
65
+ * source). The package can't read app context, so it detects the width itself.
66
+ */
67
+ const DOCK_OVERLAY_BREAKPOINT = 1024;
68
+
69
+ /** SSR-safe `(max-width: …)` match; false until mounted, then live. */
70
+ function useIsNarrow(maxWidth: number): boolean {
71
+ const [narrow, setNarrow] = useState(false);
72
+ useDockLayoutEffect(() => {
73
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
74
+ return;
75
+ }
76
+ const mql = window.matchMedia(`(max-width: ${maxWidth - 1}px)`);
77
+ const update = () => setNarrow(mql.matches);
78
+ update();
79
+ if (typeof mql.addEventListener === "function") {
80
+ mql.addEventListener("change", update);
81
+ return () => mql.removeEventListener("change", update);
82
+ }
83
+ // Legacy Safari.
84
+ mql.addListener(update);
85
+ return () => mql.removeListener(update);
86
+ }, [maxWidth]);
87
+ return narrow;
88
+ }
89
+
55
90
  export function WorkspaceDock({
56
91
  primary,
57
92
  tabs,
@@ -65,11 +100,18 @@ export function WorkspaceDock({
65
100
  maxSize = 70,
66
101
  className,
67
102
  }: WorkspaceDockProps) {
103
+ const narrow = useIsNarrow(DOCK_OVERLAY_BREAKPOINT);
68
104
  const dockPanelRef = usePanelRef();
69
105
  const [internalCollapsed, setInternalCollapsed] = useState(false);
70
106
  const [maximized, setMaximized] = useState(false);
71
107
  const [internalTab, setInternalTab] = useState(tabs[0]?.id ?? "");
72
108
  const collapsed = collapsedProp ?? internalCollapsed;
109
+ // When the host supplies `collapsed` it owns the open/close affordance (e.g.
110
+ // the app header's panel toggle) — the dock must not offer a SECOND
111
+ // open/close control (duplicate buttons with near-identical icons read as a
112
+ // bug). Standalone (uncontrolled) usage keeps the built-in collapse button
113
+ // and the thin re-open rail as its only affordances.
114
+ const hostControlled = collapsedProp !== undefined;
73
115
 
74
116
  // Persisted layout (width split) keyed by autoSaveId.
75
117
  const { defaultLayout, onLayoutChanged } = useDefaultLayout({
@@ -117,16 +159,6 @@ export function WorkspaceDock({
117
159
  // rebuild tab JSX frequently; only id changes can invalidate the active tab.
118
160
  }, [tabIds, firstTabId, current, setTab]);
119
161
 
120
- // Esc restores from maximize.
121
- useEffect(() => {
122
- if (!maximized) return;
123
- const onKey = (event: KeyboardEvent) => {
124
- if (event.key === "Escape") setMaximized(false);
125
- };
126
- window.addEventListener("keydown", onKey);
127
- return () => window.removeEventListener("keydown", onKey);
128
- }, [maximized]);
129
-
130
162
  const collapse = useCallback(() => {
131
163
  dockPanelRef.current?.collapse();
132
164
  setCollapsed(true);
@@ -136,14 +168,74 @@ export function WorkspaceDock({
136
168
  setCollapsed(false);
137
169
  }, [dockPanelRef, setCollapsed]);
138
170
 
171
+ // Esc restores from maximize (desktop) and closes the mobile overlay.
172
+ useEffect(() => {
173
+ const overlayOpen = maximized || (narrow && !collapsed);
174
+ if (!overlayOpen) return;
175
+ const onKey = (event: KeyboardEvent) => {
176
+ if (event.key !== "Escape") return;
177
+ if (maximized) setMaximized(false);
178
+ else collapse();
179
+ };
180
+ window.addEventListener("keydown", onKey);
181
+ return () => window.removeEventListener("keydown", onKey);
182
+ }, [maximized, narrow, collapsed, collapse]);
183
+
184
+ // Below the breakpoint the dock is a full-screen overlay, not a resizable
185
+ // column: primary goes full-width and no splitter ever mounts. The overlay is
186
+ // driven by the same `collapsed` contract (collapsed → hidden).
187
+ if (narrow) {
188
+ return (
189
+ <div className={cn("relative flex h-full min-h-0 w-full min-w-0", className)}>
190
+ <div className="min-h-0 min-w-0 flex-1">{primary}</div>
191
+ {!collapsed && (
192
+ <div
193
+ role="dialog"
194
+ aria-modal="true"
195
+ aria-label="Workspace"
196
+ className="fixed inset-0 z-40 flex flex-col bg-og-bg"
197
+ style={{
198
+ paddingTop: "env(safe-area-inset-top)",
199
+ paddingBottom: "env(safe-area-inset-bottom)",
200
+ }}
201
+ >
202
+ <DockChrome
203
+ tabs={tabs}
204
+ current={current}
205
+ onTab={setTab}
206
+ controls={
207
+ <ChromeButton onClick={collapse} title="Close workspace" label="Close workspace">
208
+ <XIcon className="size-4" />
209
+ </ChromeButton>
210
+ }
211
+ />
212
+ </div>
213
+ )}
214
+ </div>
215
+ );
216
+ }
217
+
139
218
  const dockChrome = (
140
219
  <DockChrome
141
220
  tabs={tabs}
142
221
  current={current}
143
222
  onTab={setTab}
144
- maximized={maximized}
145
- onToggleMaximize={() => setMaximized((m) => !m)}
146
- onCollapse={maximized ? () => setMaximized(false) : collapse}
223
+ controls={
224
+ <>
225
+ <ChromeButton
226
+ onClick={() => setMaximized((m) => !m)}
227
+ title={maximized ? "Restore (Esc)" : "Maximize"}
228
+ label={maximized ? "Restore dock" : "Maximize dock"}
229
+ >
230
+ {maximized ? <Minimize2Icon className="size-3.5" /> : <Maximize2Icon className="size-3.5" />}
231
+ </ChromeButton>
232
+ {hostControlled ? null : (
233
+ <ChromeButton onClick={collapse} title="Collapse" label="Collapse dock">
234
+ <PanelRightCloseIcon className="size-3.5" />
235
+ </ChromeButton>
236
+ )}
237
+ </>
238
+ }
147
239
  />
148
240
  );
149
241
 
@@ -161,7 +253,7 @@ export function WorkspaceDock({
161
253
 
162
254
  {!collapsed && (
163
255
  <Separator className="group relative w-1.5 shrink-0 outline-none">
164
- <span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-[color:var(--og-color-border,var(--color-border,#2a2a2a))] transition-colors group-hover:bg-[color:var(--og-color-accent,var(--color-brand,#3b82f6))] group-data-[separator-state=dragging]:bg-[color:var(--og-color-accent,var(--color-brand,#3b82f6))]" />
256
+ <span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-og-border transition-colors group-hover:bg-og-accent group-data-[separator-state=dragging]:bg-og-accent" />
165
257
  </Separator>
166
258
  )}
167
259
 
@@ -186,20 +278,21 @@ export function WorkspaceDock({
186
278
  {/* Hidden behind the overlay while maximized (avoids double-mounting
187
279
  the surfaces). */}
188
280
  {!collapsed && !maximized && (
189
- <div className="flex h-full min-h-0 min-w-0 flex-col border-l border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-bg,var(--color-bg,#0d0d0d))]">
281
+ <div className="flex h-full min-h-0 min-w-0 flex-col border-l border-og-border bg-og-bg">
190
282
  {dockChrome}
191
283
  </div>
192
284
  )}
193
285
  </Panel>
194
286
  </Group>
195
287
 
196
- {/* Collapsed rail: a thin tab on the right edge that re-opens the dock. */}
197
- {collapsed && !maximized && (
288
+ {/* Collapsed rail: the standalone fallback re-open affordance. Hidden
289
+ when the host controls collapse — its own toggle is the one way in. */}
290
+ {collapsed && !maximized && !hostControlled && (
198
291
  <button
199
292
  type="button"
200
293
  onClick={expand}
201
294
  title="Open workspace"
202
- className="absolute inset-y-0 right-0 flex w-6 shrink-0 items-center justify-center border-l border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-surface-1,var(--color-surface,#161616))] text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
295
+ className="absolute inset-y-0 right-0 flex w-6 shrink-0 items-center justify-center border-l border-og-border bg-og-surface-1 text-og-fg-subtle hover:text-og-fg"
203
296
  >
204
297
  <ChevronsLeftRightIcon className="size-3.5" />
205
298
  </button>
@@ -207,7 +300,7 @@ export function WorkspaceDock({
207
300
 
208
301
  {/* Maximize overlay: full-workspace surface above everything. */}
209
302
  {maximized && (
210
- <div className="fixed inset-0 z-40 flex flex-col bg-[color:var(--og-color-bg,var(--color-bg,#0d0d0d))]">
303
+ <div className="fixed inset-0 z-40 flex flex-col bg-og-bg">
211
304
  {dockChrome}
212
305
  </div>
213
306
  )}
@@ -215,26 +308,54 @@ export function WorkspaceDock({
215
308
  );
216
309
  }
217
310
 
311
+ /** A dock-chrome control button — compact on fine pointers, ≥40px on coarse. */
312
+ function ChromeButton({
313
+ onClick,
314
+ title,
315
+ label,
316
+ children,
317
+ }: {
318
+ onClick: () => void;
319
+ title: string;
320
+ label: string;
321
+ children: ReactNode;
322
+ }) {
323
+ return (
324
+ <button
325
+ type="button"
326
+ onClick={onClick}
327
+ title={title}
328
+ aria-label={label}
329
+ className="inline-flex items-center justify-center rounded-og-sm p-1 transition-colors hover:bg-og-surface-2 hover:text-og-fg pointer-coarse:size-10"
330
+ >
331
+ {children}
332
+ </button>
333
+ );
334
+ }
335
+
218
336
  function DockChrome({
219
337
  tabs,
220
338
  current,
221
339
  onTab,
222
- maximized,
223
- onToggleMaximize,
224
- onCollapse,
340
+ controls,
225
341
  }: {
226
342
  tabs: WorkspaceTab[];
227
343
  current: string;
228
344
  onTab: (id: string) => void;
229
- maximized: boolean;
230
- onToggleMaximize: () => void;
231
- onCollapse: () => void;
345
+ /** Right-aligned chrome controls (maximize / collapse, or the overlay close). */
346
+ controls: ReactNode;
232
347
  }) {
233
348
  const active = tabs.find((t) => t.id === current) ?? tabs[0];
234
349
  return (
235
350
  <>
236
- <div className="flex shrink-0 items-center justify-between gap-2 border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] px-1.5 py-1">
237
- <div className="flex min-w-0 items-center gap-0.5" role="tablist">
351
+ <div className="flex shrink-0 items-center gap-2 border-b border-og-border px-1.5 py-1">
352
+ {/* The tab list scrolls horizontally when it can't fit — it must never
353
+ grow into or overlap the chrome controls (they stay shrink-0). The
354
+ scrollbar is hidden to keep the strip calm. */}
355
+ <div
356
+ className="flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
357
+ role="tablist"
358
+ >
238
359
  {tabs.map((tab) => (
239
360
  <button
240
361
  key={tab.id}
@@ -243,35 +364,18 @@ function DockChrome({
243
364
  aria-selected={tab.id === current}
244
365
  onClick={() => onTab(tab.id)}
245
366
  className={cn(
246
- "flex items-center gap-1 rounded-[var(--og-radius-sm,4px)] px-2 py-1 text-[11px] font-medium transition-colors",
367
+ "flex shrink-0 items-center gap-1 rounded-og-sm px-2 py-1 text-og-xs font-medium transition-colors pointer-coarse:min-h-10",
247
368
  tab.id === current
248
- ? "bg-[color:var(--og-color-accent-soft,var(--color-surface-2,#222))] text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
249
- : "text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]",
369
+ ? "bg-og-accent-soft text-og-fg"
370
+ : "text-og-fg-subtle hover:text-og-fg",
250
371
  )}
251
372
  >
252
- <span className="truncate">{tab.label}</span>
373
+ <span>{tab.label}</span>
253
374
  {tab.badge}
254
375
  </button>
255
376
  ))}
256
377
  </div>
257
- <div className="flex shrink-0 items-center gap-0.5 text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
258
- <button
259
- type="button"
260
- onClick={onToggleMaximize}
261
- title={maximized ? "Restore (Esc)" : "Maximize"}
262
- className="rounded-[var(--og-radius-sm,4px)] p-1 hover:bg-[color:var(--og-color-surface-2,var(--color-surface-2,#222))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
263
- >
264
- {maximized ? <Minimize2Icon className="size-3.5" /> : <Maximize2Icon className="size-3.5" />}
265
- </button>
266
- <button
267
- type="button"
268
- onClick={onCollapse}
269
- title={maximized ? "Restore" : "Collapse"}
270
- className="rounded-[var(--og-radius-sm,4px)] p-1 hover:bg-[color:var(--og-color-surface-2,var(--color-surface-2,#222))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
271
- >
272
- <PanelRightCloseIcon className="size-3.5" />
273
- </button>
274
- </div>
378
+ <div className="flex shrink-0 items-center gap-0.5 text-og-fg-subtle">{controls}</div>
275
379
  </div>
276
380
  <div className="min-h-0 min-w-0 flex-1 overflow-hidden" role="tabpanel">
277
381
  {active?.content}