@allbluecn/web-app 0.5.0 → 0.6.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 (170) hide show
  1. package/package.json +239 -30
  2. package/src/components/iteration/auto-schedule-dialog.tsx +150 -0
  3. package/src/components/iteration/burndown-chart.tsx +63 -0
  4. package/src/components/iteration/constants.ts +28 -0
  5. package/src/components/iteration/create-iteration-dialog.tsx +140 -0
  6. package/src/components/iteration/iteration-card.tsx +60 -0
  7. package/src/components/iteration/iteration-editor.tsx +116 -0
  8. package/src/components/iteration/iteration-progress-bar.tsx +44 -0
  9. package/src/components/iteration/iteration-status-badge.tsx +33 -0
  10. package/src/components/knowledge/dialog/knowledge-form-dialog.tsx +21 -30
  11. package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
  12. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +2 -2
  13. package/src/components/layout/sidebar-03/nav-workspace.tsx +2 -2
  14. package/src/components/project/create-project-dialog.tsx +44 -57
  15. package/src/components/shared/__tests__/lucide-curated-icons.test.ts +83 -0
  16. package/src/components/shared/__tests__/lucide-icon-data.test.ts +131 -0
  17. package/src/components/shared/__tests__/lucide-icon-picker.test.tsx +197 -0
  18. package/src/components/shared/lucide-icon-picker/curated-icons.ts +478 -0
  19. package/src/components/shared/lucide-icon-picker/icon-data.ts +109 -0
  20. package/src/components/shared/lucide-icon-picker/index.tsx +82 -0
  21. package/src/components/shared/lucide-icon-picker/lucide-icon-by-name.tsx +78 -0
  22. package/src/components/shared/lucide-icon-picker/panel.tsx +163 -0
  23. package/src/components/story/__tests__/constants.test.ts +255 -0
  24. package/src/components/story/ai/story-ai-panel.tsx +23 -15
  25. package/src/components/story/ai/story-ai-preview-list.tsx +19 -0
  26. package/src/components/story/constants.ts +43 -1
  27. package/src/components/story/create-story-dialog.tsx +42 -16
  28. package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
  29. package/src/components/story/detail/__tests__/categories.test.tsx +62 -0
  30. package/src/components/story/detail/__tests__/dependencies-button.test.tsx +49 -0
  31. package/src/components/story/detail/activity-timeline.tsx +26 -4
  32. package/src/components/story/detail/story-detail-body.tsx +90 -38
  33. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +857 -0
  34. package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
  35. package/src/components/story/detail/story-detail-toolbar/children.tsx +9 -4
  36. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +95 -27
  37. package/src/components/story/detail/story-detail-toolbar.tsx +159 -8
  38. package/src/components/story/detail/story-properties.tsx +21 -4
  39. package/src/components/story/display/__tests__/group-fields.test.ts +107 -0
  40. package/src/components/story/display/group-resolver.tsx +2 -2
  41. package/src/components/story/inline-editors/__tests__/dependency-type-select.test.tsx +155 -0
  42. package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +155 -0
  43. package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +173 -0
  44. package/src/components/story/inline-editors/__tests__/state-editor.test.tsx +174 -0
  45. package/src/components/story/inline-editors/dependency-type-select.tsx +180 -0
  46. package/src/components/story/inline-editors/index.ts +2 -1
  47. package/src/components/story/inline-editors/labels-editor.tsx +159 -3
  48. package/src/components/story/inline-editors/story-select-dialog.tsx +41 -63
  49. package/src/components/story/peek-panel.tsx +121 -21
  50. package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
  51. package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
  52. package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
  53. package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
  54. package/src/components/story/relations/file-preview.tsx +173 -0
  55. package/src/components/story/relations/format.ts +120 -0
  56. package/src/components/story/relations/general-groups.ts +32 -0
  57. package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
  58. package/src/components/story/relations/link-manage-dialog.tsx +192 -0
  59. package/src/components/story/relations/story-ref-row.tsx +17 -27
  60. package/src/components/story/story-panel-header.tsx +25 -19
  61. package/src/components/story/story-toolbar.tsx +3 -3
  62. package/src/components/story/transfer-story-popover.tsx +2 -2
  63. package/src/components/story/types.ts +14 -0
  64. package/src/components/story/views/create-view-dialog.tsx +3 -3
  65. package/src/components/story/views/story-list/__tests__/group-header.test.tsx +219 -0
  66. package/src/components/story/views/story-list/group.tsx +1 -1
  67. package/src/components/story/views/story-list/inline-story-creator.tsx +2 -2
  68. package/src/components/story/views/story-list/row.tsx +1 -1
  69. package/src/components/workspace/create-workspace-dialog.tsx +41 -49
  70. package/src/lib/__tests__/project-display-id.test.ts +8 -5
  71. package/src/lib/changelog/sdk-versions.ts +20 -20
  72. package/src/lib/iteration/__tests__/status.test.ts +41 -0
  73. package/src/lib/iteration/status.ts +28 -0
  74. package/src/lib/project-display-id.ts +14 -5
  75. package/src/lib/project-module-meta.ts +77 -0
  76. package/src/lib/story/__tests__/video-embed.test.ts +77 -0
  77. package/src/lib/story/video-embed.ts +59 -0
  78. package/src/lib/utils.d.ts +1 -1
  79. package/src/plugins/.gen-manifest.json +13 -0
  80. package/src/plugins/modules.gen.ts +20 -0
  81. package/src/plugins/plugins.gen.ts +20 -0
  82. package/src/plugins/ui.gen.ts +8 -0
  83. package/src/providers/index.tsx +6 -0
  84. package/src/routeTree.gen.ts +96 -0
  85. package/src/routes/__root.tsx +0 -8
  86. package/src/routes/_nav/inspiration/route.tsx +8 -0
  87. package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
  88. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  89. package/src/routes/_nav/prd/index.tsx +9 -0
  90. package/src/routes/_nav/prd/route.tsx +8 -0
  91. package/src/routes/_nav/projects/$projectId/$storyId/route.lazy.tsx +52 -25
  92. package/src/routes/_nav/projects/$projectId/$storyId/route.tsx +6 -3
  93. package/src/routes/_nav/projects/$projectId/index.lazy.tsx +32 -15
  94. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.lazy.tsx +289 -0
  95. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.tsx +27 -0
  96. package/src/routes/_nav/projects/$projectId/iterations/index.lazy.tsx +111 -0
  97. package/src/routes/_nav/projects/$projectId/iterations/index.tsx +24 -0
  98. package/src/routes/_nav/projects/$projectId/route.tsx +4 -2
  99. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +78 -7
  100. package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
  101. package/src/routes/_nav/stories/$storyId/route.lazy.tsx +5 -1
  102. package/src/routes/_nav/stories/$storyId/route.tsx +4 -0
  103. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  104. package/src/routes/_nav/tags/index.tsx +8 -0
  105. package/src/routes/_nav/tags/route.tsx +8 -0
  106. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  107. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +3 -3
  108. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +4 -4
  109. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  110. package/src/routes/api/attachments/$id/content.ts +60 -0
  111. package/src/routes/api/attachments/upload.ts +148 -0
  112. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  113. package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
  114. package/src/server/serverFns/iteration/__tests__/auto-schedule-core.test.ts +62 -0
  115. package/src/server/serverFns/iteration/__tests__/burndown-core.test.ts +90 -0
  116. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +211 -0
  117. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +155 -0
  118. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +136 -0
  119. package/src/server/serverFns/iteration/auto-schedule-core.ts +54 -0
  120. package/src/server/serverFns/iteration/auto-schedule-internal.ts +88 -0
  121. package/src/server/serverFns/iteration/burndown-core.ts +60 -0
  122. package/src/server/serverFns/iteration/index.ts +22 -0
  123. package/src/server/serverFns/iteration/iteration-auto-schedule.ts +66 -0
  124. package/src/server/serverFns/iteration/iteration-burndown.ts +70 -0
  125. package/src/server/serverFns/iteration/iteration-crud.ts +208 -0
  126. package/src/server/serverFns/iteration/iteration-stories.ts +111 -0
  127. package/src/server/serverFns/iteration/iteration-transfer.ts +42 -0
  128. package/src/server/serverFns/iteration/schemas.ts +58 -0
  129. package/src/server/serverFns/iteration/transfer-internal.ts +62 -0
  130. package/src/server/serverFns/knowledge.ts +50 -4
  131. package/src/server/serverFns/project/module-overview.ts +109 -0
  132. package/src/server/serverFns/project.ts +45 -2
  133. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
  134. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +100 -0
  135. package/src/server/serverFns/story/__tests__/labels.test.ts +67 -0
  136. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
  137. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +132 -2
  138. package/src/server/serverFns/story/index.ts +9 -4
  139. package/src/server/serverFns/story/kb-general.ts +39 -0
  140. package/src/server/serverFns/story/kb-scope.ts +52 -0
  141. package/src/server/serverFns/story/link-preview.ts +115 -0
  142. package/src/server/serverFns/story/story-assoc.ts +372 -51
  143. package/src/server/serverFns/story/story-attachments.ts +32 -45
  144. package/src/{components/project/project-icon.tsx → server/serverFns/story/story-crud/label-palette.ts} +16 -19
  145. package/src/server/serverFns/story/story-crud/label-suggest-core.ts +88 -0
  146. package/src/server/serverFns/story/story-crud/label-suggest.ts +38 -0
  147. package/src/{components/knowledge/knowledge-icon.tsx → server/serverFns/story/story-crud/label-upsert.ts} +13 -19
  148. package/src/server/serverFns/story/story-crud/labels.ts +3 -5
  149. package/src/server/serverFns/story/story-crud.ts +53 -6
  150. package/src/server/serverFns/story/story-dependencies.ts +158 -5
  151. package/src/server/serverFns/story/story-links.ts +43 -7
  152. package/src/server/serverFns/story/story-transfer.ts +5 -4
  153. package/src/server/serverFns/story/story-tree.ts +28 -1
  154. package/src/server/serverFns/story/types.ts +38 -0
  155. package/src/styles/globals.css +25 -0
  156. package/src/types/project.ts +5 -5
  157. package/vite.shared.ts +2 -0
  158. package/src/components/project/project-animated-icon.tsx +0 -73
  159. package/src/components/shared/icon-packs/index.ts +0 -1
  160. package/src/components/shared/icon-packs/knowledge.ts +0 -1
  161. package/src/components/shared/icon-packs/prd.ts +0 -1
  162. package/src/components/shared/icon-packs/project.ts +0 -1
  163. package/src/components/shared/icon-picker.tsx +0 -1
  164. package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
  165. package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
  166. package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
  167. package/src/components/story/relations/attachments-panel.tsx +0 -179
  168. package/src/components/story/relations/knowledge-panel.tsx +0 -284
  169. package/src/components/story/relations/links-panel.tsx +0 -240
  170. package/src/lib/project-mock.ts +0 -156
@@ -19,10 +19,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
19
19
  import { useTranslation } from "react-i18next";
20
20
  import { useDebouncedCallback, SEARCH_DEBOUNCE_MS } from "@/lib/pacer";
21
21
  import { cn } from "@/lib/utils";
22
- import { Search, Sprout, ChevronsUpDown, Check, Trees, ListFilter, Flag, Waypoints } from "lucide-react";
22
+ import { Search, Sprout, ChevronsUpDown, Check, Trees, ListFilter, Flag } from "lucide-react";
23
23
  import { Badge } from "@/components/ui/badge";
24
24
  import { Checkbox } from "@/components/ui/checkbox";
25
- import { ProjectIconDisplay } from "@/components/project/project-icon";
25
+ import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
26
26
  import { Button } from "@/components/ui/button";
27
27
  import { Input } from "@/components/ui/input";
28
28
  import {
@@ -50,11 +50,11 @@ import {
50
50
  STATE_LABEL,
51
51
  STORY_STATES,
52
52
  STORY_PRIORITIES,
53
- DEPENDENCY_TYPE_OPTIONS,
54
53
  type StoryState,
55
54
  type DependencyTypeOption,
56
55
  } from "../constants";
57
56
  import { PriorityIcon } from "../priority-icon";
57
+ import { DependencyTypeSelect } from "./dependency-type-select";
58
58
 
59
59
  interface ParentStoryItem {
60
60
  id: string;
@@ -63,6 +63,7 @@ interface ParentStoryItem {
63
63
  state: string;
64
64
  priority: string;
65
65
  projectId: string;
66
+ iteration?: { id: string; name: string } | null;
66
67
  }
67
68
 
68
69
  interface StorySelectDialogProps {
@@ -73,7 +74,7 @@ interface StorySelectDialogProps {
73
74
  initialProjectId?: string | null;
74
75
  defaultSelectedParentId?: string | null;
75
76
  defaultSelectedParentIds?: string[];
76
- mode?: "parent" | "child" | "dependency";
77
+ mode?: "parent" | "child" | "dependency" | "iteration";
77
78
  onSelected: (parentId: string | string[]) => void;
78
79
  onActivities?: (activities: ActivityItem[]) => void;
79
80
  skipTransfer?: boolean;
@@ -115,7 +116,7 @@ export function StorySelectDialog({
115
116
  }: StorySelectDialogProps) {
116
117
  const { t } = useTranslation("story");
117
118
  const resolvedTitle = title ?? t("select.defaultTitle");
118
- const isMulti = mode === "child" || mode === "dependency";
119
+ const isMulti = mode === "child" || mode === "dependency" || mode === "iteration";
119
120
  const [projectFilter, setProjectFilter] = useState<string>(ALL_PROJECTS);
120
121
  const [items, setItems] = useState<ParentStoryItem[]>([]);
121
122
  const [search, setSearch] = useState("");
@@ -480,7 +481,7 @@ export function StorySelectDialog({
480
481
  >
481
482
  {activeFilterProject ? (
482
483
  <span className="flex items-center gap-1.5">
483
- <ProjectIconDisplay name={activeFilterProject.icon} size={14} />
484
+ <LucideIconByName name={activeFilterProject.icon} size={14} />
484
485
  <span className="truncate max-w-[80px]">{activeFilterProject.name}</span>
485
486
  </span>
486
487
  ) : (
@@ -524,7 +525,7 @@ export function StorySelectDialog({
524
525
  }`}
525
526
  >
526
527
  <span className="flex min-w-0 items-center gap-2">
527
- <ProjectIconDisplay name={p.icon} size={14} />
528
+ <LucideIconByName name={p.icon} size={14} />
528
529
  <span className="min-w-0 flex-1 truncate">{p.name}</span>
529
530
  </span>
530
531
  {isActive && <Check className="size-3.5 shrink-0 text-primary" />}
@@ -562,8 +563,9 @@ export function StorySelectDialog({
562
563
  const owner = projectById.get(story.projectId);
563
564
  return (
564
565
  <li key={story.id}>
565
- <button
566
- type="button"
566
+ <div
567
+ role="button"
568
+ tabIndex={0}
567
569
  onClick={() => {
568
570
  if (isMulti) {
569
571
  setSelectedIds((prev) =>
@@ -575,7 +577,21 @@ export function StorySelectDialog({
575
577
  setSelectedIds([story.id]);
576
578
  }
577
579
  }}
578
- className={`flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm transition-colors ${
580
+ onKeyDown={(e) => {
581
+ if (e.key === "Enter" || e.key === " ") {
582
+ e.preventDefault();
583
+ if (isMulti) {
584
+ setSelectedIds((prev) =>
585
+ prev.includes(story.id)
586
+ ? prev.filter((id) => id !== story.id)
587
+ : [...prev, story.id],
588
+ );
589
+ } else {
590
+ setSelectedIds([story.id]);
591
+ }
592
+ }
593
+ }}
594
+ className={`flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm transition-colors ${
579
595
  isSelected
580
596
  ? "bg-accent ring-1 ring-primary/30"
581
597
  : "hover:bg-muted"
@@ -606,9 +622,14 @@ export function StorySelectDialog({
606
622
  <span className="shrink-0 font-mono text-xs text-muted-foreground">
607
623
  {story.identifier}
608
624
  </span>
609
- <span className="min-w-0 flex-1 truncate">
610
- {story.name}
611
- </span>
625
+ <span className="min-w-0 flex-1 truncate">
626
+ {story.name}
627
+ </span>
628
+ {mode === "iteration" && story.iteration && (
629
+ <span className="shrink-0 text-[10px] text-muted-foreground">
630
+ {t("iterations:editor.currentIteration")}: {story.iteration.name}
631
+ </span>
632
+ )}
612
633
  {owner && (
613
634
  <span className="shrink-0 rounded-sm bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
614
635
  {owner.name}
@@ -617,7 +638,7 @@ export function StorySelectDialog({
617
638
  {!isMulti && isSelected && (
618
639
  <Check className="size-4 shrink-0 text-primary" />
619
640
  )}
620
- </button>
641
+ </div>
621
642
  </li>
622
643
  );
623
644
  })}
@@ -660,55 +681,12 @@ export function StorySelectDialog({
660
681
  {selectedCount > 0 ? t("select.selectedCount", { count: selectedCount }) : t("select.pleaseSelect")}
661
682
  </span>
662
683
  {mode === "dependency" && (
663
- <Popover open={depPopoverOpen} onOpenChange={setDepPopoverOpen}>
664
- <PopoverTrigger asChild>
665
- <Button
666
- variant="ghost"
667
- size="sm"
668
- className={cn(
669
- "h-9 gap-1.5",
670
- !dependencyType && "text-muted-foreground",
671
- )}
672
- >
673
- <Waypoints className="size-3.5" />
674
- {dependencyType
675
- ? t(
676
- DEPENDENCY_TYPE_OPTIONS.find((o) => o.key === dependencyType)!
677
- .optionLabelKey,
678
- )
679
- : t("dependency.typePlaceholder")}
680
- </Button>
681
- </PopoverTrigger>
682
- <PopoverContent align="start" className="w-80 p-1">
683
- {(["dependsOn", "dependents"] as const).map((group, gi) => (
684
- <div key={group} className={cn(gi > 0 && "mt-1 border-t pt-1")}>
685
- <p className="px-2 py-1 text-xs font-medium text-muted-foreground">
686
- {t(`dependency.optionGroups.${group}`)}
687
- </p>
688
- {DEPENDENCY_TYPE_OPTIONS.filter((o) => o.group === group).map((opt) => {
689
- const checked = dependencyType === opt.key;
690
- return (
691
- <button
692
- key={opt.key}
693
- type="button"
694
- onClick={() => {
695
- onDependencyTypeChange?.(opt.key);
696
- setDepPopoverOpen(false);
697
- }}
698
- className={cn(
699
- "flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted",
700
- checked && "font-medium",
701
- )}
702
- >
703
- <span className="flex-1">{t(opt.optionLabelKey)}</span>
704
- {checked && <Check className="size-3.5 shrink-0 text-primary" />}
705
- </button>
706
- );
707
- })}
708
- </div>
709
- ))}
710
- </PopoverContent>
711
- </Popover>
684
+ <DependencyTypeSelect
685
+ value={dependencyType}
686
+ onChange={(key) => onDependencyTypeChange?.(key)}
687
+ open={depPopoverOpen}
688
+ onOpenChange={setDepPopoverOpen}
689
+ />
712
690
  )}
713
691
  </div>
714
692
  )}
@@ -15,7 +15,7 @@
15
15
  // You should have received a copy of the GNU Affero General Public License
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
- import { useEffect, useState } from "react";
18
+ import { useCallback, useEffect, useMemo, useState } from "react";
19
19
  import { useTranslation } from "react-i18next";
20
20
  import { useHotkey } from "@tanstack/react-hotkeys";
21
21
  import { Skeleton } from "@/components/ui/skeleton";
@@ -33,11 +33,12 @@ import {
33
33
  getLinkedPrdsFn,
34
34
  getLinkedDocsFn,
35
35
  } from "@/server/serverFns/story";
36
+ import { listIterationsFn } from "@/server/serverFns/iteration";
36
37
  import type { StoryDetail, UserBrief, CommentWithReplies, ActivityItem, RelationsData } from "./types";
37
38
  import type { StoryState } from "./constants";
38
39
  import { StoryDetailBody } from "./detail/story-detail-body";
39
40
  import { StoryPanelHeader } from "./story-panel-header";
40
- import type { PeekMode } from "./story-panel-header";
41
+ import type { PeekMode, PeekRelationKind, PeekBreadcrumbItem } from "./story-panel-header";
41
42
 
42
43
  export type { PeekMode };
43
44
 
@@ -70,6 +71,13 @@ interface StoryPeekPanelProps {
70
71
  onTransferred?: () => void;
71
72
  }
72
73
 
74
+ interface PeekLocation {
75
+ storyId: string;
76
+ identifier?: string;
77
+ projectId?: string;
78
+ relationKind: PeekRelationKind;
79
+ }
80
+
73
81
  interface PeekData {
74
82
  story: StoryDetail;
75
83
  comments: CommentWithReplies[];
@@ -77,6 +85,7 @@ interface PeekData {
77
85
  labels: { id: string; name: string; color: string }[];
78
86
  stories: { id: string; identifier: string; name: string; state: StoryState }[];
79
87
  relationsData: RelationsData;
88
+ iterations: import("@/server/serverFns/iteration/schemas").IterationSummary[];
80
89
  }
81
90
 
82
91
  export function StoryPeekPanel({
@@ -100,25 +109,61 @@ export function StoryPeekPanel({
100
109
  const { t } = useTranslation("story");
101
110
  const [data, setData] = useState<PeekData | null>(null);
102
111
  const [error, setError] = useState<string | null>(null);
112
+ const [refreshing, setRefreshing] = useState(false);
113
+ const [history, setHistory] = useState<PeekLocation[]>([]);
114
+ const [rootIdentifier, setRootIdentifier] = useState<string | undefined>();
115
+ const [rootProjectId, setRootProjectId] = useState<string | undefined>();
116
+
117
+ const effectiveStoryId = history.length > 0 ? history[history.length - 1].storyId : storyId;
118
+
119
+ const onNavigateInPanel = useCallback((targetId: string, targetIdentifier?: string, relationKind: PeekRelationKind = "child") => {
120
+ setHistory((prev) => {
121
+ const currentId = prev.length > 0 ? prev[prev.length - 1].storyId : storyId;
122
+ return [...prev, { storyId: targetId, identifier: targetIdentifier, relationKind, parentId: currentId }];
123
+ });
124
+ }, [storyId]);
125
+
126
+ const onBackInPanel = useCallback(() => {
127
+ setHistory((prev) => prev.slice(0, -1));
128
+ }, []);
129
+
130
+ useEffect(() => {
131
+ setHistory([]);
132
+ }, [storyId]);
103
133
 
104
134
  useEffect(() => {
105
135
  let cancelled = false;
106
- setData(null);
107
136
  setError(null);
137
+ // 跳转时保留旧内容(不 setData(null)):同步卸载含 TipTap 等重节点的详情树会
138
+ // 阻塞主线程,导致顶部 MorphIcon 关闭/返回按钮的形变动画掉帧卡顿。
139
+ // 改为半透明遮罩过渡,新数据到达后经 key 变化整体替换。
140
+ setRefreshing(data !== null);
108
141
  (async () => {
109
142
  try {
110
- const story = await getStoryDetailFn({ data: { storyId } });
111
- const [comments, activities, labels, storiesResult, childrenStories, dependencies, links, attachments, prds, docs] = await Promise.all([
112
- listCommentsFn({ data: { storyId } }),
113
- listActivitiesFn({ data: { storyId, take: 50 } }),
143
+ const story = await getStoryDetailFn({ data: { storyId: effectiveStoryId } });
144
+ if (history.length === 0) {
145
+ setRootIdentifier(story.identifier);
146
+ setRootProjectId(story.projectId);
147
+ } else {
148
+ // 回填当前层 projectId:跨项目父子/依赖跳转时,面包屑各级需显示各自项目图标
149
+ setHistory((prev) => {
150
+ const last = prev[prev.length - 1];
151
+ if (!last || last.projectId === story.projectId) return prev;
152
+ return prev.map((h, i) => (i === prev.length - 1 ? { ...h, projectId: story.projectId } : h));
153
+ });
154
+ }
155
+ const [comments, activities, labels, storiesResult, childrenStories, dependencies, links, attachments, prds, docs, iterationsResult] = await Promise.all([
156
+ listCommentsFn({ data: { storyId: effectiveStoryId } }),
157
+ listActivitiesFn({ data: { storyId: effectiveStoryId, take: 50 } }),
114
158
  listLabelsFn(),
115
159
  listStoriesFn({ data: { projectId: story.projectId } }),
116
- listChildStoriesFn({ data: { parentId: storyId } }),
117
- getDependenciesFn({ data: { storyId } }),
118
- getLinksFn({ data: { storyId } }),
119
- getAttachmentsFn({ data: { storyId } }),
120
- getLinkedPrdsFn({ data: { storyId } }),
121
- getLinkedDocsFn({ data: { storyId } }),
160
+ listChildStoriesFn({ data: { parentId: effectiveStoryId } }),
161
+ getDependenciesFn({ data: { storyId: effectiveStoryId } }),
162
+ getLinksFn({ data: { storyId: effectiveStoryId } }),
163
+ getAttachmentsFn({ data: { storyId: effectiveStoryId } }),
164
+ getLinkedPrdsFn({ data: { storyId: effectiveStoryId } }),
165
+ getLinkedDocsFn({ data: { storyId: effectiveStoryId } }),
166
+ story.projectId ? listIterationsFn({ data: { projectId: story.projectId, includeArchived: false } }) : Promise.resolve({ items: [] }),
122
167
  ]);
123
168
  const relationsData: RelationsData = {
124
169
  hierarchy: {
@@ -131,33 +176,82 @@ export function StoryPeekPanel({
131
176
  attachments,
132
177
  knowledge: { prds, docs },
133
178
  };
134
- if (!cancelled) setData({ story, comments, activities: activities.items, labels: labels, stories: storiesResult.items.map((s) => ({ id: s.id, identifier: s.identifier, name: s.name, state: s.state })), relationsData });
179
+ if (!cancelled) {
180
+ setData({ story, comments, activities: activities.items, labels: labels, stories: storiesResult.items.map((s) => ({ id: s.id, identifier: s.identifier, name: s.name, state: s.state })), relationsData, iterations: iterationsResult.items });
181
+ setRefreshing(false);
182
+ }
135
183
  } catch (e) {
136
- if (!cancelled) setError(e instanceof Error ? e.message : t("peek.loadFailed"));
184
+ if (!cancelled) {
185
+ setError(e instanceof Error ? e.message : t("peek.loadFailed"));
186
+ setRefreshing(false);
187
+ }
137
188
  }
138
189
  })();
139
190
  return () => {
140
191
  cancelled = true;
141
192
  };
142
- }, [storyId]);
193
+ }, [effectiveStoryId]);
143
194
 
144
195
  useHotkey("Escape", onClose, {
145
- enabled: mode === "side-peek",
196
+ enabled: mode === "side-peek" && history.length === 0,
146
197
  });
147
198
 
148
199
  const canArchive = data?.story
149
200
  ? (data.story.state === "done" || data.story.state === "cancelled") && !data.story.archived
150
201
  : false;
151
202
 
203
+ const closeType: "collapse" | "close" | "back" =
204
+ history.length > 0
205
+ ? "back"
206
+ : mode === "side-peek"
207
+ ? "collapse"
208
+ : "close";
209
+
210
+ const handleClose = useCallback(() => {
211
+ if (history.length > 0) {
212
+ onBackInPanel();
213
+ } else {
214
+ onClose();
215
+ }
216
+ }, [history.length, onBackInPanel, onClose]);
217
+
218
+ const breadcrumbItems: PeekBreadcrumbItem[] = useMemo(() => {
219
+ const rootId = rootIdentifier;
220
+ const currentId = data?.story.identifier;
221
+ const items: PeekBreadcrumbItem[] = [];
222
+
223
+ if (history.length === 0) {
224
+ return items;
225
+ }
226
+
227
+ const entry = history[history.length - 1];
228
+ const parentLabel = entry.relationKind === "child" ? t("peek.parentStory") : t("peek.currentStory");
229
+ const childLabel = entry.relationKind === "child" ? t("peek.childStory") : t("peek.dependentStory");
230
+
231
+ items.push({
232
+ identifier: history.length >= 2 ? history[history.length - 2].identifier ?? rootId : rootId,
233
+ projectId: history.length >= 2 ? history[history.length - 2].projectId ?? rootProjectId : rootProjectId,
234
+ label: parentLabel,
235
+ onClick: () => onBackInPanel(),
236
+ });
237
+
238
+ items.push({
239
+ identifier: currentId,
240
+ projectId: data?.story.projectId ?? undefined,
241
+ label: childLabel,
242
+ });
243
+
244
+ return items;
245
+ }, [history, rootIdentifier, rootProjectId, data?.story.identifier, data?.story.projectId, t, onBackInPanel]);
246
+
152
247
  const header = (
153
248
  <StoryPanelHeader
154
- closeType={mode === "side-peek" ? "collapse" : "close"}
155
- onClose={onClose}
249
+ closeType={closeType}
250
+ onClose={handleClose}
156
251
  onFullscreen={onFullscreen}
157
252
  mode={mode}
158
253
  onModeChange={onModeChange}
159
254
  storyId={storyId}
160
- identifier={data?.story.identifier}
161
255
  canArchive={canArchive}
162
256
  onPrevious={onPrevious}
163
257
  onNext={onNext}
@@ -172,7 +266,10 @@ export function StoryPeekPanel({
172
266
  );
173
267
 
174
268
  const body = (
175
- <div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-8" style={{ scrollbarGutter: "stable" } as React.CSSProperties}>
269
+ <div
270
+ className={`flex min-h-0 flex-1 flex-col overflow-y-auto px-8 transition-opacity duration-200${refreshing ? " pointer-events-none opacity-50" : ""}`}
271
+ style={{ scrollbarGutter: "stable" } as React.CSSProperties}
272
+ >
176
273
 
177
274
  {error && <div className="w-full p-6 text-sm text-destructive">{t("peek.loadFailed")}: {error}</div>}
178
275
  {!error && !data && (
@@ -230,12 +327,15 @@ export function StoryPeekPanel({
230
327
  labels={data.labels}
231
328
  stories={data.stories}
232
329
  projects={projects}
330
+ iterations={data.iterations}
233
331
  relationsData={data.relationsData}
234
332
  onRelationsUpdate={(next) => setData((d) => d ? { ...d, relationsData: typeof next === "function" ? next(d.relationsData) : next } : d)}
235
333
  onAppendActivities={(items) => setData((d) => d ? { ...d, activities: [...d.activities, ...items] } : d)}
236
334
  onAppendComment={(c) => setData((d) => d ? { ...d, comments: [...d.comments, c] } : d)}
237
335
  onRemoveComment={(id) => setData((d) => d ? { ...d, comments: d.comments.filter((c) => c.id !== id) } : d)}
238
336
  onStoryUpdate={(s) => setData((d) => d ? { ...d, story: s } : d)}
337
+ onNavigateInPanel={onNavigateInPanel}
338
+ breadcrumbItems={breadcrumbItems}
239
339
  />
240
340
  )}
241
341
  </div>
@@ -0,0 +1,171 @@
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
+ // jsdom 缺少 ResizeObserver,Radix Dialog 依赖它
19
+ const MockResizeObserver = class ResizeObserver {
20
+ observe() {}
21
+ unobserve() {}
22
+ disconnect() {}
23
+ };
24
+ if (typeof globalThis.ResizeObserver === "undefined") {
25
+ (globalThis as any).ResizeObserver = MockResizeObserver;
26
+ }
27
+
28
+ import { describe, it, expect, vi, beforeEach } from "vitest";
29
+ import { render, screen, fireEvent, waitFor, type RenderResult } from "@testing-library/react";
30
+ import { TooltipProvider } from "@/components/ui/tooltip";
31
+
32
+ vi.mock("sonner", () => ({
33
+ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
34
+ }));
35
+
36
+ const fns = vi.hoisted(() => ({ uploadFetch: vi.fn() }));
37
+
38
+ // 上传走 /api/attachments/upload(multipart fetch),mock 全局 fetch
39
+ vi.stubGlobal("fetch", fns.uploadFetch);
40
+
41
+ function uploadResponse(body: Record<string, unknown>) {
42
+ return {
43
+ ok: true,
44
+ json: () => Promise.resolve(body),
45
+ };
46
+ }
47
+
48
+ import { AttachmentManageDialog } from "../attachment-manage-dialog";
49
+ import type { StoryAttachmentItem } from "../../types";
50
+
51
+ const ATTS: StoryAttachmentItem[] = [
52
+ { id: "a1", fileName: "设计稿.png", fileSize: 2048, mimeType: "image/png", storageUrl: "/mock-storage/1" },
53
+ { id: "a2", fileName: "说明.pdf", fileSize: 1048576, mimeType: "application/pdf", storageUrl: "/mock-storage/2" },
54
+ ];
55
+
56
+ function setup(): RenderResult & { onUpdate: ReturnType<typeof vi.fn>; onOpenChange: ReturnType<typeof vi.fn> } {
57
+ const onUpdate = vi.fn();
58
+ const onOpenChange = vi.fn();
59
+ const view = render(
60
+ <TooltipProvider>
61
+ <AttachmentManageDialog
62
+ open
63
+ onOpenChange={onOpenChange}
64
+ storyId="s1"
65
+ data={ATTS}
66
+ onUpdate={onUpdate}
67
+ />
68
+ </TooltipProvider>,
69
+ );
70
+ return { ...view, onUpdate, onOpenChange };
71
+ }
72
+
73
+ function getFileInput() {
74
+ return document.querySelector('input[type="file"]') as HTMLInputElement;
75
+ }
76
+
77
+ function stageFile(file: File) {
78
+ fireEvent.change(getFileInput(), { target: { files: [file] } });
79
+ }
80
+
81
+ beforeEach(() => {
82
+ vi.clearAllMocks();
83
+ fns.uploadFetch.mockResolvedValue(
84
+ uploadResponse({
85
+ id: "a3",
86
+ fileName: "新文件.txt",
87
+ fileSize: 5,
88
+ mimeType: "text/plain",
89
+ storageUrl: "/api/attachments/a3/content",
90
+ }),
91
+ );
92
+ });
93
+
94
+ describe("AttachmentManageDialog", () => {
95
+ it("不渲染已有附件列表,仅保留上传区", () => {
96
+ setup();
97
+ expect(screen.queryByText("设计稿.png")).not.toBeInTheDocument();
98
+ expect(screen.getByText("点击或拖拽上传")).toBeInTheDocument();
99
+ });
100
+
101
+ it("选择文件后先在弹窗内预览(暂存),点击确认上传才发起 POST", async () => {
102
+ setup();
103
+ const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
104
+ stageFile(file);
105
+ // 暂存预览:文件名 + 移除按钮出现
106
+ expect(screen.getByRole("button", { name: "移除" })).toBeInTheDocument();
107
+ expect(screen.getByRole("button", { name: "移除" })).toBeInTheDocument();
108
+ // 未确认前不发起上传
109
+ expect(fns.uploadFetch).not.toHaveBeenCalled();
110
+ fireEvent.click(screen.getByRole("button", { name: "确认上传" }));
111
+ await waitFor(() => expect(fns.uploadFetch).toHaveBeenCalled());
112
+ const [url, init] = fns.uploadFetch.mock.calls[0];
113
+ expect(url).toBe("/api/attachments/upload");
114
+ expect(init.method).toBe("POST");
115
+ const formData = init.body as FormData;
116
+ expect(formData.get("storyId")).toBe("s1");
117
+ expect(formData.get("file")).toBeInstanceOf(File);
118
+ });
119
+
120
+ it("确认上传成功后回调 onUpdate(含内容路由 URL)并关闭弹窗", async () => {
121
+ const { onUpdate, onOpenChange } = setup();
122
+ const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
123
+ stageFile(file);
124
+ fireEvent.click(screen.getByRole("button", { name: "确认上传" }));
125
+ await waitFor(() => expect(onUpdate).toHaveBeenCalled());
126
+ const added = onUpdate.mock.calls[0][0][0];
127
+ expect(added.storageUrl).toBe("/api/attachments/a3/content");
128
+ expect(onOpenChange).toHaveBeenCalledWith(false);
129
+ });
130
+
131
+ it("描述随确认上传提交到 FormData", async () => {
132
+ setup();
133
+ const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
134
+ stageFile(file);
135
+ fireEvent.change(screen.getByPlaceholderText("补充说明…"), { target: { value: "测试描述" } });
136
+ fireEvent.click(screen.getByRole("button", { name: "确认上传" }));
137
+ await waitFor(() => expect(fns.uploadFetch).toHaveBeenCalled());
138
+ const formData = fns.uploadFetch.mock.calls[0][1].body as FormData;
139
+ expect(formData.get("description")).toBe("测试描述");
140
+ });
141
+
142
+ it("上传接口报错时 toast 提示且不回调 onUpdate", async () => {
143
+ const { toast } = await import("sonner");
144
+ fns.uploadFetch.mockResolvedValue({ ok: false, json: () => Promise.resolve({ error: "文件大小需在 10MB 以内" }) });
145
+ const { onUpdate } = setup();
146
+ const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
147
+ stageFile(file);
148
+ fireEvent.click(screen.getByRole("button", { name: "确认上传" }));
149
+ await waitFor(() => expect(toast.error).toHaveBeenCalled());
150
+ expect(onUpdate).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it("超过 10MB 的文件被拒绝且不入暂存列表", async () => {
154
+ const { toast } = await import("sonner");
155
+ setup();
156
+ const big = new File([new ArrayBuffer(11 * 1024 * 1024)], "大文件.zip", { type: "application/zip" });
157
+ stageFile(big);
158
+ await waitFor(() => expect(toast.error).toHaveBeenCalled());
159
+ expect(fns.uploadFetch).not.toHaveBeenCalled();
160
+ expect(screen.queryByText("大文件.zip")).not.toBeInTheDocument();
161
+ });
162
+
163
+ it("取消按钮关闭弹窗", () => {
164
+ const { onOpenChange } = setup();
165
+ const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
166
+ stageFile(file);
167
+ expect(screen.getByRole("button", { name: "移除" })).toBeInTheDocument();
168
+ fireEvent.click(screen.getByRole("button", { name: "取消" }));
169
+ expect(onOpenChange).toHaveBeenCalledWith(false);
170
+ });
171
+ });