@nextclaw/ui 0.22.5 → 0.24.0-beta.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 (111) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/assets/{api-aEu9cEI8.js → api-CoqPRKmF.js} +5 -5
  3. package/dist/assets/{appearance-settings-page-z4DaHc_E.js → appearance-settings-page-CPoFZLw8.js} +1 -1
  4. package/dist/assets/{book-open-DYRapW_h.js → book-open-jcUt0rr9.js} +1 -1
  5. package/dist/assets/{channels-list-page-x07sGqsT.js → channels-list-page-anMJAkfK.js} +2 -2
  6. package/dist/assets/{config-split-page-DJDp86Ne.js → config-split-page-AcuFnbIC.js} +1 -1
  7. package/dist/assets/{confirm-dialog-BALTnP33.js → confirm-dialog-B-atCfC5.js} +1 -1
  8. package/dist/assets/{createLucideIcon-CgM0rnsa.js → createLucideIcon-Blz0FzrV.js} +1 -1
  9. package/dist/assets/{desktop-capabilities-page-4-v3wKLV.js → desktop-capabilities-page-8lKF_Ob-.js} +1 -1
  10. package/dist/assets/desktop-update-config-DABXDpbj.js +1 -0
  11. package/dist/assets/{dist-BjYvUhPL.js → dist-D8kcEpTf.js} +1 -1
  12. package/dist/assets/{dist-N9zc5bkM.js → dist-eH-_j8yU.js} +1 -1
  13. package/dist/assets/doc-browser-DJEMr7Zk.js +1 -0
  14. package/dist/assets/{doc-browser-DwRLokCI.js → doc-browser-DzXRehlW.js} +1 -1
  15. package/dist/assets/{doc-browser-context-BDuypObS.js → doc-browser-context-BlbUcg2J.js} +1 -1
  16. package/dist/assets/{extensions-DFQXmSwG.js → extensions-mquwAJ2Z.js} +1 -1
  17. package/dist/assets/{form-actions-CzGmrPAv.js → form-actions-CaZDGEmB.js} +1 -1
  18. package/dist/assets/index-DyR5iR0O.css +1 -0
  19. package/dist/assets/{index-zuqzAvNx.js → index-XxXj8WDS.js} +10 -10
  20. package/dist/assets/loader-circle-0qPOOYQ5.js +1 -0
  21. package/dist/assets/{mcp-marketplace-page-COUopIOc.js → mcp-marketplace-page-gFtgyueL.js} +1 -1
  22. package/dist/assets/mcp-marketplace-page-uanamJ6L.js +1 -0
  23. package/dist/assets/{model-config-page-g9_-hZqZ.js → model-config-page-hFBpeRAO.js} +1 -1
  24. package/dist/assets/{navigation-link-Ci4Z7Phi.js → navigation-link-Hb5Z0UxZ.js} +1 -1
  25. package/dist/assets/plus-CYOvNvcV.js +1 -0
  26. package/dist/assets/privacy-settings-page-C18zo3ox.js +1 -0
  27. package/dist/assets/projects-C8Gm9RS8.js +1 -0
  28. package/dist/assets/providers-config-page-YU0-AOXJ.js +1 -0
  29. package/dist/assets/react-DF-kP7iN.js +8 -0
  30. package/dist/assets/{refresh-cw-C-3_thgr.js → refresh-cw-ZHKfE3yx.js} +1 -1
  31. package/dist/assets/remote-B_utkBra.js +1 -0
  32. package/dist/assets/{rotate-cw-DSFTq_yu.js → rotate-cw-CLhOXGwE.js} +1 -1
  33. package/dist/assets/runtime-config-page-Dmy1A_0Y.js +1 -0
  34. package/dist/assets/{save-CpPr78_S.js → save-CWuuZs77.js} +1 -1
  35. package/dist/assets/{search-msqk2-rd.js → search-DcmsKAn2.js} +1 -1
  36. package/dist/assets/{search-config-page-BqoDbKpM.js → search-config-page-CxBacx6W.js} +1 -1
  37. package/dist/assets/{secrets-config-page-GsahsEVw.js → secrets-config-page-B4py8-KR.js} +2 -2
  38. package/dist/assets/security-config-CTP4IQWw.js +1 -0
  39. package/dist/assets/{select-DPKCWajZ.js → select-BEr0Iy_f.js} +1 -1
  40. package/dist/assets/skeleton-B2NHluWf.js +1 -0
  41. package/dist/assets/{tag-chip-ClBWeQ4p.js → tag-chip-ByqDkn6g.js} +1 -1
  42. package/dist/assets/x-CENkFyK5.js +1 -0
  43. package/dist/index.html +21 -21
  44. package/package.json +6 -6
  45. package/src/app/configs/__tests__/app-navigation.config.test.ts +20 -0
  46. package/src/app/configs/app-navigation.config.ts +10 -0
  47. package/src/app/index.tsx +8 -1
  48. package/src/features/chat/components/conversation/__tests__/chat-conversation-workspace-section.test.tsx +29 -0
  49. package/src/features/chat/components/conversation/chat-conversation-workspace-section.tsx +4 -2
  50. package/src/features/chat/components/layout/__tests__/chat-page-shell.test.tsx +21 -0
  51. package/src/features/chat/components/layout/chat-page-shell.tsx +19 -4
  52. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +1 -1
  53. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +0 -1
  54. package/src/features/chat/features/conversation/utils/__tests__/session-message-composer.utils.test.ts +11 -4
  55. package/src/features/chat/features/conversation/utils/__tests__/session-queued-input.utils.test.ts +0 -1
  56. package/src/features/chat/features/conversation/utils/session-message-composer.utils.ts +10 -11
  57. package/src/features/chat/features/input/input-surface-plugins/__tests__/context-reference-plugin.test.ts +1 -0
  58. package/src/features/chat/features/message/hooks/use-chat-message-actions.tsx +0 -2
  59. package/src/features/chat/features/runtime/utils/chat-runtime.utils.ts +0 -14
  60. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +70 -22
  61. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +79 -32
  62. package/src/features/chat/features/session/utils/__tests__/chat-sidebar-session-groups.utils.test.ts +2 -0
  63. package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +0 -6
  64. package/src/features/chat/features/session/utils/chat-sidebar-session-groups.utils.ts +2 -0
  65. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-project-options.utils.test.ts +1 -0
  66. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +27 -0
  67. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +2 -14
  68. package/src/features/chat/types/chat-run.types.ts +0 -1
  69. package/src/features/panel-apps/managers/__tests__/panel-app-bridge.manager.test.ts +51 -0
  70. package/src/features/panel-apps/managers/panel-app-bridge.manager.ts +37 -2
  71. package/src/features/projects/AGENTS.md +6 -0
  72. package/src/features/projects/components/project-agreement.tsx +92 -0
  73. package/src/features/projects/components/project-artifacts.test.tsx +77 -0
  74. package/src/features/projects/components/project-artifacts.tsx +188 -0
  75. package/src/features/projects/components/project-overview.tsx +150 -0
  76. package/src/features/projects/components/project-requests.tsx +57 -0
  77. package/src/features/projects/components/project-runs.tsx +60 -0
  78. package/src/features/projects/components/project-section.tsx +60 -0
  79. package/src/features/projects/components/project-skills.tsx +53 -0
  80. package/src/features/projects/components/project-work-items.test.tsx +78 -0
  81. package/src/features/projects/components/project-work-items.tsx +272 -0
  82. package/src/features/projects/hooks/use-project-observation.test.tsx +93 -0
  83. package/src/features/projects/hooks/use-project-observation.ts +85 -0
  84. package/src/features/projects/index.ts +2 -0
  85. package/src/features/projects/pages/project-home-page.tsx +253 -0
  86. package/src/features/projects/pages/projects-page.test.tsx +366 -0
  87. package/src/features/projects/presenters/project-home.presenter.test.ts +74 -0
  88. package/src/features/projects/presenters/project-home.presenter.ts +55 -0
  89. package/src/features/projects/utils/project-artifact-view.utils.test.ts +63 -0
  90. package/src/features/projects/utils/project-artifact-view.utils.ts +62 -0
  91. package/src/features/projects/utils/project-request-response.utils.test.ts +44 -0
  92. package/src/features/projects/utils/project-request-response.utils.ts +39 -0
  93. package/src/shared/lib/i18n/index.ts +6 -2
  94. package/src/shared/lib/i18n/locales/en-US/projects.json +90 -0
  95. package/src/shared/lib/i18n/locales/zh-CN/projects.json +90 -0
  96. package/src/shared/lib/ui-document-title/__tests__/ui-document-title.test.ts +6 -0
  97. package/src/shared/lib/ui-document-title/index.ts +1 -0
  98. package/dist/assets/desktop-update-config-Cl-_XHqU.js +0 -1
  99. package/dist/assets/doc-browser-JlXVLNRe.js +0 -1
  100. package/dist/assets/index-CwCTiMM9.css +0 -1
  101. package/dist/assets/loader-circle-D9etMT9f.js +0 -1
  102. package/dist/assets/mcp-marketplace-page-DxPPmYqh.js +0 -1
  103. package/dist/assets/plus-DKGD04AO.js +0 -1
  104. package/dist/assets/privacy-settings-page-pNhQQT4r.js +0 -1
  105. package/dist/assets/providers-config-page-DftW_6KU.js +0 -1
  106. package/dist/assets/react-CVFx225w.js +0 -8
  107. package/dist/assets/remote-DS2TkMPm.js +0 -1
  108. package/dist/assets/runtime-config-page-Cd9sOYvn.js +0 -1
  109. package/dist/assets/security-config-BnHQyrtG.js +0 -1
  110. package/dist/assets/skeleton-BsR5rwB7.js +0 -1
  111. package/dist/assets/x-Dp5GAFmP.js +0 -1
@@ -0,0 +1,60 @@
1
+ import { Link } from "react-router-dom";
2
+ import type { ProjectObservationSnapshot } from "@nextclaw/client-sdk";
3
+ import { formatDateTime, t } from "@/shared/lib/i18n";
4
+ import { formatProjectRelativeTime } from "@/features/projects/utils/project-artifact-view.utils";
5
+
6
+ export function ProjectRuns({
7
+ limit,
8
+ snapshot,
9
+ }: {
10
+ limit?: number;
11
+ snapshot: ProjectObservationSnapshot;
12
+ }) {
13
+ const availableRuns = snapshot.runs ?? [];
14
+ const runs = limit === undefined ? availableRuns : availableRuns.slice(0, limit);
15
+ if (!runs.length) return null;
16
+
17
+ return (
18
+ <div className="space-y-2">
19
+ {runs.map((run) => {
20
+ const workItem = run.workItemId
21
+ ? snapshot.workItems.find((item) => item.id === run.workItemId)
22
+ : undefined;
23
+ const workflow = workItem?.workflowId
24
+ ? snapshot.workflows.find((item) => item.id === workItem.workflowId)
25
+ : undefined;
26
+ const stage = workItem?.stageId
27
+ ? workflow?.stages.find((item) => item.id === workItem.stageId)
28
+ : undefined;
29
+ const identity = [run.agentId, run.model].filter(Boolean).join(" · ");
30
+ return (
31
+ <Link
32
+ key={run.sessionId}
33
+ className="block rounded-xl bg-muted/45 p-3 text-sm transition-colors hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
34
+ to={`/chat/${encodeURIComponent(run.sessionId)}`}
35
+ >
36
+ <span className="flex min-w-0 items-center justify-between gap-3">
37
+ <span className="truncate font-medium">
38
+ {run.label ?? workItem?.name ?? t("projectsAiRun")}
39
+ </span>
40
+ <span className="shrink-0 text-xs text-muted-foreground">
41
+ {t(`projectsRunState_${run.state}`)}
42
+ </span>
43
+ </span>
44
+ <span className="mt-1 block truncate text-xs text-muted-foreground">
45
+ {workItem
46
+ ? `${workItem.name}${stage ? ` · ${stage.label}` : ""}`
47
+ : t("projectsRunUnreported")}
48
+ </span>
49
+ <span className="mt-1 block truncate text-xs text-muted-foreground">
50
+ {identity ? `${identity} · ` : ""}
51
+ <time dateTime={run.updatedAt} title={formatDateTime(run.updatedAt)}>
52
+ {formatProjectRelativeTime(run.updatedAt)}
53
+ </time>
54
+ </span>
55
+ </Link>
56
+ );
57
+ })}
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,60 @@
1
+ import type { ReactNode } from "react";
2
+ import {
3
+ Card,
4
+ CardContent,
5
+ CardHeader,
6
+ CardTitle,
7
+ } from "@/shared/components/ui/card";
8
+
9
+ export function ProjectSection({
10
+ title,
11
+ children,
12
+ className = "",
13
+ }: {
14
+ title: string;
15
+ children: ReactNode;
16
+ className?: string;
17
+ }) {
18
+ return (
19
+ <Card hover={false} surface="flat" className={className}>
20
+ <CardHeader className="p-4 pb-3">
21
+ <CardTitle>{title}</CardTitle>
22
+ </CardHeader>
23
+ <CardContent className="p-4 pt-0">{children}</CardContent>
24
+ </Card>
25
+ );
26
+ }
27
+
28
+ export function ProjectEmptyState({ children }: { children: ReactNode }) {
29
+ return <p className="py-5 text-sm text-muted-foreground">{children}</p>;
30
+ }
31
+
32
+ export function ProjectFilePreviewButton({
33
+ available,
34
+ children,
35
+ className,
36
+ label,
37
+ onOpen,
38
+ path,
39
+ }: {
40
+ available: boolean;
41
+ children: ReactNode;
42
+ className: string;
43
+ label: string;
44
+ onOpen: (path: string, label: string) => void;
45
+ path: string;
46
+ }) {
47
+ if (!available) {
48
+ return <div className={className}>{children}</div>;
49
+ }
50
+ return (
51
+ <button
52
+ type="button"
53
+ className={className}
54
+ title={path}
55
+ onClick={() => onOpen(path, label)}
56
+ >
57
+ {children}
58
+ </button>
59
+ );
60
+ }
@@ -0,0 +1,53 @@
1
+ import type { ProjectObservationSnapshot } from "@nextclaw/client-sdk";
2
+ import { formatNumber, t } from "@/shared/lib/i18n";
3
+ import { ProjectEmptyState } from "./project-section";
4
+
5
+ export function ProjectSkills({
6
+ snapshot,
7
+ onOpen,
8
+ }: {
9
+ snapshot: ProjectObservationSnapshot;
10
+ onOpen: (skill: ProjectObservationSnapshot["skills"][number]) => void;
11
+ }) {
12
+ return (
13
+ <section className="min-w-0" aria-label={t("projectsSkills")}>
14
+ <p className="mb-3 text-sm text-muted-foreground">
15
+ {formatNumber(snapshot.skills.length)} {t("projectsSkillsCountSuffix")}
16
+ </p>
17
+ {snapshot.skills.length ? (
18
+ <div className="grid min-w-0 gap-3 md:grid-cols-2">
19
+ {snapshot.skills.map((skill) => (
20
+ <button
21
+ type="button"
22
+ key={skill.ref}
23
+ className="min-w-0 rounded-xl border border-border/60 p-3 text-left transition-colors enabled:hover:border-border enabled:hover:bg-muted/30 disabled:cursor-not-allowed"
24
+ disabled={!skill.readable}
25
+ onClick={() => onOpen(skill)}
26
+ >
27
+ <h3 className="text-sm font-medium">{skill.name}</h3>
28
+ <p
29
+ className="mt-1 truncate text-xs text-muted-foreground"
30
+ title={skill.path}
31
+ >
32
+ {skill.path}
33
+ </p>
34
+ <p
35
+ className="mt-2 line-clamp-2 text-xs text-muted-foreground"
36
+ title={skill.description ?? skill.ref}
37
+ >
38
+ {skill.description ?? skill.ref}
39
+ </p>
40
+ {!skill.readable ? (
41
+ <p className="mt-2 text-xs text-destructive">
42
+ {t("projectsUnavailable")}
43
+ </p>
44
+ ) : null}
45
+ </button>
46
+ ))}
47
+ </div>
48
+ ) : (
49
+ <ProjectEmptyState>{t("projectsNoSkills")}</ProjectEmptyState>
50
+ )}
51
+ </section>
52
+ );
53
+ }
@@ -0,0 +1,78 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import type { ProjectObservationSnapshot } from "@nextclaw/client-sdk";
4
+ import { ProjectWorkItems } from "./project-work-items";
5
+
6
+ const reference = {
7
+ kind: "ai-report" as const,
8
+ label: "AI report",
9
+ observedAt: "2026-08-30T00:00:00.000Z",
10
+ sessionId: "session-1",
11
+ };
12
+
13
+ function createSnapshot(): ProjectObservationSnapshot {
14
+ return {
15
+ asOf: reference.observedAt,
16
+ project: { name: "Demo", rootPath: "/tmp/demo", context: [] },
17
+ sources: [],
18
+ workflows: [{
19
+ id: "research",
20
+ label: "Research",
21
+ stages: [{ id: "draft", label: "Draft" }],
22
+ reference,
23
+ }],
24
+ runs: [],
25
+ workItems: [{
26
+ id: "report",
27
+ name: "Write the report",
28
+ status: "active",
29
+ workflowId: "research",
30
+ stageId: "draft",
31
+ updatedAt: reference.observedAt,
32
+ reference,
33
+ }],
34
+ artifactCategories: [], artifacts: [], signals: [], requests: [], activity: [], skills: [], diagnostics: [],
35
+ dataQuality: "complete",
36
+ };
37
+ }
38
+
39
+ describe("ProjectWorkItems", () => {
40
+ it("switches list, board, and gantt over one snapshot without fabricating a schedule", () => {
41
+ render(
42
+ <ProjectWorkItems
43
+ hasProjectSessions
44
+ snapshot={createSnapshot()}
45
+ shouldOfferObservationSetup={false}
46
+ onStartObservationSetup={vi.fn()}
47
+ onStartProjectWork={vi.fn()}
48
+ />,
49
+ );
50
+ expect(screen.getByText("Write the report")).toBeTruthy();
51
+
52
+ fireEvent.click(screen.getByRole("button", { name: /Board|看板/ }));
53
+ expect(screen.getByText("Draft")).toBeTruthy();
54
+ expect(screen.getByText("Write the report")).toBeTruthy();
55
+
56
+ fireEvent.click(screen.getByRole("button", { name: /Gantt|甘特图/ }));
57
+ expect(screen.getByText(/No schedule markers|没有 schedule Marker/)).toBeTruthy();
58
+ });
59
+
60
+ it("offers an ordinary project conversation before rendering empty view controls", () => {
61
+ const onStartProjectWork = vi.fn();
62
+ render(
63
+ <ProjectWorkItems
64
+ hasProjectSessions={false}
65
+ snapshot={{ ...createSnapshot(), workItems: [] }}
66
+ shouldOfferObservationSetup={false}
67
+ onStartObservationSetup={vi.fn()}
68
+ onStartProjectWork={onStartProjectWork}
69
+ />,
70
+ );
71
+
72
+ expect(screen.queryByRole("group", { name: /Work item views|工作项视图/ })).toBeNull();
73
+ fireEvent.click(
74
+ screen.getByRole("button", { name: /Start project work|开始项目工作/ }),
75
+ );
76
+ expect(onStartProjectWork).toHaveBeenCalledOnce();
77
+ });
78
+ });
@@ -0,0 +1,272 @@
1
+ import { useState } from "react";
2
+ import type {
3
+ ObservedWorkItem,
4
+ ProjectObservationSnapshot,
5
+ } from "@nextclaw/client-sdk";
6
+ import { Button } from "@/shared/components/ui/button";
7
+ import { t } from "@/shared/lib/i18n";
8
+ import {
9
+ createProjectBoardColumns,
10
+ getScheduledWorkItems,
11
+ getUnstagedWorkItems,
12
+ type ProjectWorkView,
13
+ } from "@/features/projects/presenters/project-home.presenter";
14
+ import { ProjectEmptyState } from "./project-section";
15
+ import { ProjectRuns } from "./project-runs";
16
+
17
+ function WorkItemCard({
18
+ item,
19
+ onSelect,
20
+ }: {
21
+ item: ObservedWorkItem;
22
+ onSelect: () => void;
23
+ }) {
24
+ return (
25
+ <button
26
+ type="button"
27
+ className="w-full rounded-xl bg-card p-3 text-left text-sm shadow-sm hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
28
+ onClick={onSelect}
29
+ >
30
+ <span className="block font-medium">{item.name}</span>
31
+ <span className="mt-1 block text-xs text-muted-foreground">
32
+ {item.reference.label} · {t(`projectsStatus_${item.status}`)}
33
+ </span>
34
+ </button>
35
+ );
36
+ }
37
+
38
+ function ProjectWorkItemsEmptyState({
39
+ hasProjectSessions,
40
+ onStartObservationSetup,
41
+ onStartProjectWork,
42
+ shouldOfferObservationSetup,
43
+ }: {
44
+ hasProjectSessions: boolean;
45
+ onStartObservationSetup: () => void;
46
+ onStartProjectWork: () => void;
47
+ shouldOfferObservationSetup: boolean;
48
+ }) {
49
+ const messageKey = shouldOfferObservationSetup
50
+ ? "projectsWorkNeedsSetup"
51
+ : hasProjectSessions
52
+ ? "projectsWorkNoReports"
53
+ : "projectsWorkNoSessions";
54
+ return (
55
+ <div className="min-w-0">
56
+ <ProjectEmptyState>{t(messageKey)}</ProjectEmptyState>
57
+ <Button
58
+ size="sm"
59
+ variant="outline"
60
+ onClick={
61
+ shouldOfferObservationSetup
62
+ ? onStartObservationSetup
63
+ : onStartProjectWork
64
+ }
65
+ >
66
+ {t(
67
+ shouldOfferObservationSetup
68
+ ? "projectsObservationSetupAction"
69
+ : "projectsStartProjectWork",
70
+ )}
71
+ </Button>
72
+ </div>
73
+ );
74
+ }
75
+
76
+ export function ProjectWorkItems({
77
+ hasProjectSessions,
78
+ onStartObservationSetup,
79
+ onStartProjectWork,
80
+ snapshot,
81
+ shouldOfferObservationSetup,
82
+ }: {
83
+ hasProjectSessions: boolean;
84
+ onStartObservationSetup: () => void;
85
+ onStartProjectWork: () => void;
86
+ snapshot: ProjectObservationSnapshot;
87
+ shouldOfferObservationSetup: boolean;
88
+ }) {
89
+ const [view, setView] = useState<ProjectWorkView>("list");
90
+ const [selectedId, setSelectedId] = useState<string | null>(null);
91
+ const selected =
92
+ snapshot.workItems.find((item) => item.id === selectedId) ?? null;
93
+ const columns = createProjectBoardColumns(
94
+ snapshot.workflows,
95
+ snapshot.workItems,
96
+ );
97
+ const unstaged = getUnstagedWorkItems(snapshot.workItems);
98
+ const scheduled = getScheduledWorkItems(snapshot.workItems);
99
+ return (
100
+ <section className="min-w-0" aria-label={t("projectsWork")}>
101
+ {(snapshot.runs?.length ?? 0) > 0 ? (
102
+ <div className="mb-5">
103
+ <h2 className="mb-2 text-sm font-semibold">{t("projectsAiActivity")}</h2>
104
+ <ProjectRuns snapshot={snapshot} />
105
+ </div>
106
+ ) : null}
107
+ {snapshot.workItems.length === 0 ? (
108
+ <ProjectWorkItemsEmptyState
109
+ hasProjectSessions={hasProjectSessions}
110
+ onStartObservationSetup={onStartObservationSetup}
111
+ onStartProjectWork={onStartProjectWork}
112
+ shouldOfferObservationSetup={shouldOfferObservationSetup}
113
+ />
114
+ ) : (
115
+ <>
116
+ <div
117
+ className="mb-4 flex flex-wrap gap-1"
118
+ role="group"
119
+ aria-label={t("projectsWorkViews")}
120
+ >
121
+ {(["list", "board", "gantt"] as const).map((key) => (
122
+ <Button
123
+ key={key}
124
+ size="sm"
125
+ variant={view === key ? "secondary" : "ghost"}
126
+ aria-pressed={view === key}
127
+ onClick={() => setView(key)}
128
+ >
129
+ {t(`projectsWork${key[0].toUpperCase()}${key.slice(1)}`)}
130
+ </Button>
131
+ ))}
132
+ </div>
133
+ {view === "list" ? (
134
+ <div className="space-y-2">
135
+ {snapshot.workItems.map((item) => (
136
+ <WorkItemCard
137
+ key={item.id}
138
+ item={item}
139
+ onSelect={() => setSelectedId(item.id)}
140
+ />
141
+ ))}
142
+ </div>
143
+ ) : null}
144
+ {view === "board" ? (
145
+ columns.length ? (
146
+ <div className="grid min-w-0 gap-3 md:grid-cols-2 xl:grid-cols-4">
147
+ {columns.map((column) => (
148
+ <section
149
+ key={column.id}
150
+ className="min-w-0 rounded-xl bg-muted/35 p-3"
151
+ >
152
+ <h3 className="mb-3 text-xs font-semibold">{column.label}</h3>
153
+ <div className="space-y-2">
154
+ {column.items.map((item) => (
155
+ <WorkItemCard
156
+ key={item.id}
157
+ item={item}
158
+ onSelect={() => setSelectedId(item.id)}
159
+ />
160
+ ))}
161
+ {!column.items.length ? (
162
+ <p className="py-3 text-xs text-muted-foreground">
163
+ {t("projectsNoData")}
164
+ </p>
165
+ ) : null}
166
+ </div>
167
+ </section>
168
+ ))}
169
+ {unstaged.length ? (
170
+ <section className="min-w-0 rounded-xl bg-muted/35 p-3">
171
+ <h3 className="mb-3 text-xs font-semibold">
172
+ {t("projectsUnstaged")}
173
+ </h3>
174
+ <div className="space-y-2">
175
+ {unstaged.map((item) => (
176
+ <WorkItemCard
177
+ key={item.id}
178
+ item={item}
179
+ onSelect={() => setSelectedId(item.id)}
180
+ />
181
+ ))}
182
+ </div>
183
+ </section>
184
+ ) : null}
185
+ </div>
186
+ ) : (
187
+ <ProjectEmptyState>
188
+ {t("projectsBoardNeedsWorkflow")}
189
+ </ProjectEmptyState>
190
+ )
191
+ ) : null}
192
+ {view === "gantt" ? (
193
+ scheduled.length ? (
194
+ <div className="overflow-x-auto rounded-xl border border-border/60">
195
+ <div className="min-w-[42rem] divide-y divide-border/60">
196
+ {scheduled.map((item) => (
197
+ <button
198
+ key={item.id}
199
+ type="button"
200
+ className="grid w-full grid-cols-[minmax(12rem,1fr)_9rem_9rem] gap-3 px-4 py-3 text-left text-sm hover:bg-[var(--interaction-hover)]"
201
+ onClick={() => setSelectedId(item.id)}
202
+ >
203
+ <span className="truncate font-medium">{item.name}</span>
204
+ <span>{item.schedule?.start ?? "—"}</span>
205
+ <span>{item.schedule?.end ?? "—"}</span>
206
+ </button>
207
+ ))}
208
+ </div>
209
+ </div>
210
+ ) : (
211
+ <ProjectEmptyState>
212
+ {t("projectsGanttNeedsSchedule")}
213
+ </ProjectEmptyState>
214
+ )
215
+ ) : null}
216
+ {selected ? (
217
+ <aside
218
+ className="mt-4 rounded-xl border border-border/60 bg-muted/25 p-4"
219
+ aria-label={t("projectsWorkDetails")}
220
+ >
221
+ <div className="flex items-start justify-between gap-3">
222
+ <div>
223
+ <h3 className="font-semibold">{selected.name}</h3>
224
+ <p className="mt-1 text-xs text-muted-foreground">
225
+ {selected.id} · {selected.reference.label}
226
+ </p>
227
+ </div>
228
+ <Button
229
+ size="sm"
230
+ variant="ghost"
231
+ onClick={() => setSelectedId(null)}
232
+ >
233
+ {t("projectsCloseDetails")}
234
+ </Button>
235
+ </div>
236
+ <dl className="mt-3 grid gap-2 text-sm sm:grid-cols-2">
237
+ <div>
238
+ <dt className="text-xs text-muted-foreground">
239
+ {t("projectsStatus")}
240
+ </dt>
241
+ <dd>{t(`projectsStatus_${selected.status}`)}</dd>
242
+ </div>
243
+ <div>
244
+ <dt className="text-xs text-muted-foreground">
245
+ {t("projectsStage")}
246
+ </dt>
247
+ <dd>{selected.stageId ?? t("projectsNoData")}</dd>
248
+ </div>
249
+ <div>
250
+ <dt className="text-xs text-muted-foreground">
251
+ {t("projectsUpdated")}
252
+ </dt>
253
+ <dd>{selected.updatedAt}</dd>
254
+ </div>
255
+ <div>
256
+ <dt className="text-xs text-muted-foreground">
257
+ {t("projectsSchedule")}
258
+ </dt>
259
+ <dd>
260
+ {selected.schedule
261
+ ? `${selected.schedule.start ?? "—"} → ${selected.schedule.end ?? "—"}`
262
+ : t("projectsNoData")}
263
+ </dd>
264
+ </div>
265
+ </dl>
266
+ </aside>
267
+ ) : null}
268
+ </>
269
+ )}
270
+ </section>
271
+ );
272
+ }
@@ -0,0 +1,93 @@
1
+ import { act, renderHook, waitFor } from "@testing-library/react";
2
+ import type { ReactNode } from "react";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { NcpEventType } from "@nextclaw/ncp";
5
+ import { eventKeys, type ProjectObservationSnapshot } from "@nextclaw/client-sdk";
6
+ import { EventBus } from "@nextclaw/shared";
7
+ import { afterEach, describe, expect, it, vi } from "vitest";
8
+ import { nextclawClient } from "@/shared/lib/api";
9
+ import { useProjectObservation } from "./use-project-observation";
10
+
11
+ const mocks = vi.hoisted(() => ({ getObservation: vi.fn() }));
12
+
13
+ vi.mock("@/shared/lib/api", () => ({
14
+ nextclawClient: {
15
+ eventBus: new EventBus(),
16
+ projects: { getObservation: mocks.getObservation },
17
+ },
18
+ }));
19
+
20
+ const observedAt = "2026-08-31T00:00:00.000Z";
21
+ const snapshot: ProjectObservationSnapshot = {
22
+ asOf: observedAt,
23
+ project: { name: "Demo", rootPath: "/tmp/demo", context: [] },
24
+ sources: [],
25
+ workflows: [],
26
+ runs: [{
27
+ sessionId: "session-1",
28
+ state: "running",
29
+ updatedAt: observedAt,
30
+ reference: {
31
+ kind: "system-record",
32
+ label: "Session run",
33
+ observedAt,
34
+ sessionId: "session-1",
35
+ },
36
+ }],
37
+ workItems: [],
38
+ artifactCategories: [],
39
+ artifacts: [],
40
+ signals: [],
41
+ requests: [],
42
+ activity: [],
43
+ skills: [],
44
+ diagnostics: [],
45
+ dataQuality: "complete",
46
+ };
47
+
48
+ afterEach(() => {
49
+ vi.useRealTimers();
50
+ mocks.getObservation.mockReset();
51
+ });
52
+
53
+ describe("useProjectObservation", () => {
54
+ it("refreshes only for the active project's session lifecycle", async () => {
55
+ mocks.getObservation.mockResolvedValue(snapshot);
56
+ const queryClient = new QueryClient({
57
+ defaultOptions: { queries: { retry: false } },
58
+ });
59
+ const wrapper = ({ children }: { children: ReactNode }) => (
60
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
61
+ );
62
+ const { result } = renderHook(
63
+ () => useProjectObservation("project-1", "/tmp/demo"),
64
+ { wrapper },
65
+ );
66
+ await waitFor(() => expect(result.current.data).toEqual(snapshot));
67
+
68
+ act(() => {
69
+ nextclawClient.eventBus.emit(eventKeys.sessionSummaryUpsert, {
70
+ summary: {
71
+ sessionId: "other-session",
72
+ messageCount: 0,
73
+ updatedAt: observedAt,
74
+ metadata: { project_root: "/tmp/other" },
75
+ },
76
+ });
77
+ });
78
+ await new Promise((resolve) => setTimeout(resolve, 150));
79
+ expect(mocks.getObservation).toHaveBeenCalledTimes(1);
80
+
81
+ act(() => {
82
+ nextclawClient.eventBus.emit(eventKeys.ncpEvent, {
83
+ type: NcpEventType.MessageTextDelta,
84
+ payload: {
85
+ sessionId: "session-1",
86
+ messageId: "assistant-1",
87
+ delta: '[nextclaw.project/v1 id=wi_7km4q2x9dn name="Live work" stage=exploration]',
88
+ },
89
+ });
90
+ });
91
+ await waitFor(() => expect(mocks.getObservation).toHaveBeenCalledTimes(2));
92
+ });
93
+ });