@nextclaw/ui 0.24.0-beta.0 → 0.24.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 (80) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/assets/api-BqVIq0lF.js +13 -0
  3. package/dist/assets/{appearance-settings-page-CPoFZLw8.js → appearance-settings-page-CTWn1eAT.js} +1 -1
  4. package/dist/assets/{book-open-jcUt0rr9.js → book-open-o2x7OLiO.js} +1 -1
  5. package/dist/assets/{channels-list-page-anMJAkfK.js → channels-list-page-Bzn-pzyJ.js} +1 -1
  6. package/dist/assets/{config-split-page-AcuFnbIC.js → config-split-page-CnRbXXlh.js} +1 -1
  7. package/dist/assets/{confirm-dialog-B-atCfC5.js → confirm-dialog-B_Hbcra2.js} +1 -1
  8. package/dist/assets/{createLucideIcon-Blz0FzrV.js → createLucideIcon-rwwpqyks.js} +1 -1
  9. package/dist/assets/{desktop-capabilities-page-8lKF_Ob-.js → desktop-capabilities-page-C2ayYQTr.js} +1 -1
  10. package/dist/assets/{desktop-update-config-DABXDpbj.js → desktop-update-config-C13czpyL.js} +1 -1
  11. package/dist/assets/{dist-eH-_j8yU.js → dist-BsF3hfOi.js} +1 -1
  12. package/dist/assets/{dist-D8kcEpTf.js → dist-aNN3iQxV.js} +1 -1
  13. package/dist/assets/{doc-browser-DzXRehlW.js → doc-browser-D8dIOo9E.js} +1 -1
  14. package/dist/assets/doc-browser-DcuOOtpP.js +1 -0
  15. package/dist/assets/{doc-browser-context-BlbUcg2J.js → doc-browser-context-Dhsit2Ma.js} +1 -1
  16. package/dist/assets/{extensions-mquwAJ2Z.js → extensions-CD6xvKF_.js} +1 -1
  17. package/dist/assets/{form-actions-CaZDGEmB.js → form-actions-CC4Wz_lL.js} +1 -1
  18. package/dist/assets/index-BTsbdtv6.js +109 -0
  19. package/dist/assets/index-Bcfy2kTh.css +1 -0
  20. package/dist/assets/loader-circle-B6aFbB-8.js +1 -0
  21. package/dist/assets/mcp-marketplace-page-BtUAypA7.js +1 -0
  22. package/dist/assets/{mcp-marketplace-page-gFtgyueL.js → mcp-marketplace-page-r2MmD9xO.js} +1 -1
  23. package/dist/assets/{model-config-page-hFBpeRAO.js → model-config-page-DboxoT-I.js} +1 -1
  24. package/dist/assets/{navigation-link-Hb5Z0UxZ.js → navigation-link-C_6oj3zz.js} +1 -1
  25. package/dist/assets/plus-DRsq9Kvs.js +1 -0
  26. package/dist/assets/{privacy-settings-page-C18zo3ox.js → privacy-settings-page-C2sDBqhb.js} +1 -1
  27. package/dist/assets/projects-BcPcx-uD.js +1 -0
  28. package/dist/assets/{providers-config-page-YU0-AOXJ.js → providers-config-page-CmUFl9RF.js} +1 -1
  29. package/dist/assets/react-BjO9I4WZ.js +8 -0
  30. package/dist/assets/{refresh-cw-ZHKfE3yx.js → refresh-cw-DsT2kvXi.js} +1 -1
  31. package/dist/assets/remote-Ci6x91ES.js +1 -0
  32. package/dist/assets/{rotate-cw-CLhOXGwE.js → rotate-cw-BCAs6c3R.js} +1 -1
  33. package/dist/assets/{runtime-config-page-Dmy1A_0Y.js → runtime-config-page-cQKNeAjL.js} +1 -1
  34. package/dist/assets/{save-CWuuZs77.js → save-CadRMTMQ.js} +1 -1
  35. package/dist/assets/{search-DcmsKAn2.js → search-B6tB4fzB.js} +1 -1
  36. package/dist/assets/{search-config-page-CxBacx6W.js → search-config-page-C8lOIkxn.js} +1 -1
  37. package/dist/assets/{secrets-config-page-B4py8-KR.js → secrets-config-page-8clzAag2.js} +1 -1
  38. package/dist/assets/security-config-DPbM6IVT.js +1 -0
  39. package/dist/assets/{select-BEr0Iy_f.js → select-CO9S_C2C.js} +1 -1
  40. package/dist/assets/skeleton-IHILNhIW.js +1 -0
  41. package/dist/assets/{tag-chip-ByqDkn6g.js → tag-chip-BZKk0UkK.js} +1 -1
  42. package/dist/assets/x-BuvHIb1d.js +1 -0
  43. package/dist/index.html +21 -21
  44. package/package.json +6 -6
  45. package/src/features/apps/components/__tests__/app-packages-panel.test.tsx +362 -241
  46. package/src/features/apps/components/app-document-access-section.tsx +187 -0
  47. package/src/features/apps/components/app-package-card.tsx +150 -275
  48. package/src/features/apps/hooks/use-app-packages.ts +91 -35
  49. package/src/features/chat/index.ts +5 -1
  50. package/src/features/projects/AGENTS.md +3 -2
  51. package/src/features/projects/components/project-overview.tsx +70 -139
  52. package/src/features/projects/components/work/project-work-item-drawer.test.tsx +65 -0
  53. package/src/features/projects/components/work/project-work-item-drawer.tsx +367 -0
  54. package/src/features/projects/components/work/project-work-items.test.tsx +98 -0
  55. package/src/features/projects/components/work/project-work-items.tsx +199 -0
  56. package/src/features/projects/components/work/project-work-state-settings.tsx +285 -0
  57. package/src/features/projects/hooks/use-project-observation.ts +56 -26
  58. package/src/features/projects/hooks/use-project-work.test.tsx +58 -0
  59. package/src/features/projects/hooks/use-project-work.ts +215 -0
  60. package/src/features/projects/pages/project-home-page.tsx +119 -138
  61. package/src/features/projects/pages/projects-page.test.tsx +79 -308
  62. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +16 -1
  63. package/src/shared/lib/i18n/locales/en-US/projects.json +60 -1
  64. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +16 -1
  65. package/src/shared/lib/i18n/locales/zh-CN/projects.json +60 -1
  66. package/dist/assets/api-CoqPRKmF.js +0 -13
  67. package/dist/assets/doc-browser-DJEMr7Zk.js +0 -1
  68. package/dist/assets/index-DyR5iR0O.css +0 -1
  69. package/dist/assets/index-XxXj8WDS.js +0 -109
  70. package/dist/assets/loader-circle-0qPOOYQ5.js +0 -1
  71. package/dist/assets/mcp-marketplace-page-uanamJ6L.js +0 -1
  72. package/dist/assets/plus-CYOvNvcV.js +0 -1
  73. package/dist/assets/projects-C8Gm9RS8.js +0 -1
  74. package/dist/assets/react-DF-kP7iN.js +0 -8
  75. package/dist/assets/remote-B_utkBra.js +0 -1
  76. package/dist/assets/security-config-CTP4IQWw.js +0 -1
  77. package/dist/assets/skeleton-B2NHluWf.js +0 -1
  78. package/dist/assets/x-CENkFyK5.js +0 -1
  79. package/src/features/projects/components/project-work-items.test.tsx +0 -78
  80. package/src/features/projects/components/project-work-items.tsx +0 -272
@@ -1,37 +1,51 @@
1
- import { useEffect } from 'react';
2
- import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
3
- import type { AppPackageOperationStatus, AppPackageOperationView } from '@nextclaw/client-sdk';
4
- import { nextclawClient } from '@/shared/lib/api';
5
- import type { AppPackageOperationSettlementManager } from '@/features/apps/managers/app-package-operation-settlement.manager';
6
- import { APP_DATA_QUERY_KEY } from '@/features/app-data';
1
+ import { useEffect } from "react";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import type {
4
+ AppPackageOperationStatus,
5
+ AppPackageOperationView,
6
+ } from "@nextclaw/client-sdk";
7
+ import { nextclawClient } from "@/shared/lib/api";
8
+ import type { AppPackageOperationSettlementManager } from "@/features/apps/managers/app-package-operation-settlement.manager";
9
+ import { APP_DATA_QUERY_KEY } from "@/features/app-data";
7
10
 
8
- const APP_PACKAGES_QUERY_KEY = ['app-packages'] as const;
9
- const PANEL_APPS_QUERY_KEY = ['panel-apps'] as const;
10
- const SERVICE_APPS_QUERY_KEY = ['service-apps'] as const;
11
- const SERVICE_ACTIONS_QUERY_KEY = ['service-actions'] as const;
12
- const APP_PACKAGE_OPERATIONS_QUERY_KEY = ['app-package-operations'] as const;
11
+ const APP_PACKAGES_QUERY_KEY = ["app-packages"] as const;
12
+ const PANEL_APPS_QUERY_KEY = ["panel-apps"] as const;
13
+ const SERVICE_APPS_QUERY_KEY = ["service-apps"] as const;
14
+ const SERVICE_ACTIONS_QUERY_KEY = ["service-actions"] as const;
15
+ const APP_PACKAGE_OPERATIONS_QUERY_KEY = ["app-package-operations"] as const;
13
16
 
14
17
  const ACTIVE_OPERATION_STATUSES = new Set<AppPackageOperationStatus>([
15
- 'queued',
16
- 'resolving',
17
- 'downloading',
18
- 'verifying',
19
- 'installing',
20
- 'finalizing',
18
+ "queued",
19
+ "resolving",
20
+ "downloading",
21
+ "verifying",
22
+ "installing",
23
+ "finalizing",
21
24
  ]);
22
25
 
23
26
  export type AppPackageMutationInput =
24
- | { action: 'enable'; appId: string }
25
- | { action: 'disable'; appId: string }
26
- | { action: 'install'; source: string; registryUrl?: string }
27
- | { action: 'update'; appId: string }
28
- | { action: 'rollback'; appId: string; version: string }
29
- | { action: 'uninstall'; appId: string; purgeData?: boolean };
27
+ | { action: "enable"; appId: string }
28
+ | { action: "disable"; appId: string }
29
+ | { action: "install"; source: string; registryUrl?: string }
30
+ | { action: "update"; appId: string }
31
+ | { action: "rollback"; appId: string; version: string }
32
+ | { action: "uninstall"; appId: string; purgeData?: boolean };
33
+
34
+ export type AppDocumentAccessMutationInput =
35
+ | {
36
+ action: "grant";
37
+ appId: string;
38
+ scopeId: string;
39
+ directoryPath: string;
40
+ mode: "read" | "read-write";
41
+ }
42
+ | { action: "revoke"; appId: string; scopeId: string };
30
43
 
31
44
  export function useAppPackages() {
32
45
  return useQuery({
33
46
  queryKey: APP_PACKAGES_QUERY_KEY,
34
- queryFn: () => nextclawClient.appPackages.list({ includeStorageUsage: false }),
47
+ queryFn: () =>
48
+ nextclawClient.appPackages.list({ includeStorageUsage: false }),
35
49
  staleTime: 0,
36
50
  });
37
51
  }
@@ -42,13 +56,17 @@ export function useAppPackageOperations() {
42
56
  queryFn: () => nextclawClient.appPackages.listOperations(),
43
57
  refetchInterval: (query) => {
44
58
  const entries = query.state.data?.entries ?? [];
45
- return entries.some((entry) => isAppPackageOperationActive(entry.status)) ? 500 : false;
59
+ return entries.some((entry) => isAppPackageOperationActive(entry.status))
60
+ ? 500
61
+ : false;
46
62
  },
47
63
  staleTime: 0,
48
64
  });
49
65
  }
50
66
 
51
- export function isAppPackageOperationActive(status: AppPackageOperationStatus): boolean {
67
+ export function isAppPackageOperationActive(
68
+ status: AppPackageOperationStatus,
69
+ ): boolean {
52
70
  return ACTIVE_OPERATION_STATUSES.has(status);
53
71
  }
54
72
 
@@ -77,7 +95,14 @@ export function useAppPackageOperationSettlement({
77
95
  refetchPanels,
78
96
  refetchAppData,
79
97
  });
80
- }, [isLoaded, operations, refetchPackages, refetchPanels, refetchAppData, settlementManager]);
98
+ }, [
99
+ isLoaded,
100
+ operations,
101
+ refetchPackages,
102
+ refetchPanels,
103
+ refetchAppData,
104
+ settlementManager,
105
+ ]);
81
106
  }
82
107
 
83
108
  export function useAppPackageMutation() {
@@ -85,20 +110,23 @@ export function useAppPackageMutation() {
85
110
  return useMutation({
86
111
  mutationFn: async (input: AppPackageMutationInput) => {
87
112
  switch (input.action) {
88
- case 'enable':
113
+ case "enable":
89
114
  return await nextclawClient.appPackages.enable(input.appId);
90
- case 'disable':
115
+ case "disable":
91
116
  return await nextclawClient.appPackages.disable(input.appId);
92
- case 'install':
117
+ case "install":
93
118
  return await nextclawClient.appPackages.startInstall({
94
119
  source: input.source,
95
120
  registryUrl: input.registryUrl,
96
121
  });
97
- case 'update':
122
+ case "update":
98
123
  return await nextclawClient.appPackages.startUpdate(input.appId);
99
- case 'rollback':
100
- return await nextclawClient.appPackages.startRollback(input.appId, input.version);
101
- case 'uninstall':
124
+ case "rollback":
125
+ return await nextclawClient.appPackages.startRollback(
126
+ input.appId,
127
+ input.version,
128
+ );
129
+ case "uninstall":
102
130
  return await nextclawClient.appPackages.startUninstall(
103
131
  input.appId,
104
132
  input.purgeData ?? false,
@@ -111,9 +139,37 @@ export function useAppPackageMutation() {
111
139
  queryClient.invalidateQueries({ queryKey: PANEL_APPS_QUERY_KEY }),
112
140
  queryClient.invalidateQueries({ queryKey: SERVICE_APPS_QUERY_KEY }),
113
141
  queryClient.invalidateQueries({ queryKey: SERVICE_ACTIONS_QUERY_KEY }),
114
- queryClient.invalidateQueries({ queryKey: APP_PACKAGE_OPERATIONS_QUERY_KEY }),
142
+ queryClient.invalidateQueries({
143
+ queryKey: APP_PACKAGE_OPERATIONS_QUERY_KEY,
144
+ }),
115
145
  queryClient.invalidateQueries({ queryKey: APP_DATA_QUERY_KEY }),
116
146
  ]);
117
147
  },
118
148
  });
119
149
  }
150
+
151
+ export function useAppDocumentAccessMutation() {
152
+ const queryClient = useQueryClient();
153
+ return useMutation({
154
+ mutationFn: async (input: AppDocumentAccessMutationInput) => {
155
+ if (input.action === "revoke") {
156
+ return await nextclawClient.appPackages.revokeDocumentAccess(
157
+ input.appId,
158
+ input.scopeId,
159
+ );
160
+ }
161
+ return await nextclawClient.appPackages.grantDocumentAccess(input.appId, {
162
+ scopeId: input.scopeId,
163
+ directoryPath: input.directoryPath,
164
+ mode: input.mode,
165
+ });
166
+ },
167
+ onSuccess: async () => {
168
+ await Promise.all([
169
+ queryClient.invalidateQueries({ queryKey: APP_PACKAGES_QUERY_KEY }),
170
+ queryClient.invalidateQueries({ queryKey: SERVICE_APPS_QUERY_KEY }),
171
+ queryClient.invalidateQueries({ queryKey: SERVICE_ACTIONS_QUERY_KEY }),
172
+ ]);
173
+ },
174
+ });
175
+ }
@@ -1,4 +1,5 @@
1
1
  export { ChatConversationPanel } from "./components/conversation/chat-conversation-panel";
2
+ export { ChatConversationWorkspaceSection } from "./components/conversation/chat-conversation-workspace-section";
2
3
  export { ChatSidebar } from "./components/layout/chat-sidebar";
3
4
  export { ChatPresenterProvider } from "./components/providers/chat-presenter.provider";
4
5
  export { usePresenter } from "./components/providers/chat-presenter.provider";
@@ -6,7 +7,10 @@ export { ChatPresenter } from "./presenters/chat.presenter";
6
7
  export { ChatDraftIntentManager } from "./managers/chat-draft-intent.manager";
7
8
  export { ChatComposerIntentManager } from "./managers/chat-composer-intent.manager";
8
9
  export { ChatCompletionNotificationManager } from "./managers/chat-completion-notification.manager";
9
- export { buildSessionPath, CHAT_DRAFT_SESSION_PATH } from "./features/session/utils/chat-session-route.utils";
10
+ export {
11
+ buildSessionPath,
12
+ CHAT_DRAFT_SESSION_PATH,
13
+ } from "./features/session/utils/chat-session-route.utils";
10
14
  export { useChatSessionListStore } from "./stores/chat-session-list.store";
11
15
  export { useChatThreadStore } from "./stores/chat-thread.store";
12
16
  export { useChatMessageLayoutStore } from "./stores/chat-message-layout.store";
@@ -1,6 +1,7 @@
1
1
  # Projects UI boundary
2
2
 
3
- - 本目录拥有 Projects 页面、presenter、查询状态和用户回复编排;事实来源只能是 `client.projects` 的公共快照,组件不得自行扫描文件或解析 Marker。
3
+ - 本目录拥有 Projects 页面、presenter、查询状态和用户回复编排;项目观测事实来自 `client.projects` 公共快照,工作项来自 Project Work API,组件不得自行扫描文件或解析 Marker。
4
4
  - 页面默认只读;确认/拒绝只复用现有 agent-run 消息入口,并携带可追踪、可幂等的响应元数据,不创建第二套 response bridge。
5
- - Overview、列表、看板、甘特图和详情必须消费同一份 snapshot,不为视图复制业务状态或推断缺失阶段、日期与进度。
5
+ - Overview、列表和看板消费同一份工作项查询状态;所有工作项入口统一打开右侧详情抽屉,不得在页面内追加平铺详情。
6
+ - `project.work.changed` 只用于失效查询;UI 不重放事件历史,也不以轮询或目录扫描代替业务存储。
6
7
  - 组件负责连接与展示,跨组件状态归 hook/presenter;用户文案必须走 Projects i18n 资源。
@@ -1,150 +1,81 @@
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";
1
+ import { t } from "@/shared/lib/i18n";
5
2
  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";
3
+ useProjectWork,
4
+ useProjectWorkSummary,
5
+ } from "@/features/projects/hooks/use-project-work";
16
6
 
17
7
  export function ProjectOverview({
18
- hasProjectSessions,
19
- snapshot,
20
- onStartObservationSetup,
21
- onStartProjectWork,
22
- onOpenFile,
23
- shouldOfferObservationSetup,
8
+ projectId,
9
+ onOpenWorkItem,
24
10
  }: {
25
- hasProjectSessions: boolean;
26
- snapshot: ProjectObservationSnapshot;
27
- onStartObservationSetup: () => void;
28
- onStartProjectWork: () => void;
29
- onOpenFile: (path: string, label: string) => void;
30
- shouldOfferObservationSetup: boolean;
11
+ projectId: string;
12
+ onOpenWorkItem: (workItemId: string) => void;
31
13
  }) {
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);
14
+ const summary = useProjectWorkSummary(projectId);
15
+ const work = useProjectWork(projectId);
16
+ if (summary.isLoading || work.isLoading)
17
+ return (
18
+ <div className="rounded-xl border border-border/60 p-5 text-sm text-muted-foreground">
19
+ {t("projectsLoading")}
20
+ </div>
21
+ );
22
+ if (summary.isError || work.isError)
23
+ return (
24
+ <div className="rounded-xl border border-destructive/40 p-5 text-sm text-destructive">
25
+ {t("projectsLoadFailed")}
26
+ </div>
27
+ );
28
+ const recent = work.data!.items.slice(0, 5);
37
29
  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}
30
+ <div className="space-y-5">
31
+ <section
32
+ className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"
33
+ aria-label={t("projectsWorkSummary")}
34
+ >
35
+ {(
36
+ [
37
+ ["projectsWorkTotal", summary.data!.total],
38
+ ["projectsWorkActive", summary.data!.active],
39
+ ["projectsWorkNeedsAttention", summary.data!.attention],
40
+ ["projectsWorkCompleted", summary.data!.completed],
41
+ ] as const
42
+ ).map(([label, value]) => (
43
+ <div
44
+ key={label}
45
+ className="rounded-xl border border-border/60 bg-card p-4"
48
46
  >
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
- ))}
47
+ <div className="text-2xl font-semibold">{value}</div>
48
+ <div className="mt-1 text-xs text-muted-foreground">{t(label)}</div>
145
49
  </div>
146
- ) : <ProjectEmptyState>{t("projectsNoActivity")}</ProjectEmptyState>}
147
- </ProjectSection>
50
+ ))}
51
+ </section>
52
+ <section>
53
+ <h2 className="mb-3 text-sm font-semibold">
54
+ {t("projectsWorkRecentlyUpdated")}
55
+ </h2>
56
+ <div className="space-y-2">
57
+ {recent.map((item) => (
58
+ <button
59
+ key={item.id}
60
+ type="button"
61
+ className="flex w-full items-center justify-between gap-3 rounded-xl border border-border/60 bg-card px-4 py-3 text-left hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
62
+ onClick={() => onOpenWorkItem(item.id)}
63
+ >
64
+ <span className="min-w-0 truncate text-sm font-medium">
65
+ {item.title}
66
+ </span>
67
+ <span className="shrink-0 text-xs text-muted-foreground">
68
+ {item.state.name}
69
+ </span>
70
+ </button>
71
+ ))}
72
+ {!recent.length ? (
73
+ <div className="rounded-xl border border-dashed border-border/60 p-8 text-center text-sm text-muted-foreground">
74
+ {t("projectsWorkEmpty")}
75
+ </div>
76
+ ) : null}
77
+ </div>
78
+ </section>
148
79
  </div>
149
80
  );
150
81
  }
@@ -0,0 +1,65 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { ProjectWorkItemDrawer } from "./project-work-item-drawer";
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ updateItem: vi.fn(),
7
+ useProjectWorkItem: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("@/features/projects/hooks/use-project-work", () => ({
11
+ useProjectWork: () => ({
12
+ data: { states: [{ id: "planned", name: "Planned" }] },
13
+ }),
14
+ useProjectWorkItem: mocks.useProjectWorkItem,
15
+ useProjectWorkActivity: () => ({ data: { activities: [] } }),
16
+ useProjectWorkActions: () => ({
17
+ updateItem: { mutateAsync: mocks.updateItem, isPending: false },
18
+ deleteItem: { mutateAsync: vi.fn() },
19
+ restoreItem: { mutateAsync: vi.fn() },
20
+ linkArtifact: { mutateAsync: vi.fn(), isPending: false },
21
+ unlinkArtifact: { mutateAsync: vi.fn() },
22
+ }),
23
+ }));
24
+
25
+ describe("ProjectWorkItemDrawer", () => {
26
+ it("renders details in a right-side dialog and opens linked artifacts", () => {
27
+ mocks.useProjectWorkItem.mockReturnValue({
28
+ isLoading: false,
29
+ isError: false,
30
+ data: {
31
+ id: "work-1",
32
+ projectId: "project-1",
33
+ title: "Drawer details",
34
+ description: "Durable details",
35
+ stateId: "planned",
36
+ state: { id: "planned", name: "Planned" },
37
+ attention: "none",
38
+ version: 1,
39
+ deletedAt: null,
40
+ artifacts: [
41
+ { id: "artifact-1", path: "docs/design.md", label: "Design" },
42
+ ],
43
+ },
44
+ });
45
+ const onOpenArtifact = vi.fn();
46
+
47
+ render(
48
+ <ProjectWorkItemDrawer
49
+ projectId="project-1"
50
+ projectRoot="/tmp/project"
51
+ workItemId="work-1"
52
+ onOpenChange={vi.fn()}
53
+ onOpenArtifact={onOpenArtifact}
54
+ />,
55
+ );
56
+
57
+ expect(screen.getByRole("dialog")).toBeTruthy();
58
+ expect(screen.getByDisplayValue("Drawer details")).toBeTruthy();
59
+ fireEvent.click(screen.getByRole("button", { name: /Design/ }));
60
+ expect(onOpenArtifact).toHaveBeenCalledWith(
61
+ "/tmp/project/docs/design.md",
62
+ "Design",
63
+ );
64
+ });
65
+ });