@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
@@ -9,6 +9,9 @@ import type {
9
9
  ServiceActionGrantView,
10
10
  ServiceActionInvokeResultView,
11
11
  ServiceActionListView,
12
+ PortableRuntimeAcceptanceExportApiView,
13
+ PortableRuntimeAcceptanceStatusApiView,
14
+ RuntimeVerificationRecordListView,
12
15
  } from '@nextclaw/client-sdk';
13
16
  import type { ServiceActionAuthorizationManager } from '@/features/service-apps';
14
17
  import { nextclawClient } from '@/shared/lib/api';
@@ -26,13 +29,20 @@ type PanelAppBridgeRequest = {
26
29
  method:
27
30
  | 'agent.generateObject'
28
31
  | 'agent.send'
32
+ | 'acceptance.export'
33
+ | 'acceptance.status'
29
34
  | 'invoke'
30
35
  | 'list'
31
36
  | 'requestGrant'
32
- | 'revokeGrant';
37
+ | 'revokeGrant'
38
+ | 'verification.list';
33
39
  payload?: {
40
+ acceptanceId?: string;
34
41
  actionId?: string;
42
+ appId?: string;
35
43
  input?: unknown;
44
+ limit?: number;
45
+ locale?: string;
36
46
  request?: unknown;
37
47
  };
38
48
  };
@@ -103,6 +113,9 @@ export class PanelAppBridgeManager {
103
113
  | PanelAppAgentGenerateObjectResultView
104
114
  | PanelAppAgentSendResultView
105
115
  | PanelAppCapabilityGrantView
116
+ | PortableRuntimeAcceptanceExportApiView
117
+ | PortableRuntimeAcceptanceStatusApiView
118
+ | RuntimeVerificationRecordListView
106
119
  | ServiceActionGrantView
107
120
  | ServiceActionInvokeResultView
108
121
  | ServiceActionListView
@@ -126,6 +139,25 @@ export class PanelAppBridgeManager {
126
139
  return await this.sendAgentMessageWithAuthorization(session, request);
127
140
  case 'agent.generateObject':
128
141
  return await this.generateAgentObjectWithAuthorization(session, request);
142
+ case 'verification.list':
143
+ return await nextclawClient.serviceApps.listVerificationRecords({
144
+ acceptanceId: request.payload?.acceptanceId,
145
+ appId: request.payload?.appId,
146
+ limit: request.payload?.limit,
147
+ bridgeSessionToken: session.token,
148
+ });
149
+ case 'acceptance.status':
150
+ return await nextclawClient.serviceApps.getPortableRuntimeAcceptanceStatus({
151
+ appId: request.payload?.appId,
152
+ locale: request.payload?.locale,
153
+ bridgeSessionToken: session.token,
154
+ });
155
+ case 'acceptance.export':
156
+ return await nextclawClient.serviceApps.exportPortableRuntimeAcceptance({
157
+ appId: request.payload?.appId,
158
+ locale: request.payload?.locale,
159
+ bridgeSessionToken: session.token,
160
+ });
129
161
  default:
130
162
  throw new Error(`Unsupported panel bridge method: ${String(request.method)}`);
131
163
  }
@@ -378,9 +410,12 @@ export class PanelAppBridgeManager {
378
410
  (candidate.method === 'invoke' ||
379
411
  candidate.method === 'agent.send' ||
380
412
  candidate.method === 'agent.generateObject' ||
413
+ candidate.method === 'acceptance.export' ||
414
+ candidate.method === 'acceptance.status' ||
381
415
  candidate.method === 'list' ||
382
416
  candidate.method === 'requestGrant' ||
383
- candidate.method === 'revokeGrant')
417
+ candidate.method === 'revokeGrant' ||
418
+ candidate.method === 'verification.list')
384
419
  );
385
420
  };
386
421
  }
@@ -0,0 +1,6 @@
1
+ # Projects UI boundary
2
+
3
+ - 本目录拥有 Projects 页面、presenter、查询状态和用户回复编排;事实来源只能是 `client.projects` 的公共快照,组件不得自行扫描文件或解析 Marker。
4
+ - 页面默认只读;确认/拒绝只复用现有 agent-run 消息入口,并携带可追踪、可幂等的响应元数据,不创建第二套 response bridge。
5
+ - Overview、列表、看板、甘特图和详情必须消费同一份 snapshot,不为视图复制业务状态或推断缺失阶段、日期与进度。
6
+ - 组件负责连接与展示,跨组件状态归 hook/presenter;用户文案必须走 Projects i18n 资源。
@@ -0,0 +1,92 @@
1
+ import type { ProjectObservationSnapshot } from "@nextclaw/client-sdk";
2
+ import { t } from "@/shared/lib/i18n";
3
+ import {
4
+ ProjectEmptyState,
5
+ ProjectFilePreviewButton,
6
+ ProjectSection,
7
+ } from "./project-section";
8
+
9
+ export function ProjectAgreement({
10
+ snapshot,
11
+ onOpenFile,
12
+ }: {
13
+ snapshot: ProjectObservationSnapshot;
14
+ onOpenFile: (path: string, label: string) => void;
15
+ }) {
16
+ return (
17
+ <div className="grid min-w-0 gap-4 lg:grid-cols-2">
18
+ <ProjectSection title={t("projectsVisionAndContext")}>
19
+ {snapshot.project.summary ? (
20
+ <p className="mb-4 text-sm">{snapshot.project.summary}</p>
21
+ ) : null}
22
+ {snapshot.project.context.length ? (
23
+ <div className="divide-y divide-border/60">
24
+ {snapshot.project.context.map((context) => (
25
+ <ProjectContextReference
26
+ key={context.id}
27
+ context={context}
28
+ onOpenFile={onOpenFile}
29
+ />
30
+ ))}
31
+ </div>
32
+ ) : (
33
+ <ProjectEmptyState>{t("projectsNoContext")}</ProjectEmptyState>
34
+ )}
35
+ </ProjectSection>
36
+ <ProjectSection title={t("projectsSourceHealth")}>
37
+ <div className="space-y-2">
38
+ {snapshot.sources.map((source) => (
39
+ <div
40
+ key={source.id}
41
+ className="flex items-center justify-between gap-3 rounded-xl bg-muted/45 p-3 text-sm"
42
+ >
43
+ <span>{source.label}</span>
44
+ <span className="text-muted-foreground">
45
+ {t(`projectsSource_${source.status}`)} · {source.itemCount}
46
+ </span>
47
+ </div>
48
+ ))}
49
+ </div>
50
+ {snapshot.diagnostics.length ? (
51
+ <div className="mt-4 space-y-2 border-t border-border/60 pt-4">
52
+ {snapshot.diagnostics.map((diagnostic) => (
53
+ <div
54
+ key={diagnostic.id}
55
+ className="rounded-xl bg-muted/35 p-3 text-sm"
56
+ >
57
+ <p className="font-medium">{diagnostic.code}</p>
58
+ <p className="mt-1 text-xs text-muted-foreground">
59
+ {diagnostic.message}
60
+ </p>
61
+ </div>
62
+ ))}
63
+ </div>
64
+ ) : null}
65
+ </ProjectSection>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ function ProjectContextReference({
71
+ context,
72
+ onOpenFile,
73
+ }: {
74
+ context: ProjectObservationSnapshot["project"]["context"][number];
75
+ onOpenFile: (path: string, label: string) => void;
76
+ }) {
77
+ const { accessible, role, source } = context;
78
+ return (
79
+ <ProjectFilePreviewButton
80
+ available={accessible}
81
+ path={source}
82
+ label={role}
83
+ onOpen={onOpenFile}
84
+ className="block w-full py-3 text-left first:pt-0 enabled:hover:text-foreground enabled:focus-visible:outline-none enabled:focus-visible:ring-1 enabled:focus-visible:ring-border"
85
+ >
86
+ <span className="block text-sm font-medium">{role}</span>
87
+ <span className="mt-1 block truncate text-xs text-muted-foreground">
88
+ {source}{!accessible ? ` · ${t("projectsUnavailable")}` : ""}
89
+ </span>
90
+ </ProjectFilePreviewButton>
91
+ );
92
+ }
@@ -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
+ }