@orbit-work/ui 0.1.1 → 0.1.2-preview.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.
@@ -18,6 +18,7 @@ import {
18
18
  Folder,
19
19
  Grid,
20
20
  Icon,
21
+ Memo,
21
22
  More,
22
23
  Package,
23
24
  PanelSidebar,
@@ -28,11 +29,12 @@ import {
28
29
  Trash,
29
30
  } from "../../../composite/Icons"
30
31
  import { TextField } from "../../../composite/NativeControls"
31
- import { OrbitworkLockup } from "../../../composite/OrbitworkLogo"
32
+ import { OrbitworkLockup, OrbitworkMark } from "../../../composite/OrbitworkLogo"
32
33
  import { ProjectSelect } from "../../../composite/ProjectSelect"
33
34
  import { Avatar, Button } from "../../../primitives"
34
35
  import { MenuSurface } from "../../../primitives/Menu"
35
36
  import type { MenuItem } from "../../../primitives/Menu"
37
+ import { Spinner } from "../../../primitives/Spinner"
36
38
  import { DesktopAccountMenuView, updateRowText } from "../DesktopAccountMenuView"
37
39
 
38
40
  export type DesktopSidebarNavId = "new" | "skills" | "sites" | "cron" | "files" | "library"
@@ -111,6 +113,12 @@ export interface DesktopSidebarViewProps {
111
113
  * 宿主 `features/sidebar/Sidebar.tsx` 补 `view === 'home' → 'new'` 即可,见交接单。
112
114
  */
113
115
  activeNav?: DesktopSidebarNavId
116
+ /**
117
+ * 首次会话列表加载中(宿主 `loadSessions` 首轮)。为 true 且还没有任何会话行时,
118
+ * 列表区出品牌 mark + spinner 的启动态,而不是一块空白——首屏读得出「在加载」
119
+ * 而不是「没有会话」。分页/刷新加载不改变此值(那是 `loadingMore` 的语义)。
120
+ */
121
+ sessionsLoading?: boolean
114
122
  /**
115
123
  * 是否在导航里出「我的空间」(A11-B1 · 裁决 16)。
116
124
  *
@@ -346,6 +354,17 @@ export function DesktopSidebarView(props: DesktopSidebarViewProps): React.ReactE
346
354
  onClick={() => props.onNavigate("files")}
347
355
  />
348
356
  )}
357
+ {/* 资料库:恢复的入口(曾隐藏导致已接线的 LibraryPage 不可达——App.tsx 已把
358
+ baseView === "library" 路由到 workbenchView 并以 active prop 挂载 LibraryPage)。
359
+ 放在「我的空间」之后;只有 running 的 thread 才禁用移动,archived 可以移动。 */}
360
+ <NavItem
361
+ id="library"
362
+ label="资料库"
363
+ title="资料库"
364
+ active={props.activeNav === "library"}
365
+ icon={<Memo />}
366
+ onClick={() => props.onNavigate("library")}
367
+ />
349
368
  </nav>
350
369
 
351
370
  <div className="owrecent-head" data-tour-anchor="projects">
@@ -369,7 +388,13 @@ export function DesktopSidebarView(props: DesktopSidebarViewProps): React.ReactE
369
388
  </div>
370
389
  </div>
371
390
  <div className="owprojects">
372
- {projects.map((project) => (
391
+ {props.sessionsLoading && projects.every((project) => project.threads.length === 0) ? (
392
+ <div className="owprojects-boot" role="status" aria-live="polite">
393
+ <OrbitworkMark variant="auto" size={40} />
394
+ <Spinner size="md" />
395
+ </div>
396
+ ) : (
397
+ projects.map((project) => (
373
398
  <div key={project.id} className="owproject">
374
399
  {renamingProject?.id === project.id ? (
375
400
  <div className="owproject__head renaming">
@@ -569,15 +594,26 @@ export function DesktopSidebarView(props: DesktopSidebarViewProps): React.ReactE
569
594
  {/* R2 · L2-1-1:「展开显示」原来是一行没有图标、没有 chevron、
570
595
  与会话行同缩进同字号的 --ow-text-3 灰字(浅档 2.29:1),读起来像
571
596
  一条被禁用的会话。补一枚 14 的 ChevDown 前置图标(与同族
572
- .owthread-new 的 Plus 同语法),让它读成动作;字色并轨见 CSS。 */}
573
- <ChevDown className="lead" aria-hidden="true" />
574
- {project.loadingMore ? "加载中…" : project.loadError ? "加载失败,点击重试" : "展开显示"}
597
+ .owthread-new 的 Plus 同语法),让它读成动作;字色并轨见 CSS。
598
+ 加载中同理:纯文案太朴素,换成 spinner + 文案(#34)。 */}
599
+ {project.loadingMore ? (
600
+ <>
601
+ <Spinner size="sm" />
602
+ 加载中…
603
+ </>
604
+ ) : (
605
+ <>
606
+ <ChevDown className="lead" aria-hidden="true" />
607
+ {project.loadError ? "加载失败,点击重试" : "展开显示"}
608
+ </>
609
+ )}
575
610
  </Button>
576
611
  )}
577
612
  </div>
578
613
  )}
579
614
  </div>
580
- ))}
615
+ ))
616
+ )}
581
617
  </div>
582
618
 
583
619
  {projectsMenu && (
@@ -614,7 +650,7 @@ export function DesktopSidebarView(props: DesktopSidebarViewProps): React.ReactE
614
650
  key: "move",
615
651
  label: "移动",
616
652
  icon: <Folder />,
617
- disabled: Boolean(threadById(threadMenu.id)?.running || threadById(threadMenu.id)?.archived),
653
+ disabled: Boolean(threadById(threadMenu.id)?.running),
618
654
  },
619
655
  ...(!threadById(threadMenu.id)?.archived
620
656
  ? ([
@@ -352,8 +352,8 @@ export function DesktopSitesListView({
352
352
  {(hasMore || error) && (
353
353
  <div className="sites-grid__more">
354
354
  {error && <InlineMessage variant="error" title="暂时无法加载更多站点" detail={error} />}
355
- <Button disabled={loading} onClick={error ? onRetry : onLoadMore}>
356
- {loading ? "正在加载…" : error ? "重试" : "加载更多"}
355
+ <Button loading={loading} onClick={error ? onRetry : onLoadMore}>
356
+ {error ? "重试" : "加载更多"}
357
357
  </Button>
358
358
  </div>
359
359
  )}
@@ -207,7 +207,10 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
207
207
  )
208
208
  )}
209
209
  {children && (
210
- <span data-ow-part="label" className="inline-flex items-center gap-[inherit] whitespace-nowrap">
210
+ // min-w-0:受 max-w 约束的按钮(如 composer 模型选择器)要能在窄容器里
211
+ // 收缩,否则内部 nowrap 文本撑破按钮边界、溢出覆盖相邻控件。真正的省略
212
+ // 号由调用方的 label span(overflow-hidden text-ellipsis)承担。
213
+ <span data-ow-part="label" className="inline-flex min-w-0 items-center gap-[inherit] whitespace-nowrap">
211
214
  {children}
212
215
  </span>
213
216
  )}
@@ -900,8 +900,9 @@ export function Menu({
900
900
  /**
901
901
  * 向上找最近的带 transform/perspective/filter/will-change 的祖先 —— 它会给
902
902
  * fixed 元素造出新的包含块,让坐标不再相对视口。没有就返回 null。
903
+ * (Select 的 portal 菜单量位也复用这个。)
903
904
  */
904
- function findFixedOffsetAncestor(el: HTMLElement): HTMLElement | null {
905
+ export function findFixedOffsetAncestor(el: HTMLElement): HTMLElement | null {
905
906
  let parent = el.parentElement
906
907
  while (parent && parent !== document.body && parent !== document.documentElement) {
907
908
  const style = getComputedStyle(parent)
@@ -8,11 +8,14 @@ import {
8
8
  forwardRef,
9
9
  useEffect,
10
10
  useId,
11
+ useLayoutEffect,
11
12
  useRef,
12
13
  useState,
13
14
  } from "react"
15
+ import { createPortal } from "react-dom" // ui-imports-guard-disable-line
14
16
  import { cn } from "../../../lib/utils"
15
17
  import { Check, ChevDown } from "../../composite/Icons"
18
+ import { findFixedOffsetAncestor } from "../Menu"
16
19
 
17
20
  export type SelectOption = {
18
21
  value: string
@@ -52,22 +55,14 @@ const selectTriggerVariants = cva(
52
55
 
53
56
  const selectMenuVariants = cva(
54
57
  [
55
- "absolute left-0 right-0 z-[var(--ow-z-dropdown)] p-1.5 max-h-[264px] overflow-y-auto",
58
+ // R6:菜单纯 portal document.body fixed 定位(量位见下方 effect)。
59
+ // 原先 absolute 挂在触发器下,会被 Dialog 这类 overflow-hidden 立面裁到只剩
60
+ // 一行——裁剪抬 z-index 解决不了(MenuSurface portal 档注释同款结论)。
61
+ "p-1.5 max-h-[264px] overflow-y-auto",
56
62
  "bg-[var(--ow-popover-bg)] border border-ow-border",
57
63
  "shadow-[var(--ow-shadow-pop)]",
58
64
  "[border-radius:var(--ow-radius-menu)]",
59
65
  ].join(" "),
60
- {
61
- variants: {
62
- position: {
63
- down: "top-[calc(100%+4px)]",
64
- up: "bottom-[calc(100%+4px)]",
65
- },
66
- },
67
- defaultVariants: {
68
- position: "down",
69
- },
70
- },
71
66
  )
72
67
 
73
68
  const selectOptionVariants = cva(
@@ -187,20 +182,28 @@ export function Select({
187
182
  className,
188
183
  }: SelectProps) {
189
184
  const [open, setOpen] = useState(false)
190
- const [up, setUp] = useState(false)
191
185
  const [activeIndex, setActiveIndex] = useState(-1)
186
+ const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null)
192
187
  const wrapRef = useRef<HTMLDivElement>(null)
193
188
  const buttonRef = useRef<HTMLButtonElement>(null)
194
189
  const listRef = useRef<HTMLDivElement>(null)
195
190
  const listId = useId()
196
191
  const selected = options.find((option) => option.value === value)
197
192
  const optionId = (index: number) => `${listId}-opt-${index}`
193
+ // 菜单纯 portal 到 body 后与 Dialog 同处 --ow-z-modal 层:弹层晚于对话框挂载(DOM
194
+ // 序在后),同层相叠画在立面之上;用 --ow-z-dropdown 会落到模态 scrim 下面。
198
195
 
199
196
  useEffect(() => {
200
197
  if (!open) return undefined
201
198
 
202
199
  const closeOnOutsideClick = (event: MouseEvent) => {
203
- if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
200
+ const target = event.target as Node
201
+ // 菜单 portal 到 body,不在 wrapRef 子树里,命中面本体时不算外点。
202
+ if (
203
+ wrapRef.current &&
204
+ !wrapRef.current.contains(target) &&
205
+ !(listRef.current && listRef.current.contains(target))
206
+ ) {
204
207
  setOpen(false)
205
208
  }
206
209
  }
@@ -225,12 +228,48 @@ export function Select({
225
228
  listRef.current?.querySelector(`[data-index="${activeIndex}"]`)?.scrollIntoView({ block: "nearest" })
226
229
  }, [open, activeIndex])
227
230
 
231
+ // Portal 量位:贴触发器下缘,下方空间不足翻到下缘上方;滚动/缩放时跟锚点重算。
232
+ // transform 祖先(如 Dialog 的入场动画)会改写 fixed 的包含块,偏移要减回去。
233
+ useLayoutEffect(() => {
234
+ if (!open) return undefined
235
+ const el = listRef.current
236
+ const button = buttonRef.current
237
+ if (!el || !button) return undefined
238
+ const measure = () => {
239
+ const anchorRect = button.getBoundingClientRect()
240
+ // 菜单宽跟随触发器(原 absolute left-0 right-0 的同款语义),先写死再量高度。
241
+ el.style.width = `${anchorRect.width}px`
242
+ const dropRect = el.getBoundingClientRect()
243
+ const offsetParent = findFixedOffsetAncestor(el)
244
+ const offsetX = offsetParent ? offsetParent.getBoundingClientRect().left : 0
245
+ const offsetY = offsetParent ? offsetParent.getBoundingClientRect().top : 0
246
+
247
+ let top = anchorRect.bottom + 4 - offsetY
248
+ if (anchorRect.bottom + 4 + dropRect.height > window.innerHeight - 8) {
249
+ top = anchorRect.top - dropRect.height - 4 - offsetY
250
+ }
251
+ if (top + offsetY < 8) top = 8 - offsetY
252
+ let left = anchorRect.left - offsetX
253
+ if (left + offsetX + dropRect.width > window.innerWidth - 8) {
254
+ left = window.innerWidth - dropRect.width - 8 - offsetX
255
+ }
256
+ if (left + offsetX < 8) left = 8 - offsetX
257
+
258
+ setMenuPos((current) => (current && current.top === top && current.left === left ? current : { top, left }))
259
+ }
260
+ // 等菜单渲染完再量,否则拿不到高度。
261
+ const raf = requestAnimationFrame(measure)
262
+ window.addEventListener("resize", measure)
263
+ window.addEventListener("scroll", measure, true)
264
+ return () => {
265
+ cancelAnimationFrame(raf)
266
+ window.removeEventListener("resize", measure)
267
+ window.removeEventListener("scroll", measure, true)
268
+ }
269
+ }, [open, options.length])
270
+
228
271
  const openMenu = () => {
229
272
  if (disabled) return
230
- if (buttonRef.current) {
231
- const rect = buttonRef.current.getBoundingClientRect()
232
- setUp(window.innerHeight - rect.bottom < 300)
233
- }
234
273
  const selectedIndex = options.findIndex((option) => option.value === value)
235
274
  setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0)
236
275
  setOpen(true)
@@ -313,52 +352,55 @@ export function Select({
313
352
  <span className="text-[color:var(--ow-placeholder)]">{placeholder}</span>
314
353
  )}
315
354
  </SelectTrigger>
316
- {open && (
317
- <div
318
- ref={listRef}
319
- id={listId}
320
- data-ow-part="list"
321
- className={selectMenuVariants({ position: up ? "up" : "down" })}
322
- role="listbox"
323
- >
324
- {options.map((option, index) => (
325
- <button
326
- key={option.value}
327
- id={optionId(index)}
328
- type="button"
329
- role="option"
330
- tabIndex={-1}
331
- aria-selected={option.value === value}
332
- data-index={index}
333
- data-ow-role={option.value === value ? "selected" : "option"}
334
- className={selectOptionVariants({ active: index === activeIndex })}
335
- onMouseDown={(event) => event.preventDefault()}
336
- onMouseEnter={() => setActiveIndex(index)}
337
- onClick={() => pick(option.value)}
338
- >
339
- <span className="flex-1 min-w-0 flex flex-col gap-0.5">
340
- <span
341
- className={cn(
342
- "overflow-hidden text-ellipsis whitespace-nowrap",
343
- option.value === value && "font-medium",
355
+ {open &&
356
+ createPortal(
357
+ <div
358
+ ref={listRef}
359
+ id={listId}
360
+ data-ow-part="list"
361
+ className={cn(selectMenuVariants(), "fixed z-[var(--ow-z-modal)]", !menuPos && "invisible")}
362
+ style={{ top: menuPos?.top ?? 0, left: menuPos?.left ?? 0 }}
363
+ role="listbox"
364
+ >
365
+ {options.map((option, index) => (
366
+ <button
367
+ key={option.value}
368
+ id={optionId(index)}
369
+ type="button"
370
+ role="option"
371
+ tabIndex={-1}
372
+ aria-selected={option.value === value}
373
+ data-index={index}
374
+ data-ow-role={option.value === value ? "selected" : "option"}
375
+ className={selectOptionVariants({ active: index === activeIndex })}
376
+ onMouseDown={(event) => event.preventDefault()}
377
+ onMouseEnter={() => setActiveIndex(index)}
378
+ onClick={() => pick(option.value)}
379
+ >
380
+ <span className="flex-1 min-w-0 flex flex-col gap-0.5">
381
+ <span
382
+ className={cn(
383
+ "overflow-hidden text-ellipsis whitespace-nowrap",
384
+ option.value === value && "font-medium",
385
+ )}
386
+ >
387
+ {option.label}
388
+ </span>
389
+ {option.sub && (
390
+ <span className="text-ow-fs-eyebrow font-normal leading-snug text-[color:var(--ow-text-3)] overflow-hidden text-ellipsis whitespace-nowrap">
391
+ {option.sub}
392
+ </span>
344
393
  )}
345
- >
346
- {option.label}
347
394
  </span>
348
- {option.sub && (
349
- <span className="text-ow-fs-eyebrow font-normal leading-snug text-[color:var(--ow-text-3)] overflow-hidden text-ellipsis whitespace-nowrap">
350
- {option.sub}
351
- </span>
395
+ {option.value === value && (
396
+ <Check className="shrink-0 w-4 h-4 text-[color:var(--ow-text)]" aria-hidden="true" />
352
397
  )}
353
- </span>
354
- {option.value === value && (
355
- <Check className="shrink-0 w-4 h-4 text-[color:var(--ow-text)]" aria-hidden="true" />
356
- )}
357
- </button>
358
- ))}
359
- {footer}
360
- </div>
361
- )}
398
+ </button>
399
+ ))}
400
+ {footer}
401
+ </div>,
402
+ document.body,
403
+ )}
362
404
  </div>
363
405
  )
364
406
  }