@nextclaw/ui 0.23.0 → 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 (109) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/assets/{api-n96IfCKb.js → api-CoqPRKmF.js} +5 -5
  3. package/dist/assets/{appearance-settings-page-C_AqxSF_.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-uR-M4Ls8.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-CHVBMpla.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-phMuNq1B.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-CQKXTqTI.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-CLw9DuCJ.js → index-XxXj8WDS.js} +9 -9
  20. package/dist/assets/loader-circle-0qPOOYQ5.js +1 -0
  21. package/dist/assets/{mcp-marketplace-page-s9PTnSDT.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-CxnCUWRW.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-D3_qNXbB.js → search-config-page-CxBacx6W.js} +1 -1
  37. package/dist/assets/{secrets-config-page-Be4P1E8o.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-Dc_NcCEe.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/projects/AGENTS.md +6 -0
  70. package/src/features/projects/components/project-agreement.tsx +92 -0
  71. package/src/features/projects/components/project-artifacts.test.tsx +77 -0
  72. package/src/features/projects/components/project-artifacts.tsx +188 -0
  73. package/src/features/projects/components/project-overview.tsx +150 -0
  74. package/src/features/projects/components/project-requests.tsx +57 -0
  75. package/src/features/projects/components/project-runs.tsx +60 -0
  76. package/src/features/projects/components/project-section.tsx +60 -0
  77. package/src/features/projects/components/project-skills.tsx +53 -0
  78. package/src/features/projects/components/project-work-items.test.tsx +78 -0
  79. package/src/features/projects/components/project-work-items.tsx +272 -0
  80. package/src/features/projects/hooks/use-project-observation.test.tsx +93 -0
  81. package/src/features/projects/hooks/use-project-observation.ts +85 -0
  82. package/src/features/projects/index.ts +2 -0
  83. package/src/features/projects/pages/project-home-page.tsx +253 -0
  84. package/src/features/projects/pages/projects-page.test.tsx +366 -0
  85. package/src/features/projects/presenters/project-home.presenter.test.ts +74 -0
  86. package/src/features/projects/presenters/project-home.presenter.ts +55 -0
  87. package/src/features/projects/utils/project-artifact-view.utils.test.ts +63 -0
  88. package/src/features/projects/utils/project-artifact-view.utils.ts +62 -0
  89. package/src/features/projects/utils/project-request-response.utils.test.ts +44 -0
  90. package/src/features/projects/utils/project-request-response.utils.ts +39 -0
  91. package/src/shared/lib/i18n/index.ts +6 -2
  92. package/src/shared/lib/i18n/locales/en-US/projects.json +90 -0
  93. package/src/shared/lib/i18n/locales/zh-CN/projects.json +90 -0
  94. package/src/shared/lib/ui-document-title/__tests__/ui-document-title.test.ts +6 -0
  95. package/src/shared/lib/ui-document-title/index.ts +1 -0
  96. package/dist/assets/desktop-update-config-C77hkbor.js +0 -1
  97. package/dist/assets/doc-browser-JlXVLNRe.js +0 -1
  98. package/dist/assets/index-CwCTiMM9.css +0 -1
  99. package/dist/assets/loader-circle-D9etMT9f.js +0 -1
  100. package/dist/assets/mcp-marketplace-page-CbWOamyW.js +0 -1
  101. package/dist/assets/plus-DKGD04AO.js +0 -1
  102. package/dist/assets/privacy-settings-page-D8HZHHjL.js +0 -1
  103. package/dist/assets/providers-config-page-C0GIoGxm.js +0 -1
  104. package/dist/assets/react-CVFx225w.js +0 -8
  105. package/dist/assets/remote-DvTCSAxQ.js +0 -1
  106. package/dist/assets/runtime-config-page-VNJZui5f.js +0 -1
  107. package/dist/assets/security-config-BtcpKIhQ.js +0 -1
  108. package/dist/assets/skeleton-BsR5rwB7.js +0 -1
  109. package/dist/assets/x-Dp5GAFmP.js +0 -1
@@ -0,0 +1,77 @@
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 { ProjectArtifacts } from "./project-artifacts";
5
+
6
+ const observedAt = "2026-08-30T00:00:00.000Z";
7
+ const designs = Array.from({ length: 11 }, (_, index) => ({
8
+ id: `design-${index + 1}`,
9
+ path: `docs/designs/${index + 1}.md`,
10
+ categoryId: "designs",
11
+ categoryLabel: "Designs",
12
+ exists: true,
13
+ fileUpdatedAt: `2026-08-${String(index + 1).padStart(2, "0")}T00:00:00.000Z`,
14
+ references: [],
15
+ }));
16
+
17
+ const snapshot: ProjectObservationSnapshot = {
18
+ asOf: observedAt,
19
+ project: { name: "Research", rootPath: "/tmp/research", context: [] },
20
+ sources: [],
21
+ workflows: [],
22
+ runs: [],
23
+ workItems: [],
24
+ artifactCategories: [
25
+ { id: "plans", label: "Plans" },
26
+ { id: "designs", label: "Designs" },
27
+ ],
28
+ artifacts: [
29
+ ...designs,
30
+ {
31
+ id: "plan-1",
32
+ path: "docs/plans/delivery.md",
33
+ categoryId: "plans",
34
+ categoryLabel: "Plans",
35
+ exists: true,
36
+ fileUpdatedAt: "2026-08-30T00:00:00.000Z",
37
+ references: [],
38
+ },
39
+ ],
40
+ signals: [],
41
+ requests: [],
42
+ activity: [],
43
+ skills: [],
44
+ diagnostics: [],
45
+ dataQuality: "complete",
46
+ };
47
+
48
+ describe("ProjectArtifacts", () => {
49
+ it("groups in configuration order, expands incrementally, filters, and opens the selected file", () => {
50
+ const onOpenFile = vi.fn();
51
+ render(<ProjectArtifacts snapshot={snapshot} onOpenFile={onOpenFile} />);
52
+
53
+ const groupHeaders = screen.getAllByRole("button", { name: /Plans 1|Designs 11/ });
54
+ expect(groupHeaders.map((header) => header.getAttribute("aria-label"))).toEqual([
55
+ "Plans 1",
56
+ "Designs 11",
57
+ ]);
58
+ expect(screen.getByTitle("docs/designs/11.md")).toBeTruthy();
59
+ expect(screen.queryByTitle("docs/designs/1.md")).toBeNull();
60
+
61
+ fireEvent.click(screen.getByRole("button", { name: /Show more \(1\)/ }));
62
+ expect(screen.getByTitle("docs/designs/1.md")).toBeTruthy();
63
+
64
+ fireEvent.change(screen.getByPlaceholderText("Search artifacts"), {
65
+ target: { value: "delivery" },
66
+ });
67
+ expect(screen.queryByRole("button", { name: /Designs 11/ })).toBeNull();
68
+ expect(screen.getByTitle("docs/plans/delivery.md")).toBeTruthy();
69
+ fireEvent.click(screen.getByTitle("docs/plans/delivery.md"));
70
+ expect(onOpenFile).toHaveBeenCalledWith("docs/plans/delivery.md", "docs/plans/delivery.md");
71
+
72
+ const plansHeader = screen.getByRole("button", { name: /Plans 1/ });
73
+ fireEvent.click(plansHeader);
74
+ expect(plansHeader.getAttribute("aria-expanded")).toBe("false");
75
+ expect(screen.queryByTitle("docs/plans/delivery.md")).toBeNull();
76
+ });
77
+ });
@@ -0,0 +1,188 @@
1
+ import { useMemo, useState } from "react";
2
+ import type { ProjectObservationSnapshot } from "@nextclaw/client-sdk";
3
+ import { formatDateTime, formatNumber, t } from "@/shared/lib/i18n";
4
+ import {
5
+ Select,
6
+ SelectContent,
7
+ SelectItem,
8
+ SelectTrigger,
9
+ SelectValue,
10
+ } from "@/shared/components/ui/select";
11
+ import { ProjectEmptyState, ProjectFilePreviewButton } from "./project-section";
12
+ import {
13
+ DEFAULT_PROJECT_ARTIFACT_SORT,
14
+ formatProjectRelativeTime,
15
+ sortProjectArtifacts,
16
+ type ProjectArtifactSort,
17
+ } from "../utils/project-artifact-view.utils";
18
+
19
+ const INITIAL_VISIBLE_COUNT = 10;
20
+
21
+ type ArtifactGroup = {
22
+ id: string;
23
+ label: string;
24
+ artifacts: ProjectObservationSnapshot["artifacts"];
25
+ };
26
+
27
+ function createArtifactGroups(
28
+ snapshot: ProjectObservationSnapshot,
29
+ query: string,
30
+ sort: ProjectArtifactSort,
31
+ ): ArtifactGroup[] {
32
+ const normalizedQuery = query.trim().toLocaleLowerCase();
33
+ const filteredArtifacts = normalizedQuery
34
+ ? snapshot.artifacts.filter((artifact) => artifact.path.toLocaleLowerCase().includes(normalizedQuery))
35
+ : snapshot.artifacts;
36
+ const artifacts = sortProjectArtifacts(filteredArtifacts, sort);
37
+ const artifactsByCategory = new Map<string, ProjectObservationSnapshot["artifacts"]>();
38
+ for (const artifact of artifacts) {
39
+ const group = artifactsByCategory.get(artifact.categoryId) ?? [];
40
+ group.push(artifact);
41
+ artifactsByCategory.set(artifact.categoryId, group);
42
+ }
43
+ const configuredCategories = snapshot.artifactCategories ?? [];
44
+ const configuredIds = new Set(configuredCategories.map((category) => category.id));
45
+ const groups = configuredCategories.flatMap((category) => {
46
+ const groupArtifacts = artifactsByCategory.get(category.id) ?? [];
47
+ return groupArtifacts.length ? [{ ...category, artifacts: groupArtifacts }] : [];
48
+ });
49
+ const unconfiguredGroups = [...artifactsByCategory.entries()]
50
+ .filter(([categoryId]) => !configuredIds.has(categoryId))
51
+ .map(([id, groupArtifacts]) => ({
52
+ id,
53
+ label: groupArtifacts[0]?.categoryLabel ?? id,
54
+ artifacts: groupArtifacts,
55
+ }))
56
+ .sort((left, right) => left.label.localeCompare(right.label));
57
+ return [...groups, ...unconfiguredGroups];
58
+ }
59
+
60
+ export function ProjectArtifacts({
61
+ snapshot,
62
+ onOpenFile,
63
+ }: {
64
+ snapshot: ProjectObservationSnapshot;
65
+ onOpenFile: (path: string, label: string) => void;
66
+ }) {
67
+ const [query, setQuery] = useState("");
68
+ const [sort, setSort] = useState<ProjectArtifactSort>(DEFAULT_PROJECT_ARTIFACT_SORT);
69
+ const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set());
70
+ const [visibleCounts, setVisibleCounts] = useState<Record<string, number>>({});
71
+ const groups = useMemo(() => createArtifactGroups(snapshot, query, sort), [query, snapshot, sort]);
72
+ const canSortByCreated = snapshot.artifacts.some((artifact) => artifact.fileCreatedAt);
73
+
74
+ if (!snapshot.artifacts.length) {
75
+ return <ProjectEmptyState>{t("projectsNoArtifacts")}</ProjectEmptyState>;
76
+ }
77
+
78
+ return (
79
+ <section className="min-w-0" aria-label={t("projectsArtifacts")}>
80
+ <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
81
+ <p className="text-sm text-muted-foreground">
82
+ {formatNumber(snapshot.artifacts.length)} {t("projectsArtifactsCountSuffix")}
83
+ </p>
84
+ <div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
85
+ <Select value={sort} onValueChange={(value) => setSort(value as ProjectArtifactSort)}>
86
+ <SelectTrigger aria-label={t("projectsArtifactsSort")} className="h-8 w-32 rounded-lg">
87
+ <SelectValue />
88
+ </SelectTrigger>
89
+ <SelectContent>
90
+ <SelectItem value="updated-desc">{t("projectsArtifactsSortUpdatedDesc")}</SelectItem>
91
+ <SelectItem value="updated-asc">{t("projectsArtifactsSortUpdatedAsc")}</SelectItem>
92
+ {canSortByCreated ? (
93
+ <SelectItem value="created-desc">{t("projectsArtifactsSortCreatedDesc")}</SelectItem>
94
+ ) : null}
95
+ <SelectItem value="name">{t("projectsArtifactsSortName")}</SelectItem>
96
+ </SelectContent>
97
+ </Select>
98
+ <input
99
+ type="search"
100
+ value={query}
101
+ className="h-8 min-w-0 flex-1 rounded-lg border border-border/60 bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-border sm:w-60"
102
+ placeholder={t("projectsArtifactsSearchPlaceholder")}
103
+ onChange={(event) => {
104
+ setQuery(event.target.value);
105
+ setVisibleCounts({});
106
+ }}
107
+ />
108
+ </div>
109
+ </div>
110
+ {groups.length ? (
111
+ <div className="space-y-2">
112
+ {groups.map((group) => {
113
+ const collapsed = collapsedGroups.has(group.id);
114
+ const visibleCount = visibleCounts[group.id] ?? INITIAL_VISIBLE_COUNT;
115
+ const visibleArtifacts = group.artifacts.slice(0, visibleCount);
116
+ return (
117
+ <section key={group.id} className="overflow-hidden rounded-xl border border-border/60">
118
+ <button
119
+ type="button"
120
+ className="flex w-full items-center justify-between gap-3 bg-muted/30 px-3 py-2.5 text-left text-sm transition-colors hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
121
+ aria-label={`${group.label} ${formatNumber(group.artifacts.length)}`}
122
+ aria-expanded={!collapsed}
123
+ onClick={() => setCollapsedGroups((current) => {
124
+ const next = new Set(current);
125
+ if (next.has(group.id)) next.delete(group.id);
126
+ else next.add(group.id);
127
+ return next;
128
+ })}
129
+ >
130
+ <span className="min-w-0 truncate font-medium">{group.label}</span>
131
+ <span className="shrink-0 text-xs text-muted-foreground">
132
+ {formatNumber(group.artifacts.length)} {collapsed ? "▸" : "▾"}
133
+ </span>
134
+ </button>
135
+ {!collapsed ? (
136
+ <div className="divide-y divide-border/60">
137
+ {visibleArtifacts.map((artifact) => (
138
+ <ProjectFilePreviewButton
139
+ key={artifact.id}
140
+ available={artifact.exists}
141
+ path={artifact.path}
142
+ label={artifact.path}
143
+ onOpen={onOpenFile}
144
+ className="flex w-full min-w-0 items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors enabled:hover:bg-[var(--interaction-hover)] enabled:focus-visible:outline-none enabled:focus-visible:ring-1 enabled:focus-visible:ring-border"
145
+ >
146
+ <span className="min-w-0 flex-1 truncate">{artifact.path}</span>
147
+ {artifact.fileUpdatedAt ? (
148
+ <time
149
+ className="shrink-0 text-xs text-muted-foreground"
150
+ dateTime={artifact.fileUpdatedAt}
151
+ title={formatDateTime(artifact.fileUpdatedAt)}
152
+ >
153
+ {formatProjectRelativeTime(artifact.fileUpdatedAt)}
154
+ </time>
155
+ ) : null}
156
+ {!artifact.exists ? (
157
+ <span className="shrink-0 text-xs text-destructive">
158
+ {t("projectsUnavailable")}
159
+ </span>
160
+ ) : null}
161
+ </ProjectFilePreviewButton>
162
+ ))}
163
+ {group.artifacts.length > visibleArtifacts.length ? (
164
+ <button
165
+ type="button"
166
+ className="w-full px-3 py-2.5 text-left text-sm text-muted-foreground transition-colors hover:bg-[var(--interaction-hover)] hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
167
+ onClick={() => setVisibleCounts((current) => ({
168
+ ...current,
169
+ [group.id]: visibleCount + INITIAL_VISIBLE_COUNT,
170
+ }))}
171
+ >
172
+ {t("projectsArtifactsShowMore")} ({formatNumber(
173
+ Math.min(INITIAL_VISIBLE_COUNT, group.artifacts.length - visibleArtifacts.length),
174
+ )})
175
+ </button>
176
+ ) : null}
177
+ </div>
178
+ ) : null}
179
+ </section>
180
+ );
181
+ })}
182
+ </div>
183
+ ) : (
184
+ <ProjectEmptyState>{t("projectsArtifactsNoMatches")}</ProjectEmptyState>
185
+ )}
186
+ </section>
187
+ );
188
+ }
@@ -0,0 +1,150 @@
1
+ import { Link } from "react-router-dom";
2
+ import type { ProjectObservationSnapshot } from "@nextclaw/client-sdk";
3
+ import { Button } from "@/shared/components/ui/button";
4
+ import { formatDateTime, t } from "@/shared/lib/i18n";
5
+ import {
6
+ ProjectEmptyState,
7
+ ProjectFilePreviewButton,
8
+ ProjectSection,
9
+ } from "./project-section";
10
+ import {
11
+ DEFAULT_PROJECT_ARTIFACT_SORT,
12
+ formatProjectRelativeTime,
13
+ sortProjectArtifacts,
14
+ } from "../utils/project-artifact-view.utils";
15
+ import { ProjectRuns } from "./project-runs";
16
+
17
+ export function ProjectOverview({
18
+ hasProjectSessions,
19
+ snapshot,
20
+ onStartObservationSetup,
21
+ onStartProjectWork,
22
+ onOpenFile,
23
+ shouldOfferObservationSetup,
24
+ }: {
25
+ hasProjectSessions: boolean;
26
+ snapshot: ProjectObservationSnapshot;
27
+ onStartObservationSetup: () => void;
28
+ onStartProjectWork: () => void;
29
+ onOpenFile: (path: string, label: string) => void;
30
+ shouldOfferObservationSetup: boolean;
31
+ }) {
32
+ const openSignals = snapshot.signals.filter((signal) => signal.status === "open");
33
+ const recentArtifacts = sortProjectArtifacts(snapshot.artifacts, DEFAULT_PROJECT_ARTIFACT_SORT).slice(0, 5);
34
+ const recentActivity = [...snapshot.activity]
35
+ .sort((left, right) => right.at.localeCompare(left.at))
36
+ .slice(0, 6);
37
+ return (
38
+ <div className="grid min-w-0 gap-4 lg:grid-cols-2">
39
+ {shouldOfferObservationSetup ? (
40
+ <ProjectSection title={t("projectsObservationSetupTitle")} className="lg:col-span-2">
41
+ <p className="text-sm text-muted-foreground">
42
+ {t("projectsObservationSetupDescription")}
43
+ </p>
44
+ <button
45
+ type="button"
46
+ className="mt-3 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
47
+ onClick={onStartObservationSetup}
48
+ >
49
+ {t("projectsObservationSetupAction")}
50
+ </button>
51
+ </ProjectSection>
52
+ ) : null}
53
+ {(snapshot.runs?.length ?? 0) > 0 ? (
54
+ <ProjectSection title={t("projectsAiActivity")} className="lg:col-span-2">
55
+ <ProjectRuns limit={5} snapshot={snapshot} />
56
+ </ProjectSection>
57
+ ) : null}
58
+ <ProjectSection title={t("projectsAttention")}>
59
+ {openSignals.length ? (
60
+ <div className="space-y-2">
61
+ {openSignals.map((signal) => (
62
+ <div key={signal.id} className="rounded-xl bg-muted/45 p-3 text-sm">
63
+ <p className="font-medium">{signal.message}</p>
64
+ <p className="mt-1 text-xs text-muted-foreground">{signal.reference.label} · {signal.level}</p>
65
+ </div>
66
+ ))}
67
+ </div>
68
+ ) : <ProjectEmptyState>{t("projectsNoAttention")}</ProjectEmptyState>}
69
+ </ProjectSection>
70
+ <ProjectSection title={t("projectsWork")}>
71
+ {snapshot.workItems.length ? (
72
+ <div className="space-y-2">
73
+ {snapshot.workItems.slice(0, 5).map((item) => (
74
+ <div key={item.id} className="rounded-xl bg-muted/45 p-3 text-sm">
75
+ <p className="font-medium">{item.name}</p>
76
+ <p className="mt-1 text-xs text-muted-foreground">
77
+ {item.reference.label} · {t(`projectsStatus_${item.status}`)}
78
+ </p>
79
+ </div>
80
+ ))}
81
+ </div>
82
+ ) : (
83
+ <>
84
+ <ProjectEmptyState>
85
+ {t(
86
+ shouldOfferObservationSetup
87
+ ? "projectsWorkNeedsSetup"
88
+ : hasProjectSessions
89
+ ? "projectsWorkNoReports"
90
+ : "projectsWorkNoSessions",
91
+ )}
92
+ </ProjectEmptyState>
93
+ {!shouldOfferObservationSetup ? (
94
+ <Button size="sm" variant="outline" onClick={onStartProjectWork}>
95
+ {t("projectsStartProjectWork")}
96
+ </Button>
97
+ ) : null}
98
+ </>
99
+ )}
100
+ </ProjectSection>
101
+ <ProjectSection title={t("projectsArtifacts")}>
102
+ {recentArtifacts.length ? (
103
+ <div className="space-y-2">
104
+ {recentArtifacts.map((artifact) => (
105
+ <ProjectFilePreviewButton
106
+ key={artifact.id}
107
+ available={artifact.exists}
108
+ path={artifact.path}
109
+ label={artifact.path}
110
+ onOpen={onOpenFile}
111
+ className="block w-full rounded-xl bg-muted/45 p-3 text-left text-sm transition-colors enabled:hover:bg-[var(--interaction-hover)] enabled:focus-visible:outline-none enabled:focus-visible:ring-1 enabled:focus-visible:ring-border"
112
+ >
113
+ <span className="block truncate font-medium">{artifact.path}</span>
114
+ <span className="mt-1 block text-xs text-muted-foreground">
115
+ {artifact.categoryLabel}
116
+ {artifact.fileUpdatedAt ? (
117
+ <time dateTime={artifact.fileUpdatedAt} title={formatDateTime(artifact.fileUpdatedAt)}>
118
+ {` · ${formatProjectRelativeTime(artifact.fileUpdatedAt)}`}
119
+ </time>
120
+ ) : null}
121
+ {!artifact.exists ? ` · ${t("projectsUnavailable")}` : ""}
122
+ </span>
123
+ </ProjectFilePreviewButton>
124
+ ))}
125
+ </div>
126
+ ) : <ProjectEmptyState>{t("projectsNoArtifacts")}</ProjectEmptyState>}
127
+ </ProjectSection>
128
+ <ProjectSection title={t("projectsActivity")}>
129
+ {recentActivity.length ? (
130
+ <div className="space-y-3">
131
+ {recentActivity.map((activity) => (
132
+ <div key={activity.id} className="text-sm">
133
+ <p>{activity.message}</p>
134
+ <p className="mt-1 text-xs text-muted-foreground">
135
+ {activity.reference.sessionId ? (
136
+ <Link className="underline underline-offset-2" to={`/chat/${encodeURIComponent(activity.reference.sessionId)}`}>
137
+ {activity.reference.label}
138
+ </Link>
139
+ ) : activity.reference.label} · <time dateTime={activity.at} title={formatDateTime(activity.at)}>
140
+ {formatProjectRelativeTime(activity.at)}
141
+ </time>
142
+ </p>
143
+ </div>
144
+ ))}
145
+ </div>
146
+ ) : <ProjectEmptyState>{t("projectsNoActivity")}</ProjectEmptyState>}
147
+ </ProjectSection>
148
+ </div>
149
+ );
150
+ }
@@ -0,0 +1,57 @@
1
+ import { Link } from "react-router-dom";
2
+ import type { ObservedRequest } from "@nextclaw/client-sdk";
3
+ import { Button, buttonVariants } from "@/shared/components/ui/button";
4
+ import { t } from "@/shared/lib/i18n";
5
+ import { canReplyToProjectRequest } from "@/features/projects/presenters/project-home.presenter";
6
+ import type { ProjectRequestDecision } from "@/features/projects/utils/project-request-response.utils";
7
+ import { ProjectSection } from "./project-section";
8
+
9
+ export function ProjectRequests({
10
+ requests,
11
+ pendingRequestId,
12
+ onReply,
13
+ }: {
14
+ requests: ObservedRequest[];
15
+ pendingRequestId: string | null;
16
+ onReply: (request: ObservedRequest, decision: ProjectRequestDecision) => void;
17
+ }) {
18
+ if (requests.length === 0) return null;
19
+ return (
20
+ <ProjectSection title={t("projectsRequests")}>
21
+ <div className="space-y-2">
22
+ {requests.map((request) => {
23
+ const { id, prompt, reference: { label, sessionId }, reply } = request;
24
+ const isPending = pendingRequestId === id;
25
+ return (
26
+ <div key={id} className="flex flex-wrap items-center gap-2 rounded-xl bg-muted/45 p-3">
27
+ <div className="min-w-48 flex-1">
28
+ <p className="text-sm font-medium">{prompt}</p>
29
+ <p className="mt-1 text-xs text-muted-foreground">
30
+ {reply ? t("projectsResponseWaiting") : label}
31
+ </p>
32
+ </div>
33
+ {canReplyToProjectRequest(request) ? (
34
+ <>
35
+ <Button size="sm" disabled={isPending} onClick={() => onReply(request, "confirmed")}>
36
+ {t("projectsConfirm")}
37
+ </Button>
38
+ <Button size="sm" variant="outline" disabled={isPending} onClick={() => onReply(request, "rejected")}>
39
+ {t("projectsReject")}
40
+ </Button>
41
+ </>
42
+ ) : null}
43
+ {sessionId ? (
44
+ <Link
45
+ className={buttonVariants({ size: "sm", variant: "ghost" })}
46
+ to={`/chat/${encodeURIComponent(sessionId)}`}
47
+ >
48
+ {t("projectsOpenSession")}
49
+ </Link>
50
+ ) : null}
51
+ </div>
52
+ );
53
+ })}
54
+ </div>
55
+ </ProjectSection>
56
+ );
57
+ }
@@ -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
+ }