@allbluecn/web-app 0.21.0 → 0.22.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 (74) hide show
  1. package/package.json +3 -3
  2. package/src/components/billing/__tests__/use-resource.test.tsx +2 -0
  3. package/src/components/billing/subscription-provider.tsx +2 -0
  4. package/src/components/bug/create-bug-dialog.tsx +1 -1
  5. package/src/components/bug/detail/props-section.tsx +1 -0
  6. package/src/components/bug/inline-editors/labels-editor.tsx +4 -1
  7. package/src/components/bug/views/bug-list/batch-toolbar.tsx +5 -2
  8. package/src/components/bug/views/bug-list/inline-bug-creator.tsx +1 -1
  9. package/src/components/bug/views/bug-list/row.tsx +1 -1
  10. package/src/components/design/editor/dashboard/design-dashboard.tsx +21 -7
  11. package/src/components/design/editor/layout/design-navigation.tsx +22 -12
  12. package/src/components/harness/dock/dock-panel-container.tsx +17 -4
  13. package/src/components/harness/harness-workbench-page.tsx +14 -1
  14. package/src/components/harness/todo-panel.tsx +2 -5
  15. package/src/components/harness/use-todo-projection.ts +3 -3
  16. package/src/components/story/ai/story-ai-panel.tsx +2 -2
  17. package/src/components/story/create-story-dialog.tsx +1 -1
  18. package/src/components/story/detail/story-collab-gate.tsx +191 -0
  19. package/src/components/story/detail/story-detail-body.tsx +5 -169
  20. package/src/components/story/detail/story-properties.tsx +1 -0
  21. package/src/components/story/filters/field-configs.tsx +6 -0
  22. package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +2 -1
  23. package/src/components/story/inline-editors/labels-editor.tsx +14 -10
  24. package/src/components/story/peek-panel.tsx +1 -1
  25. package/src/components/story/relations/story-ref-row.tsx +1 -1
  26. package/src/components/story/views/story-list/batch-toolbar.tsx +1 -1
  27. package/src/components/story/views/story-list/inline-story-creator.tsx +1 -0
  28. package/src/components/story/views/story-list/row.tsx +1 -1
  29. package/src/components/testcase/runs/convert-bug-button.tsx +1 -1
  30. package/src/design-engine/document/serialize-read.ts +30 -1
  31. package/src/design-engine/engine/app.ts +18 -1
  32. package/src/design-engine/engine/engine-base.ts +2 -0
  33. package/src/lib/changelog/sdk-versions.ts +4 -4
  34. package/src/lib/collections/reference/labels-collection.ts +1 -1
  35. package/src/paraglide/messages/_index.d.ts +4 -0
  36. package/src/paraglide/messages/bug_list_loadmorefailed2.d.ts +17 -0
  37. package/src/paraglide/messages/bug_list_loadmorefailed2.js +30 -0
  38. package/src/paraglide/messages/bug_storypanel_partialfailed2.d.ts +19 -0
  39. package/src/paraglide/messages/bug_storypanel_partialfailed2.js +30 -0
  40. package/src/paraglide/messages/chat_workspace_githubnotconnected2.d.ts +17 -0
  41. package/src/paraglide/messages/chat_workspace_githubnotconnected2.js +30 -0
  42. package/src/paraglide/messages/dashboard_activityfeed_ai1.d.ts +17 -0
  43. package/src/paraglide/messages/dashboard_activityfeed_ai1.js +30 -0
  44. package/src/routes/_nav/bugs/$bugId/route.tsx +1 -1
  45. package/src/routes/_nav/bugs/index.tsx +1 -1
  46. package/src/routes/_nav/dashboard/-components/activity-feed.tsx +23 -4
  47. package/src/routes/_nav/dashboard/route.lazy.tsx +6 -6
  48. package/src/routes/_nav/dashboard/route.tsx +11 -4
  49. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +35 -1
  50. package/src/routes/_nav/stories/$storyId/route.tsx +1 -1
  51. package/src/routes/_nav/stories/index.tsx +1 -1
  52. package/src/routes/api/billing-webhook.ts +1 -1
  53. package/src/server/__tests__/resources.agents.test.ts +2 -0
  54. package/src/server/__tests__/resources.license.test.ts +2 -0
  55. package/src/server/__tests__/resources.team-seats.test.ts +2 -0
  56. package/src/server/__tests__/resources.test.ts +2 -0
  57. package/src/server/ai-gateway/__tests__/pgvector-memory-adapter.test.ts +16 -0
  58. package/src/server/ai-gateway/memory/pgvector-adapter.ts +14 -0
  59. package/src/server/ai-gateway/storage/__tests__/blob-store.test.ts +4 -0
  60. package/src/server/ai-gateway/storage/db-blob-store.ts +4 -0
  61. package/src/server/billing-email.ts +18 -5
  62. package/src/server/bug/bug-crud-helpers.ts +107 -0
  63. package/src/server/knowledge/knowledge-helpers.ts +40 -0
  64. package/src/server/resources.ts +43 -2
  65. package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +27 -14
  66. package/src/server/serverFns/activity.ts +61 -0
  67. package/src/server/serverFns/anyhub/install.ts +3 -0
  68. package/src/server/serverFns/bug/bug-crud.ts +10 -83
  69. package/src/server/serverFns/knowledge.ts +2 -17
  70. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +2 -2
  71. package/src/server/serverFns/story/kb-scope.ts +6 -6
  72. package/src/server/serverFns/story/story-crud/labels.ts +20 -7
  73. package/src/server/story/story-crud/label-upsert.ts +47 -9
  74. package/src/start.ts +15 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/web-app",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "type": "module",
6
6
  "files": [
@@ -60,8 +60,8 @@
60
60
  "dependencies": {
61
61
  "@ag-ui/core": "1.0.0",
62
62
  "@allbluecn/collab-core": "0.2.0",
63
- "@allbluecn/database": "0.10.1",
64
- "@allbluecn/kernel": "0.11.0",
63
+ "@allbluecn/database": "0.11.0",
64
+ "@allbluecn/kernel": "0.12.0",
65
65
  "@ariakit/react": "^0.4.28",
66
66
  "@auth/core": "^0.41.2",
67
67
  "@auth/prisma-adapter": "^2.11.2",
@@ -20,6 +20,8 @@ const SNAPSHOTS: ResourceSnapshots = {
20
20
  sandboxMinutesMonthly: { limit: 'unlimited', usage: 0 },
21
21
  mediaGenerationsMonthly: { limit: 100, usage: 5 },
22
22
  galleryStorageMb: { limit: 500, usage: 50 },
23
+ installQuota: { limit: 'unlimited' as const, usage: 0 },
24
+ blobStorageMb: { limit: 'unlimited' as const, usage: 0 },
23
25
  }
24
26
 
25
27
  function setup(resources: ResourceSnapshots | null, loading = false) {
@@ -24,6 +24,8 @@ const DEFAULT_OSS_RESOURCES: ResourceSnapshots = {
24
24
  sandboxMinutesMonthly: { limit: 'unlimited', usage: 0 },
25
25
  mediaGenerationsMonthly: { limit: 0, usage: 0 },
26
26
  galleryStorageMb: { limit: 0, usage: 0 },
27
+ installQuota: { limit: 'unlimited', usage: 0 },
28
+ blobStorageMb: { limit: 'unlimited', usage: 0 },
27
29
  }
28
30
 
29
31
  function buildOssFallback(): PlanState {
@@ -301,7 +301,7 @@ export function CreateBugDialog({
301
301
  onChange={setAssigneeId}
302
302
  variant="button"
303
303
  />
304
- <LabelsEditor labelIds={labelIds} labels={labels} onChange={setLabelIds} variant="button" />
304
+ <LabelsEditor projectId={projectId} labelIds={labelIds} labels={labels} onChange={setLabelIds} variant="button" />
305
305
  </div>
306
306
 
307
307
  <div className="space-y-2">
@@ -342,6 +342,7 @@ export function PropsSection({ bug, users, labels, onBugUpdate, onStoryChange, c
342
342
  </PropertyRow>
343
343
  <PropertyRow label={m.bug_detail_labels()}>
344
344
  <LabelsEditor
345
+ projectId={bug.projectId}
345
346
  labelIds={bug.labels.map((l) => l.id)}
346
347
  labels={labels}
347
348
  onChange={(labelIds) => void updateField({ labelIds })}
@@ -23,6 +23,8 @@ import { InlineTrigger } from "./inline-trigger";
23
23
  import { m } from "@/paraglide/messages.js";
24
24
 
25
25
  interface LabelsEditorProps {
26
+ /** S24:标签归属项目(创建必挂;本组件仅勾选既有标签,不触发创建)。 */
27
+ projectId?: string;
26
28
  labelIds: string[];
27
29
  labels: { id: string; name: string; color: string }[];
28
30
  onChange: (labelIds: string[]) => void;
@@ -31,7 +33,8 @@ interface LabelsEditorProps {
31
33
  variant?: "chip" | "button";
32
34
  }
33
35
 
34
- export function LabelsEditor({ labelIds, labels, onChange, className, variant = "chip" }: LabelsEditorProps) {
36
+ export function LabelsEditor({ projectId, labelIds, labels, onChange, className, variant = "chip" }: LabelsEditorProps) {
37
+ void projectId;
35
38
  const [open, setOpen] = useState(false);
36
39
  const showChip = variant === "chip";
37
40
 
@@ -48,6 +48,9 @@ export interface BugBatchToolbarProps {
48
48
  selectedIds: string[];
49
49
  users: UserBrief[];
50
50
  labels: { id: string; name: string; color: string }[];
51
+ /** S24:批量场景无单一项目归属(全部项目 scope)时缺省——新建标签会被
52
+ * 服务端拒绝(标签必挂项目),勾选既有标签不受影响 */
53
+ projectId?: string;
51
54
  onToggleAll: () => void;
52
55
  onArchive: (bugIds: string[]) => void;
53
56
  onDelete: (bugIds: string[]) => void;
@@ -68,7 +71,7 @@ export function BugBatchToolbar({
68
71
  onDelete,
69
72
  onBulkUpdate,
70
73
  onClearSelection,
71
- }: BugBatchToolbarProps) {
74
+ projectId,}: BugBatchToolbarProps) {
72
75
  const [archiveOpen, setArchiveOpen] = useState(false);
73
76
  const [deleteOpen, setDeleteOpen] = useState(false);
74
77
 
@@ -93,7 +96,7 @@ export function BugBatchToolbar({
93
96
  <StateEditor value="open" onChange={(v) => onBulkUpdate({ state: v })} variant="button" />
94
97
  <SeverityEditor value="medium" onChange={(v) => onBulkUpdate({ severity: v })} variant="button" />
95
98
  <AssigneeEditor assignee={null} users={users} onChange={(v) => onBulkUpdate({ assigneeId: v })} variant="button" />
96
- <LabelsEditor labelIds={[]} labels={labels} onChange={(v) => onBulkUpdate({ labelIds: v })} variant="button" />
99
+ <LabelsEditor projectId={projectId ?? ""} labelIds={[]} labels={labels} onChange={(v) => onBulkUpdate({ labelIds: v })} variant="button" />
97
100
  </div>
98
101
  </BatchToolbarShell>
99
102
 
@@ -108,7 +108,7 @@ export const InlineBugCreator = memo(function InlineBugCreator({
108
108
  <SeverityEditor value={severity} onChange={setSeverity} variant="button" />
109
109
  <StateEditor value={state} onChange={setState} variant="button" />
110
110
  <AssigneeEditor assignee={users.find((u) => u.id === assigneeId) ?? null} users={users} onChange={setAssigneeId} variant="button" />
111
- <LabelsEditor labelIds={labelIds} labels={labels} onChange={setLabelIds} variant="button" />
111
+ <LabelsEditor projectId={projectId} labelIds={labelIds} labels={labels} onChange={setLabelIds} variant="button" />
112
112
  <Button
113
113
  variant="ghost"
114
114
  size="icon-xs"
@@ -167,7 +167,7 @@ export const BugListRow = memo(function BugListRow({ bug, users, labels, propert
167
167
  <AssigneeEditor assignee={bug.assignee} users={users} onChange={setAssignee} variant="button" />
168
168
  )}
169
169
  {properties.includes("labels") && (
170
- <LabelsEditor labelIds={currentLabelIds} labels={labels} onChange={setLabels} variant="button" />
170
+ <LabelsEditor projectId={bug.projectId} labelIds={currentLabelIds} labels={labels} onChange={setLabels} variant="button" />
171
171
  )}
172
172
  {properties.includes("source") && (
173
173
  <span className={cn("inline-flex shrink-0 items-center rounded-sm border border-border px-1.5 py-0.5 text-xs", SOURCE_COLOR[bug.source]?.split(" ").find((c) => c.startsWith("text")))}>
@@ -28,15 +28,29 @@ import { LayoutPanelTopIcon } from "@/components/shared-kit/layout/layout-panel-
28
28
  import { ConfirmDialog } from "@/components/confirm-dialog";
29
29
  import { toast } from "@/components/ui/toast";
30
30
  import { CreateDesignDialog } from "./create-design-dialog";
31
- import { ICON_MAP, type Design } from "@allbluecn/kernel";
31
+ import type { Design } from "@allbluecn/kernel";
32
+ import type { ComponentType } from "react";
33
+ // S20:ICON_MAP 副本(同 design-navigation;kernel 下一 minor 移除导出)。
34
+ // 键清单须与 kernel contract/types/design.ts ICON_MAP 保持一致。
35
+ const ICON_MAP: Record<string, ComponentType<{ className?: string }>> = {
36
+ folder: Folder, file: FileText, chart: BarChart3, users: Users, settings: SettingsIcon,
37
+ package: Package, database: Database, cart: ShoppingCart, heart: Heart, sparkles: Sparkles,
38
+ link: LinkIcon, message: MessageSquare, calendar: Calendar, bell: Bell, building2: Building2,
39
+ squareChartGantt: SquareChartGantt, heartHandshake: HeartHandshake, shrimp: Shrimp,
40
+ fish: Fish, venetianMask: VenetianMask, graduationCap: GraduationCap, handFist: HandFist,
41
+ loaderPinwheel: LoaderPinwheel, toolCase: ToolCase, partyPopper: PartyPopper,
42
+ pencilRuler: PencilRuler, circleStar: CircleStar, brain: Brain, badgeDollarSign: BadgeDollarSign,
43
+ earth: Earth, music4: Music4,
44
+ };
32
45
  import { listDesignsFn, deleteDesignFn } from "@/server/serverFns/design/designs";
33
46
  import {
34
- Clock,
35
- FileText,
36
- LoaderPinwheel,
37
- Plus,
38
- Search,
39
- Trash2,
47
+ Clock, FileText, LoaderPinwheel, Plus, Search, Trash2,
48
+ Folder, BarChart3, Users, Settings as SettingsIcon,
49
+ Package, Database, ShoppingCart, Heart, Sparkles, Link as LinkIcon,
50
+ MessageSquare, Calendar, Bell, Building2, SquareChartGantt,
51
+ HeartHandshake, Shrimp, Fish, VenetianMask, GraduationCap,
52
+ HandFist, ToolCase, PartyPopper, PencilRuler,
53
+ CircleStar, Brain, BadgeDollarSign, Earth, Music4,
40
54
  } from "lucide-react";
41
55
  import { formatDate } from "@/lib/datetime";
42
56
  import { useTimezone } from "@/components/shared-kit/timezone/timezone-provider";
@@ -32,20 +32,30 @@ import {
32
32
  } from "@/components/ui/dropdown-menu";
33
33
  import { NameConfirmDialog } from "@/components/shared-kit/dialogs/name-confirm-dialog";
34
34
  import { useRouter } from "@tanstack/react-router";
35
- import { ICON_MAP, type Design } from "@allbluecn/kernel";
35
+ import type { Design } from "@allbluecn/kernel";
36
+ import type { ComponentType } from "react";
37
+ // S20(两步走第一步):ICON_MAP 副本落 web——kernel 下一 minor 移除导出
38
+ // (Pro web-saas 镜像同步后)。键清单须与 kernel contract/types/design.ts
39
+ // ICON_MAP 保持一致(39 键)。
40
+ const ICON_MAP: Record<string, ComponentType<{ className?: string }>> = {
41
+ folder: Folder, file: FileText, chart: BarChart3, users: Users, settings: SettingsIcon,
42
+ package: Package, database: Database, cart: ShoppingCart, heart: Heart, sparkles: Sparkles,
43
+ link: LinkIcon, message: MessageSquare, calendar: Calendar, bell: Bell, building2: Building2,
44
+ squareChartGantt: SquareChartGantt, heartHandshake: HeartHandshake, shrimp: Shrimp,
45
+ fish: Fish, venetianMask: VenetianMask, graduationCap: GraduationCap, handFist: HandFist,
46
+ loaderPinwheel: LoaderPinwheel, toolCase: ToolCase, partyPopper: PartyPopper,
47
+ pencilRuler: PencilRuler, circleStar: CircleStar, brain: Brain, badgeDollarSign: BadgeDollarSign,
48
+ earth: Earth, music4: Music4,
49
+ };
36
50
  import { deleteDesignFn } from "@/server/serverFns/design/designs";
37
51
  import {
38
- Share2,
39
- History,
40
- Download,
41
- Save,
42
- Pencil,
43
- Check,
44
- X,
45
- FileText,
46
- Settings as SettingsIcon,
47
- Trash2,
48
- MoreVertical,
52
+ Share2, History, Download, Save, Pencil, Check, X, FileText,
53
+ Settings as SettingsIcon, Trash2, MoreVertical,
54
+ Folder, BarChart3, Users, Package, Database, ShoppingCart, Heart,
55
+ Sparkles, Link as LinkIcon, MessageSquare, Calendar, Bell, Building2,
56
+ SquareChartGantt, HeartHandshake, Shrimp, Fish, VenetianMask,
57
+ GraduationCap, HandFist, LoaderPinwheel, ToolCase, PartyPopper,
58
+ PencilRuler, CircleStar, Brain, BadgeDollarSign, Earth, Music4,
49
59
  } from "lucide-react";
50
60
  interface DesignNavigationProps {
51
61
  prd: Design;
@@ -16,7 +16,7 @@
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
18
 
19
- import { useEffect, useSyncExternalStore } from "react"
19
+ import { useEffect, useRef, useSyncExternalStore } from "react"
20
20
  import { ListTodo, Puzzle, FileText, X } from "lucide-react"
21
21
  import { Button } from "@/components/ui/button"
22
22
  import { TodoPanel } from "@/components/harness/todo-panel"
@@ -26,6 +26,16 @@ import { parseResourceUri } from "./resource-uri"
26
26
  import { ResourcePreview } from "./resource-preview"
27
27
  import { m } from "@/paraglide/messages.js"
28
28
 
29
+ /**
30
+ * S28(裁决:最小实现)接线语义:
31
+ * - TodoPanel:消息源 = harness 会话真实消息(chatMessagesRef 透传,非占位 []);
32
+ * - PluginPanel:onClose = 关闭自身 tab(此前 no-op,用户无关闭入口——tab 条
33
+ * 上的 X 关闭的是整个 tab 但面板内按钮空转)。
34
+ * 待上游扩展(不阻塞):todo 的跨会话持久化(当前随会话消息投影)。
35
+ */
36
+ type ChatMessageLike = import("@/components/harness/use-todo-projection").TodoMessageLike;
37
+ type TodoMessageSource = ChatMessageLike[];
38
+
29
39
  function PlaceholderTab() {
30
40
  return (
31
41
  <div className="flex h-full items-center justify-center p-4 text-sm text-muted-foreground">
@@ -34,22 +44,25 @@ function PlaceholderTab() {
34
44
  )
35
45
  }
36
46
 
37
- export function DockPanelContainer() {
47
+ export function DockPanelContainer({ chatMessages }: { chatMessages?: ChatMessageLike[] }) {
38
48
  const state = useSyncExternalStore(
39
49
  (onChange) => dockRegistry.subscribe(onChange),
40
50
  () => dockRegistry.getState(),
41
51
  )
52
+ // ref 保新值:registry render 闭包捕获 container 的 props 引用即可
53
+ const chatMessagesRef = useRef<readonly ChatMessageLike[]>(chatMessages ?? [])
54
+ chatMessagesRef.current = chatMessages ?? []
42
55
 
43
56
  useEffect(() => {
44
57
  dockRegistry.registerPanel({
45
58
  id: "todo",
46
59
  title: m.harness_dock_todo(),
47
- render: () => <TodoPanel messages={[]} />,
60
+ render: () => <TodoPanel messages={[...chatMessagesRef.current]} />,
48
61
  })
49
62
  dockRegistry.registerPanel({
50
63
  id: "plugin",
51
64
  title: m.harness_dock_plugin(),
52
- render: () => <PluginPanel onClose={() => {}} />,
65
+ render: () => <PluginPanel onClose={() => dockRegistry.closeTab("plugin")} />,
53
66
  })
54
67
  // 卸载时注销面板,避免模块级 registry 永久残留
55
68
  return () => {
@@ -239,6 +239,11 @@ export default function HarnessWorkbenchPage({ initialThreadId, initialMessages
239
239
 
240
240
  const mediaPanelRef = useRef({ mediaRoute, threadId: activeConversationId })
241
241
  mediaPanelRef.current = { mediaRoute, threadId: activeConversationId }
242
+ // S28(裁决:最小实现):TodoPanel 真实消息源——chat.messages 经 ref 透传
243
+ // 给 dock(registry render 闭包稳定,ref 保新值,避免 panel 注册随消息刷新)
244
+ // UIMessage 结构兼容 TodoPanel 的 TodoMessageLike 投影面(role + parts)
245
+ const chatMessagesRef = useRef(chat.messages)
246
+ chatMessagesRef.current = chat.messages
242
247
 
243
248
  useEffect(() => {
244
249
  dockRegistry.registerPanel({
@@ -573,7 +578,15 @@ export default function HarnessWorkbenchPage({ initialThreadId, initialMessages
573
578
  />
574
579
  }
575
580
  center={centerContent}
576
- rail={dockOpen ? <DockPanelContainer /> : null}
581
+ rail={
582
+ dockOpen ? (
583
+ <DockPanelContainer
584
+ // UIMessage 与 TodoMessageLike 的投影面(role+parts)结构兼容;
585
+ // 判别联合差异在此单点 as 收窄(S28)
586
+ chatMessages={chatMessagesRef.current as unknown as import('@/components/harness/use-todo-projection').TodoMessageLike[]}
587
+ />
588
+ ) : null
589
+ }
577
590
  />
578
591
 
579
592
  <ConfirmDialog
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { Circle, CircleCheck, ListTodo, Loader2 } from "lucide-react";
20
20
  import { m } from "@/paraglide/messages.js";
21
- import { useTodoProjection, type TodoItem } from "./use-todo-projection";
21
+ import { useTodoProjection } from "./use-todo-projection";
22
22
 
23
23
  const STATUS_ICON = {
24
24
  pending: Circle,
@@ -35,10 +35,7 @@ const STATUS_COLOR = {
35
35
  export function TodoPanel({
36
36
  messages,
37
37
  }: {
38
- messages: Array<{
39
- role: string;
40
- parts: Array<{ type: string; toolName?: string; input?: { todos?: TodoItem[] } }>;
41
- }>;
38
+ messages: readonly import("./use-todo-projection").TodoMessageLike[];
42
39
  }) {
43
40
  const todos = useTodoProjection(messages);
44
41
 
@@ -26,15 +26,15 @@ export interface TodoItem {
26
26
 
27
27
  export type TodoMessageLike = {
28
28
  role: string;
29
- parts: Array<{
29
+ parts: readonly {
30
30
  type: string;
31
31
  toolName?: string;
32
32
  input?: { todos?: TodoItem[] };
33
- }>;
33
+ }[];
34
34
  };
35
35
 
36
36
  export function useTodoProjection(
37
- messages: TodoMessageLike[],
37
+ messages: readonly TodoMessageLike[],
38
38
  ): TodoItem[] {
39
39
  return useMemo(() => {
40
40
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -220,9 +220,9 @@ export function StoryAiContent({
220
220
  let finalLabelIds = item.labelIds
221
221
  if (unmatched.length > 0) {
222
222
  const createdLabels = await Promise.all(
223
- unmatched.map((n) => createLabelFn({ data: { name: n } })),
223
+ unmatched.map((n) => createLabelFn({ data: { name: n, projectId: gen.projectId } })),
224
224
  )
225
- finalLabelIds = Array.from(new Set([...item.labelIds, ...createdLabels.map((c) => c.id)]))
225
+ finalLabelIds = Array.from(new Set([...item.labelIds, ...createdLabels.map((c: { id: string }) => c.id)]))
226
226
  }
227
227
  const created = await createStoryFn({
228
228
  data: {
@@ -336,7 +336,7 @@ export function CreateStoryDialog({
336
336
  <DateEditor value={field.value || null} onChange={(v) => field.handleChange(v ?? "")} label={m.story_create_dueDate()} icon={CalendarCheck} />
337
337
  )} />
338
338
  <form.Field name="labelIds" children={(field) => (
339
- <LabelsEditor labelIds={field.value as string[]} labels={labels} onChange={(v) => field.handleChange(v as string[])} variant="button" aiContext={{ name: () => ((form.state.values.name as string) ?? "").trim() }} />
339
+ <LabelsEditor projectId={(form.state.values.projectId as string) ?? ""} labelIds={field.value as string[]} labels={labels} onChange={(v) => field.handleChange(v as string[])} variant="button" aiContext={{ name: () => ((form.state.values.name as string) ?? "").trim() }} />
340
340
  )} />
341
341
  </div>
342
342
 
@@ -0,0 +1,191 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { useMemo, useRef, type ReactNode } from "react";
19
+ import { useSession } from "@/components/shared-kit/auth/auth-client";
20
+ import { useSelfProfileBroadcast } from "@/lib/collab/use-self-profile-broadcast";
21
+ import type { StoryState } from "@/shared/story/constants";
22
+ import type { StoryDetail, UserBrief, CommentWithReplies, ActivityItem, RelationsData } from "@/shared/story/types";
23
+ import type { StoryPatch } from "../display/group-fields";
24
+ import type { Editor } from "@tiptap/react";
25
+ import type { NotionEditorCollabExtensions } from "@/components/tiptap/notion-like/editor-extensions";
26
+ import type { CollabProviderHandle } from "@/lib/collab/provider";
27
+ import { buildRoomName } from "@allbluecn/collab-core/rooms";
28
+ import { getUserColor } from "@/lib/collab/user-color";
29
+ import { useCollabDoc } from "@/lib/collab/use-collab-doc";
30
+
31
+ // S2(纯机械拆分):StoryCollabGate 从 story-detail-body.tsx 迁此。
32
+
33
+ export interface StoryCollabRuntimeState {
34
+ /** 协同配置(null = 会话未就绪/协同不可用,编辑器暂不挂载协同扩展) */
35
+ config: NotionEditorCollabExtensions | null;
36
+ /** 会话是否就绪(协同配置稳定后再挂编辑器,防 caret user 半截装配) */
37
+ ready: boolean;
38
+ /** provider offline(降级单机模式,提示条 + 旧保存链路) */
39
+ offline: boolean;
40
+ /** provider connecting(建连窗口,轻提示条;窗口内 Y 是唯一写手) */
41
+ connecting: boolean;
42
+ /** 协同写手活跃(connecting/synced 均算——Y 是唯一内容源,DB 链路短路) */
43
+ collabActive: boolean;
44
+ /** room 连接句柄(字段 binding 数据源;null = 非协同/noop) */
45
+ handle: CollabProviderHandle | null;
46
+ /** 字段 editing 标记署名(session brief;null 走 binding 内 profile/clientID 降级) */
47
+ identity: { userId: string; name: string } | null;
48
+ }
49
+
50
+ /** 非协同分支的协同状态常量(稳定引用,行为与既有单机链路逐字一致) */
51
+ export const NON_COLLAB_STATE: StoryCollabRuntimeState = {
52
+ config: null,
53
+ ready: true,
54
+ offline: false,
55
+ connecting: false,
56
+ collabActive: false,
57
+ handle: null,
58
+ identity: null,
59
+ };
60
+
61
+ interface StoryCollabGateProps {
62
+ storyId: string;
63
+ snapshot: string | null;
64
+ children: (state: StoryCollabRuntimeState) => ReactNode;
65
+ }
66
+
67
+ /**
68
+ * 协同运行时装配(条件挂载于 collab 分支,非协同路径不产生任何协同 hook
69
+ * 观察/连接——结构对齐任务 15 CollabDocGate):会话 brief(caret 用户 + 字段
70
+ * editing 署名)+ entity room 建连/快照预水合/状态订阅。
71
+ */
72
+ export function StoryCollabGate({ storyId, snapshot, children }: StoryCollabGateProps) {
73
+ const { data: session, isPending } = useSession();
74
+ const roomName = useMemo(
75
+ () => buildRoomName({ kind: "entity-story", storyId }),
76
+ [storyId],
77
+ );
78
+ const { handle, status } = useCollabDoc({ roomName, snapshot });
79
+ // 自身 profile 广播:字段 binding 的 identity 缺省路径(会话 pending 期间
80
+ // 创建 binding)读 awareness profile 兜底署名——无广播则 editing 标记降级
81
+ // 为 client:{clientID}(远端 pill 无名)。Story 详情无 presence 头像组,
82
+ // 广播在本 gate 自持(hook 自 collab-presence-header 提取复用)。
83
+ useSelfProfileBroadcast(handle);
84
+
85
+ const user = session?.user?.id
86
+ ? {
87
+ name: session.user.name || session.user.id,
88
+ color: getUserColor(session.user.id),
89
+ }
90
+ : null;
91
+
92
+ const config: NotionEditorCollabExtensions | null =
93
+ handle && user
94
+ ? {
95
+ ydoc: handle.doc,
96
+ // noop/awareness 缺失时不挂 Caret(扩展工厂按 provider 判定)
97
+ provider: handle.awareness ? handle : null,
98
+ user,
99
+ }
100
+ : null;
101
+
102
+ // I1 钉住(同任务 15):扩展集在编辑器创建时冻结,config 一旦非空就沿用
103
+ // 首个非空值——防中途回落 null 时 stale 内容经仍在位的 Collaboration 插件
104
+ // 写进 Y 并同步给协作者(协同内容静默回滚)。
105
+ const pinnedConfigRef = useRef<NotionEditorCollabExtensions | null>(null);
106
+ if (pinnedConfigRef.current === null && config !== null) {
107
+ pinnedConfigRef.current = config;
108
+ }
109
+ const effectiveConfig = pinnedConfigRef.current;
110
+
111
+ // 会话就绪但拿不到 user(_nav beforeLoad 守卫下不应发生)时不进协同态:
112
+ // collabActive=false 让 DB 保存链路兜底,防「编辑器非 Y 绑定 + DB 短路」双失能
113
+ const sessionUsable = isPending || user != null;
114
+
115
+ return (
116
+ <>
117
+ {children({
118
+ config: effectiveConfig,
119
+ ready: !isPending,
120
+ offline: status === "offline",
121
+ connecting: status === "connecting",
122
+ collabActive: sessionUsable && status !== null && status !== "offline",
123
+ handle,
124
+ identity:
125
+ session?.user?.id
126
+ ? { userId: session.user.id, name: session.user.name || session.user.id }
127
+ : null,
128
+ })}
129
+ </>
130
+ );
131
+ }
132
+
133
+ /** 详情面板可回写视图(列表/看板乐观更新)的字段集合——与 StoryPatch 对齐。
134
+ * projectId 不在其中(项目归属变更走专门的转移流程,不在此通道)。 */
135
+ export const PATCHABLE_FIELDS = new Set<keyof StoryPatch>([
136
+ "name",
137
+ "state",
138
+ "priority",
139
+ "assigneeId",
140
+ "startDate",
141
+ "targetDate",
142
+ ]);
143
+
144
+ export interface StoryDetailBodyProps {
145
+ story: StoryDetail;
146
+ users: UserBrief[];
147
+ comments: CommentWithReplies[];
148
+ activities: ActivityItem[];
149
+ currentUserId: string | undefined;
150
+ layout?: "split" | "stack";
151
+ identifier?: string;
152
+ /** 所属项目(图标 + 名称),用于在需求 ID 前展示项目归属 */
153
+ project?: { name: string; icon: string } | null;
154
+ noScroll?: boolean;
155
+ /** 所有标签列表 */
156
+ labels?: { id: string; name: string; color: string }[];
157
+ /** 所有需求列表(用于父需求选择) */
158
+ stories?: { id: string; identifier: string; name: string; state: StoryState; projectId?: string }[];
159
+ /** 所有项目列表(用于父需求选择) */
160
+ projects?: { id: string; name: string; icon: string; displayId?: string | null }[];
161
+ /** 迭代列表(用于迭代选择器) */
162
+ iterations?: import("@/server/serverFns/iteration/schemas").IterationSummary[];
163
+ /** 关联数据(从路由 loader 预加载) */
164
+ relationsData?: RelationsData;
165
+ onRelationsUpdate?: (next: RelationsData | ((prev: RelationsData) => RelationsData)) => void;
166
+ onAppendActivities?: (items: ActivityItem[]) => void;
167
+ onAppendComment?: (comment: CommentWithReplies) => void;
168
+ onRemoveComment?: (commentId: string) => void;
169
+ onStoryUpdate?: (s: StoryDetail) => void;
170
+ /** 字段落库成功后的补丁回调(PeekPanel 回写列表/看板做乐观更新) */
171
+ onStoryPatch?: (storyId: string, patch: StoryPatch) => void;
172
+ onNavigateInPanel?: (targetId: string, targetIdentifier?: string, relationKind?: "child" | "dependency") => void;
173
+ onOpenEntity?: (entity: import("@/components/story/entity-overlay").StoryEntityOverlay) => void;
174
+ /** 用例/BUG 行的面板内叠加打开(peek 传入;缺省整页跳转) */
175
+ /** 内嵌面板导航面包屑(在面板内跳转时替代单层 identifier 显示) */
176
+ breadcrumbItems?: Array<{ identifier?: string; projectId?: string; label: string; onClick?: () => void }>;
177
+ /** 编辑器实例就绪回调(透传 NotionDocEditor.onEditorReady;测试观察点) */
178
+ onEditorReady?: (editor: Editor) => void;
179
+ /**
180
+ * 协同模式(Yjs 多人协作任务 20,规格 §7.1 Story 行):true 时 description
181
+ * 接 Collaboration(Y fragment 'default')+ 字段接 useCollabFields,全部
182
+ * 保存通道(8s 防抖 / 失焦 / 卸载 / meta 字段 / 标签 / 父需求)短路——
183
+ * Y 是唯一写手;offline 降级恢复 DB 链路。缺省 false = 现有单机链路逐字保留。
184
+ */
185
+ collab?: boolean;
186
+ /** SSR 协同快照(loader 并行取 getCollabSnapshotFn;collab=true 时必传,null = 空文档) */
187
+ collabSnapshot?: string | null;
188
+ }
189
+
190
+ /** memo:顶栏滚动标题等轻量 state 翻转时,详情巨树(面板/行列表)必须整体跳过
191
+ * 重渲染,否则越阈一次卡几百毫秒、标题延迟出现。调用方回调需 useCallback 稳定。 */