@taskforcehq/taskforce 0.3.306 → 0.3.308

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 (232) hide show
  1. package/README.md +0 -2
  2. package/dist/Taskforce.module.css +317 -27
  3. package/dist/TaskforceCore.js +1 -0
  4. package/dist/TaskforceCore.test.js +178 -0
  5. package/dist/components/activity/EntityActivityTimeline.d.ts +34 -0
  6. package/dist/components/activity/EntityActivityTimeline.js +81 -0
  7. package/dist/components/context/ContextAttachmentManager.js +15 -3
  8. package/dist/components/features/AgentsModule.d.ts +5 -1
  9. package/dist/components/features/AgentsModule.js +142 -39
  10. package/dist/components/features/AgentsModule.test.js +260 -11
  11. package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +3 -1
  12. package/dist/components/features/AnnotatedAttachmentWorkspace.js +118 -4
  13. package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +52 -0
  14. package/dist/components/features/DocumentIndex.d.ts +2 -2
  15. package/dist/components/features/DocumentIndex.js +3 -4
  16. package/dist/components/features/DocumentViewer.d.ts +3 -1
  17. package/dist/components/features/DocumentViewer.js +6 -5
  18. package/dist/components/features/DocumentWorkspace.d.ts +10 -2
  19. package/dist/components/features/DocumentWorkspace.js +15 -4
  20. package/dist/components/features/DocumentWorkspace.test.js +20 -3
  21. package/dist/components/features/TaskSettings.d.ts +2 -0
  22. package/dist/components/features/TaskSettings.js +83 -78
  23. package/dist/components/features/TaskSettings.test.js +171 -82
  24. package/dist/components/task/TaskActionHeader.js +11 -5
  25. package/dist/components/task/TaskActionHeader.test.js +10 -0
  26. package/dist/components/task/TaskCard.js +6 -2
  27. package/dist/components/task/TaskContextUpload.test.js +3 -3
  28. package/dist/components/task/TaskForm.js +14 -64
  29. package/dist/components/task/TaskKanban.js +0 -8
  30. package/dist/components/task/TaskList.js +5 -1
  31. package/dist/components/task/TaskList.test.js +4 -0
  32. package/dist/components/ui/HierarchyActionBadge.d.ts +10 -0
  33. package/dist/components/ui/HierarchyActionBadge.js +5 -0
  34. package/dist/components/views/AppShellHeader.d.ts +3 -1
  35. package/dist/components/views/AppShellHeader.js +27 -11
  36. package/dist/components/views/AppShellHeader.test.js +18 -1
  37. package/dist/components/views/PlanComparisonPage.d.ts +20 -15
  38. package/dist/components/views/PlanComparisonPage.js +221 -82
  39. package/dist/components/views/PlanComparisonPage.test.js +268 -14
  40. package/dist/components/views/PlansPage.js +16 -10
  41. package/dist/components/views/PlansPage.test.js +4 -2
  42. package/dist/components/views/StandaloneLayout.js +256 -69
  43. package/dist/components/views/WidgetView.js +10 -8
  44. package/dist/components/views/panels/PlanningDrawer.d.ts +19 -6
  45. package/dist/components/views/panels/PlanningDrawer.js +259 -51
  46. package/dist/components/views/panels/PlanningDrawer.test.js +392 -4
  47. package/dist/config/envSchema.js +1 -0
  48. package/dist/core/AiProfileService.d.ts +7 -0
  49. package/dist/core/AiProfileService.js +460 -35
  50. package/dist/core/AiProfiles.test.js +648 -2
  51. package/dist/core/AttachmentLinkService.d.ts +3 -0
  52. package/dist/core/AttachmentLinkService.js +58 -13
  53. package/dist/core/GlobalSettingsService.js +30 -0
  54. package/dist/core/GlobalSettingsService.test.d.ts +1 -0
  55. package/dist/core/GlobalSettingsService.test.js +45 -0
  56. package/dist/core/McpTokenService.d.ts +12 -0
  57. package/dist/core/McpTokenService.js +44 -1
  58. package/dist/core/PlanEntitlementService.d.ts +20 -0
  59. package/dist/core/PlanEntitlementService.js +215 -19
  60. package/dist/core/PlanFeatureCatalog.test.js +55 -0
  61. package/dist/core/PlanningEntities.test.js +366 -4
  62. package/dist/core/TaskActivityService.d.ts +28 -1
  63. package/dist/core/TaskActivityService.js +227 -46
  64. package/dist/core/TaskActivitySync.test.d.ts +1 -0
  65. package/dist/core/TaskActivitySync.test.js +90 -0
  66. package/dist/core/TaskAuditTimeline.test.js +96 -0
  67. package/dist/core/Taskforce.d.ts +204 -26
  68. package/dist/core/Taskforce.ids.test.js +58 -0
  69. package/dist/core/Taskforce.js +781 -71
  70. package/dist/core/Taskforce.mcpAuth.test.js +109 -0
  71. package/dist/core/types.d.ts +10 -0
  72. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +2 -1
  73. package/dist/hooks/bootstrapTimeouts.d.ts +4 -0
  74. package/dist/hooks/bootstrapTimeouts.js +8 -0
  75. package/dist/hooks/bootstrapTimeouts.test.d.ts +1 -0
  76. package/dist/hooks/bootstrapTimeouts.test.js +12 -0
  77. package/dist/hooks/sync/orchestratorShared.d.ts +3 -3
  78. package/dist/hooks/sync/orchestratorShared.js +61 -1
  79. package/dist/hooks/sync/recovery.js +8 -0
  80. package/dist/hooks/sync/recovery.test.js +5 -5
  81. package/dist/hooks/sync/transfers.d.ts +2 -1
  82. package/dist/hooks/sync/transfers.js +36 -9
  83. package/dist/hooks/sync/transfers.test.js +534 -2
  84. package/dist/hooks/useFilterSort.js +13 -13
  85. package/dist/hooks/useFilterSort.test.js +87 -1
  86. package/dist/hooks/useSyncOrchestrator.js +50 -5
  87. package/dist/hooks/useTaskforce.d.ts +85 -50
  88. package/dist/hooks/useTaskforce.js +44 -14
  89. package/dist/hooks/useTaskforce.runtime-routing.test.js +94 -0
  90. package/dist/hooks/useTaskforce.sync-behavior.test.js +99 -0
  91. package/dist/hooks/useWorkspaceSyncController.js +7 -1
  92. package/dist/hooks/useWorkspaceSyncController.test.js +48 -0
  93. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +14 -7
  94. package/dist/mcp/canonicalAssetHelpers.d.ts +16 -4
  95. package/dist/mcp/canonicalAssetHelpers.js +18 -10
  96. package/dist/mcp/canonicalAssetHelpers.test.d.ts +1 -0
  97. package/dist/mcp/canonicalAssetHelpers.test.js +67 -0
  98. package/dist/mcp/clientIntegrations.d.ts +1 -0
  99. package/dist/mcp/clientIntegrations.js +25 -0
  100. package/dist/mcp/clientIntegrations.test.js +14 -3
  101. package/dist/mcp/clientProfileDefaults.js +3 -3
  102. package/dist/mcp/collaborationRegistrar.js +7 -7
  103. package/dist/mcp/documentAssetRegistrar.js +113 -7
  104. package/dist/mcp/runtime.d.ts +1 -0
  105. package/dist/mcp/runtime.js +969 -122
  106. package/dist/mcp/runtime.test.js +1083 -11
  107. package/dist/mcp/taskAttachmentTypes.d.ts +1 -1
  108. package/dist/mcp/taskPlanningRegistrar.js +48 -0
  109. package/dist/mcp/toolRegistry.d.ts +56 -0
  110. package/dist/mcp/toolRegistry.js +8 -0
  111. package/dist/mcp/toolRegistry.test.js +4 -0
  112. package/dist/migrations/taskSchemaMigrations.js +58 -15
  113. package/dist/server/aiProfileAvatarAssets.d.ts +49 -0
  114. package/dist/server/aiProfileAvatarAssets.js +319 -0
  115. package/dist/server/aiProfileAvatarAssets.test.d.ts +1 -0
  116. package/dist/server/aiProfileAvatarAssets.test.js +213 -0
  117. package/dist/server/annotatedAttachmentsRoutes.test.js +66 -7
  118. package/dist/server/index.js +4 -0
  119. package/dist/server/index.test.js +6 -1
  120. package/dist/server/routes/admin.js +328 -9
  121. package/dist/server/routes/annotatedAttachments.js +51 -0
  122. package/dist/server/routes/auth.d.ts +11 -0
  123. package/dist/server/routes/auth.js +189 -27
  124. package/dist/server/routes/authSupport.d.ts +5 -0
  125. package/dist/server/routes/authSupport.js +13 -1
  126. package/dist/server/routes/billing.d.ts +1 -0
  127. package/dist/server/routes/billing.js +116 -63
  128. package/dist/server/routes/billing.test.js +162 -11
  129. package/dist/server/routes/documents.js +10 -0
  130. package/dist/server/routes/shared.js +0 -10
  131. package/dist/server/routes/sync.integration.test.js +221 -0
  132. package/dist/server/routes/sync.js +13 -3
  133. package/dist/server/routes/syncSnapshotRoutes.js +2 -0
  134. package/dist/server/routes/tasks.js +12 -9
  135. package/dist/server/routes.js +87 -8
  136. package/dist/server/routes.test.js +972 -23
  137. package/dist/shared/aiProfileSurfaceType.d.ts +1 -1
  138. package/dist/shared/aiProfileSurfaceType.js +10 -8
  139. package/dist/storage/objectStorage.d.ts +1 -0
  140. package/dist/storage/objectStorage.js +2 -1
  141. package/dist/storage/objectStorageClient.d.ts +1 -0
  142. package/dist/storage/objectStorageClient.js +41 -8
  143. package/dist/storage/objectStorageClient.test.js +26 -1
  144. package/dist/storage/objectStorageConfig.js +3 -1
  145. package/dist/storage/objectStorageConfig.test.js +13 -1
  146. package/dist/sync/collaborationSyncPayload.js +2 -0
  147. package/dist/sync/collaborationSyncPayload.test.js +1 -0
  148. package/dist/sync/collaborationSyncState.d.ts +3 -3
  149. package/dist/sync/collaborationSyncState.js +19 -12
  150. package/dist/sync/contentSyncPayload.d.ts +5 -1
  151. package/dist/sync/contentSyncPayload.js +9 -0
  152. package/dist/sync/contentSyncPayload.test.js +39 -0
  153. package/dist/sync/contentSyncState.d.ts +5 -1
  154. package/dist/sync/contentSyncState.js +231 -37
  155. package/dist/sync/planningSyncPayload.js +5 -0
  156. package/dist/sync/planningSyncPayload.test.js +24 -0
  157. package/dist/sync/syncApplyHandlers.d.ts +4 -4
  158. package/dist/sync/syncApplyHandlers.js +89 -31
  159. package/dist/sync/syncApplyHandlers.test.js +184 -4
  160. package/dist/sync/syncResourceAdapters.d.ts +2 -0
  161. package/dist/sync/syncResourceAdapters.js +2 -0
  162. package/dist/sync/syncService.d.ts +1 -0
  163. package/dist/sync/syncService.js +13 -1
  164. package/dist/sync/syncService.test.js +39 -0
  165. package/dist/sync/workspacePullFeed.d.ts +4 -0
  166. package/dist/sync/workspacePullFeed.js +129 -70
  167. package/dist/sync/workspacePullFeed.test.js +49 -0
  168. package/dist/sync/workspaceSyncModel.d.ts +28 -6
  169. package/dist/sync/workspaceSyncModel.js +159 -45
  170. package/dist/sync/workspaceSyncModel.test.js +269 -3
  171. package/dist/types.d.ts +33 -2
  172. package/dist/ui/agent-logos/ChatGPT.png +0 -0
  173. package/dist/ui/agent-logos/Gemini CLI.jpeg +0 -0
  174. package/dist/ui/agent-logos/antigravity.jpeg +0 -0
  175. package/dist/ui/agent-logos/claude-code.jpeg +0 -0
  176. package/dist/ui/agent-logos/codex.jpeg +0 -0
  177. package/dist/ui/agent-logos/cursor.png +0 -0
  178. package/dist/ui/agent-logos/openclaw.jpeg +0 -0
  179. package/dist/ui/agent-logos/windsurf.png +0 -0
  180. package/dist/ui/assets/AgentsModule-BS4Pi_nC.js +1 -0
  181. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
  182. package/dist/ui/assets/AnnotatedAttachmentWorkspace-CCckzWYZ.js +3 -0
  183. package/dist/ui/assets/ContextAttachmentManager-CfG_ER7C.js +3 -0
  184. package/dist/ui/assets/DocumentWorkspace-BVaWPA25.js +252 -0
  185. package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +1 -0
  186. package/dist/ui/assets/EntityActivityTimeline-DSj0ThaK.js +1 -0
  187. package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
  188. package/dist/ui/assets/{InitiativesModule-9E6ParrY.js → InitiativesModule-AxMkWtxH.js} +1 -1
  189. package/dist/ui/assets/PlansPage-B_z-D6Nh.css +1 -0
  190. package/dist/ui/assets/PlansPage-Ndt7mYfo.js +1 -0
  191. package/dist/ui/assets/TaskContextUpload-Dw4rTF4i.js +1 -0
  192. package/dist/ui/assets/TaskSettings-f2ga42_V.js +9 -0
  193. package/dist/ui/assets/{WorkflowsModule-BA7jcEpH.js → WorkflowsModule-E4UL3mov.js} +4 -4
  194. package/dist/ui/assets/documentReferences-BOUcJm6-.js +1 -0
  195. package/dist/ui/assets/index-BvAbqx5Q.css +1 -0
  196. package/dist/ui/assets/index-MAHKvFqp.js +4 -0
  197. package/dist/ui/assets/{vendor-icons-DuEd_65c.js → vendor-icons-B32hGuF2.js} +1 -1
  198. package/dist/ui/index.html +3 -3
  199. package/dist/ui/og-image.png +0 -0
  200. package/dist/ui/product/agent manager.png +0 -0
  201. package/dist/ui/product/agent_manager_02.png +0 -0
  202. package/dist/utils/aiProfileEvents.d.ts +7 -0
  203. package/dist/utils/aiProfileEvents.js +6 -0
  204. package/dist/utils/assignees.d.ts +6 -0
  205. package/dist/utils/assignees.js +8 -1
  206. package/dist/utils/assignees.test.d.ts +1 -0
  207. package/dist/utils/assignees.test.js +25 -0
  208. package/dist/utils/avatarDisplay.d.ts +1 -0
  209. package/dist/utils/avatarDisplay.js +15 -0
  210. package/dist/utils/contextFiles.d.ts +3 -1
  211. package/dist/utils/contextFiles.js +2 -1
  212. package/dist/utils/taskActivity.d.ts +7 -1
  213. package/dist/utils/taskActivity.js +63 -1
  214. package/dist/utils/taskActivity.test.js +23 -1
  215. package/dist/utils/taskSearch.d.ts +22 -0
  216. package/dist/utils/taskSearch.js +87 -0
  217. package/dist/utils/taskSearch.test.d.ts +1 -0
  218. package/dist/utils/taskSearch.test.js +69 -0
  219. package/dist/utils/taskUtils.js +10 -3
  220. package/dist/utils/taskUtils.test.d.ts +1 -0
  221. package/dist/utils/taskUtils.test.js +19 -0
  222. package/package.json +1 -1
  223. package/dist/ui/assets/AgentsModule-DrLawwNl.js +0 -1
  224. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +0 -1
  225. package/dist/ui/assets/AnnotatedAttachmentWorkspace-Z9_whf7F.js +0 -3
  226. package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +0 -252
  227. package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +0 -1
  228. package/dist/ui/assets/PlansPage-CvfnMiWq.css +0 -1
  229. package/dist/ui/assets/PlansPage-DRXscYxH.js +0 -1
  230. package/dist/ui/assets/TaskSettings-DZX4jk7e.js +0 -9
  231. package/dist/ui/assets/index-9eT8e0Lu.css +0 -1
  232. package/dist/ui/assets/index-C6k75jr8.js +0 -6
@@ -5,6 +5,7 @@ import { AgentsModule } from './AgentsModule';
5
5
  describe('AgentsModule', () => {
6
6
  afterEach(() => {
7
7
  vi.restoreAllMocks();
8
+ vi.unstubAllGlobals();
8
9
  });
9
10
  it('groups registered AI profiles by surface type in a stable section order', async () => {
10
11
  const fetchMock = vi.fn()
@@ -90,12 +91,11 @@ describe('AgentsModule', () => {
90
91
  await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
91
92
  expect((await screen.findAllByText('Chat Apps')).length).toBeGreaterThan(0);
92
93
  expect(screen.getAllByText('Coding Tools').length).toBeGreaterThan(0);
93
- expect(screen.getByText('Unclassified')).toBeInTheDocument();
94
+ expect(screen.getAllByText('Unclassified').length).toBeGreaterThan(0);
94
95
  expect(screen.getAllByText('Codex')).toHaveLength(1);
95
- expect(screen.getByText(/2 duplicates/i)).toBeInTheDocument();
96
- expect(screen.getByText('Coding tool')).toBeInTheDocument();
97
- expect(screen.getAllByText('Cloud MCP').length).toBeGreaterThan(0);
98
- expect(screen.getAllByText('Local MCP').length).toBeGreaterThan(0);
96
+ expect(screen.getByText(/1 duplicate/i)).toBeInTheDocument();
97
+ expect(screen.getAllByTitle('Cloud MCP').length).toBeGreaterThan(0);
98
+ expect(screen.getAllByTitle('Local MCP').length).toBeGreaterThan(0);
99
99
  expect(screen.getByText('Registered Cloud Agents: 2/5')).toBeInTheDocument();
100
100
  expect(screen.getAllByText('General-purpose chat surface for planning and writing.').length).toBeGreaterThan(0);
101
101
  expect(screen.getAllByText('Product copilot').length).toBeGreaterThan(0);
@@ -107,6 +107,7 @@ describe('AgentsModule', () => {
107
107
  expect(text.indexOf('Chat Apps')).toBeLessThan(text.indexOf('Coding Tools'));
108
108
  expect(text.indexOf('Coding Tools')).toBeLessThan(text.indexOf('Unclassified'));
109
109
  fireEvent.click(screen.getByRole('button', { name: /codex/i }));
110
+ expect(screen.getByText('Coding Tool')).toBeInTheDocument();
110
111
  expect(screen.getAllByText('Terminal-first coding agent.').length).toBeGreaterThan(0);
111
112
  expect(screen.getAllByText('Implementation partner')[0]).toBeInTheDocument();
112
113
  expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0);
@@ -119,6 +120,191 @@ describe('AgentsModule', () => {
119
120
  expect(screen.getAllByTitle('Cloud MCP').length).toBeGreaterThan(0);
120
121
  expect(screen.getAllByTitle('Local MCP').length).toBeGreaterThan(0);
121
122
  });
123
+ it('does not mark local and cloud profiles with the same name as duplicates', async () => {
124
+ const fetchMock = vi.fn()
125
+ .mockResolvedValueOnce({
126
+ ok: true,
127
+ json: async () => ({
128
+ aiProfileSeatUsage: {
129
+ used: 1,
130
+ limit: null,
131
+ remaining: null
132
+ },
133
+ assignees: [
134
+ {
135
+ value: 'ai-codex-local',
136
+ label: 'Codex',
137
+ username: 'codex',
138
+ icon: 'Bot',
139
+ color: '#0ea5e9',
140
+ kind: 'agent',
141
+ surfaceType: 'cli',
142
+ seatScope: 'local_unmetered',
143
+ description: 'Terminal-first coding agent.',
144
+ role: 'Coding Agent',
145
+ provider: 'OpenAI',
146
+ model: 'Codex',
147
+ createdAt: '2026-04-02T09:00:00.000Z',
148
+ updatedAt: '2026-04-04T13:15:00.000Z',
149
+ lastActiveAt: '2026-04-04T13:45:00.000Z'
150
+ },
151
+ {
152
+ value: 'ai-codex-cloud',
153
+ label: 'Codex',
154
+ username: 'codex',
155
+ icon: 'Bot',
156
+ color: '#0ea5e9',
157
+ kind: 'agent',
158
+ surfaceType: 'cli',
159
+ seatScope: 'cloud_metered',
160
+ description: 'Cloud MCP coding profile.',
161
+ role: 'Coding Agent',
162
+ provider: 'OpenAI',
163
+ model: 'Codex',
164
+ createdAt: '2026-04-02T10:00:00.000Z',
165
+ updatedAt: '2026-04-04T13:16:00.000Z',
166
+ lastActiveAt: '2026-04-04T13:46:00.000Z'
167
+ }
168
+ ]
169
+ })
170
+ });
171
+ vi.stubGlobal('fetch', fetchMock);
172
+ render(_jsx(AgentsModule, { workspaceId: "workspace-1", cloudAuthConfigured: true, authSessionResolved: true, isAuthenticated: true, cloudAiProfileSeatUsage: { used: 1, limit: 5, remaining: 4 } }));
173
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
174
+ expect(screen.getAllByRole('button', { name: /codex/i })).toHaveLength(2);
175
+ expect(screen.queryByText(/\d+ duplicate/i)).not.toBeInTheDocument();
176
+ expect(screen.queryByText(/Profile instances/i)).not.toBeInTheDocument();
177
+ expect(screen.getAllByTitle('Cloud MCP').length).toBeGreaterThan(0);
178
+ expect(screen.getAllByTitle('Local MCP').length).toBeGreaterThan(0);
179
+ });
180
+ it('keeps a duplicate group avatar visible when the newest duplicate has no photo', async () => {
181
+ const avatarUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fai-codex-old%2Favatar%2Fcropped.webp';
182
+ const fetchMock = vi.fn()
183
+ .mockResolvedValueOnce({
184
+ ok: true,
185
+ json: async () => ({
186
+ aiProfileSeatUsage: {
187
+ used: 2,
188
+ limit: null,
189
+ remaining: null
190
+ },
191
+ assignees: [
192
+ {
193
+ value: 'ai-codex-new',
194
+ label: 'Codex',
195
+ username: 'codex-new',
196
+ icon: 'Bot',
197
+ color: '#0ea5e9',
198
+ kind: 'agent',
199
+ surfaceType: 'coding_tool',
200
+ seatScope: 'local_unmetered',
201
+ createdAt: '2026-04-02T10:00:00.000Z',
202
+ updatedAt: '2026-04-04T16:00:00.000Z',
203
+ lastActiveAt: '2026-04-04T16:30:00.000Z',
204
+ avatarUrl: null,
205
+ avatarSourceUrl: null
206
+ },
207
+ {
208
+ value: 'ai-codex-old',
209
+ label: 'Codex',
210
+ username: 'codex-old',
211
+ icon: 'Bot',
212
+ color: '#0ea5e9',
213
+ kind: 'agent',
214
+ surfaceType: 'coding_tool',
215
+ seatScope: 'local_unmetered',
216
+ createdAt: '2026-04-02T09:00:00.000Z',
217
+ updatedAt: '2026-04-04T13:15:00.000Z',
218
+ lastActiveAt: '2026-04-04T13:45:00.000Z',
219
+ avatarUrl,
220
+ avatarSourceUrl: avatarUrl
221
+ }
222
+ ]
223
+ })
224
+ });
225
+ vi.stubGlobal('fetch', fetchMock);
226
+ render(_jsx(AgentsModule, { workspaceId: "workspace-1", cloudAuthConfigured: true, authSessionResolved: true, isAuthenticated: true, cloudAiProfileSeatUsage: { used: 2, limit: 5, remaining: 3 } }));
227
+ const codexButton = await screen.findByRole('button', { name: /codex/i });
228
+ expect(codexButton.querySelector('img')).toHaveAttribute('src', avatarUrl);
229
+ expect(screen.getByLabelText('Edit AI profile photo').querySelector('img')).toHaveAttribute('src', avatarUrl);
230
+ });
231
+ it('uploads AI profile avatars through the stable avatar endpoint', async () => {
232
+ const avatarUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fai-codex%2Favatar%2Fdisplay.gif';
233
+ const fetchMock = vi.fn(async (input, init) => {
234
+ const url = String(input);
235
+ if (url.startsWith('/api/taskforce/workspace/assignee-options')) {
236
+ return {
237
+ ok: true,
238
+ json: async () => ({
239
+ aiProfileSeatUsage: {
240
+ used: 1,
241
+ limit: null,
242
+ remaining: null
243
+ },
244
+ assignees: [
245
+ {
246
+ value: 'ai-codex',
247
+ label: 'Codex',
248
+ username: 'codex',
249
+ icon: 'Bot',
250
+ color: '#0ea5e9',
251
+ kind: 'agent',
252
+ surfaceType: 'coding_tool',
253
+ seatScope: 'cloud_metered',
254
+ createdAt: '2026-04-02T09:00:00.000Z',
255
+ updatedAt: '2026-04-04T13:15:00.000Z',
256
+ lastActiveAt: '2026-04-04T13:45:00.000Z',
257
+ avatarUrl: null,
258
+ avatarSourceUrl: null
259
+ }
260
+ ]
261
+ })
262
+ };
263
+ }
264
+ if (url === '/api/taskforce/workspace/ai-profiles/avatar/upload') {
265
+ return {
266
+ ok: true,
267
+ json: async () => ({
268
+ profile: {
269
+ id: 'ai-codex',
270
+ avatarUrl,
271
+ avatarSourceUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fai-codex%2Favatar%2Fsource.gif',
272
+ avatarRevision: 1,
273
+ avatarUpdatedAt: '2026-04-28T00:00:00.000Z'
274
+ }
275
+ })
276
+ };
277
+ }
278
+ throw new Error(`Unexpected fetch ${url}: ${String(init?.body || '')}`);
279
+ });
280
+ vi.stubGlobal('fetch', fetchMock);
281
+ vi.stubGlobal('URL', {
282
+ ...URL,
283
+ createObjectURL: vi.fn(() => 'blob:avatar-preview'),
284
+ revokeObjectURL: vi.fn()
285
+ });
286
+ render(_jsx(AgentsModule, { workspaceId: "workspace-1", cloudAuthConfigured: true, authSessionResolved: true, isAuthenticated: true, cloudAiProfileSeatUsage: { used: 1, limit: 5, remaining: 4 } }));
287
+ fireEvent.click(await screen.findByLabelText('Edit AI profile photo'));
288
+ const input = await screen.findByLabelText('Choose profile photo');
289
+ const file = new File(['avatar'], 'avatar.gif', { type: 'image/gif' });
290
+ fireEvent.change(input, { target: { files: [file] } });
291
+ fireEvent.click(screen.getByRole('button', { name: 'Apply Photo' }));
292
+ await waitFor(() => {
293
+ expect(fetchMock).toHaveBeenCalledWith('/api/taskforce/workspace/ai-profiles/avatar/upload', expect.objectContaining({ method: 'POST' }));
294
+ });
295
+ const uploadBody = JSON.parse(String(fetchMock.mock.calls.find((call) => call[0] === '/api/taskforce/workspace/ai-profiles/avatar/upload')?.[1]?.body || '{}'));
296
+ expect(uploadBody).toEqual(expect.objectContaining({
297
+ profileId: 'ai-codex',
298
+ displayImage: expect.objectContaining({
299
+ mimeType: 'image/gif',
300
+ originalName: 'avatar.gif'
301
+ })
302
+ }));
303
+ expect(String(uploadBody.displayImage.dataUrl)).toMatch(new RegExp('^data:image/gif;base64,'));
304
+ expect(fetchMock.mock.calls.some((call) => call[0] === '/api/taskforce/context-upload/init')).toBe(false);
305
+ expect(fetchMock.mock.calls.some((call) => call[0] === '/api/taskforce/context-upload/finalize')).toBe(false);
306
+ expect(fetchMock.mock.calls.some((call) => call[0] === '/api/taskforce/context-upload' && String(call[1]?.method || '').toUpperCase() === 'POST')).toBe(false);
307
+ });
122
308
  it('removes a profile from the active roster and reloads the list', async () => {
123
309
  const confirmMock = vi.fn().mockReturnValue(true);
124
310
  const fetchMock = vi.fn()
@@ -174,19 +360,83 @@ describe('AgentsModule', () => {
174
360
  vi.stubGlobal('confirm', confirmMock);
175
361
  render(_jsx(AgentsModule, { workspaceId: "workspace-1", cloudAuthConfigured: true, authSessionResolved: true, isAuthenticated: true, cloudAiProfileSeatUsage: { used: 1, limit: 3, remaining: 2 } }));
176
362
  expect(await screen.findByRole('button', { name: /codex/i })).toBeInTheDocument();
177
- fireEvent.click(screen.getByRole('button', { name: /remove from roster/i }));
363
+ fireEvent.change(screen.getByLabelText('Agent roster status'), { target: { value: 'retire' } });
178
364
  await waitFor(() => expect(confirmMock).toHaveBeenCalledTimes(1));
179
365
  expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/taskforce/workspace/ai-profiles/archive', expect.objectContaining({
180
366
  method: 'POST',
181
367
  credentials: 'include'
182
368
  }));
183
- expect(fetchMock).toHaveBeenNthCalledWith(3, '/api/taskforce/workspace/assignee-options?workspaceId=workspace-1', expect.objectContaining({
369
+ expect(fetchMock).toHaveBeenNthCalledWith(3, '/api/taskforce/workspace/assignee-options?workspaceId=workspace-1&kind=agent', expect.objectContaining({
184
370
  credentials: 'include'
185
371
  }));
186
372
  expect(await screen.findByText('AI profile removed from active roster.')).toBeInTheDocument();
187
373
  expect(await screen.findByText('No AI profiles registered yet.')).toBeInTheDocument();
188
374
  });
189
- it('shows archive permission errors inline above the remove button', async () => {
375
+ it('updates an AI profile category from the detail dropdown', async () => {
376
+ const fetchMock = vi.fn(async (input, init) => {
377
+ const url = String(input);
378
+ if (url.startsWith('/api/taskforce/workspace/assignee-options')) {
379
+ return {
380
+ ok: true,
381
+ json: async () => ({
382
+ aiProfileSeatUsage: {
383
+ used: 1,
384
+ limit: null,
385
+ remaining: null
386
+ },
387
+ assignees: [
388
+ {
389
+ value: 'ai-chatgpt',
390
+ label: 'ChatGPT',
391
+ username: 'chatgpt',
392
+ icon: 'Bot',
393
+ color: '#10a37f',
394
+ kind: 'agent',
395
+ surfaceType: 'chat_app',
396
+ seatScope: 'cloud_metered',
397
+ provider: 'OpenAI',
398
+ model: 'GPT-5',
399
+ createdAt: '2026-04-02T09:00:00.000Z',
400
+ updatedAt: '2026-04-04T13:15:00.000Z',
401
+ lastActiveAt: '2026-04-04T13:45:00.000Z'
402
+ }
403
+ ]
404
+ })
405
+ };
406
+ }
407
+ if (url === '/api/taskforce/workspace/ai-profiles/surface-type') {
408
+ return {
409
+ ok: true,
410
+ json: async () => ({
411
+ profile: {
412
+ id: 'ai-chatgpt',
413
+ surfaceType: 'cli',
414
+ updatedAt: '2026-04-28T00:00:00.000Z'
415
+ }
416
+ })
417
+ };
418
+ }
419
+ throw new Error(`Unexpected fetch ${url}: ${String(init?.body || '')}`);
420
+ });
421
+ vi.stubGlobal('fetch', fetchMock);
422
+ render(_jsx(AgentsModule, { workspaceId: "workspace-1", cloudAuthConfigured: true, authSessionResolved: true, isAuthenticated: true, cloudAiProfileSeatUsage: { used: 1, limit: 3, remaining: 2 } }));
423
+ const categorySelect = await screen.findByLabelText('Agent category');
424
+ fireEvent.change(categorySelect, { target: { value: 'cli' } });
425
+ await waitFor(() => {
426
+ expect(fetchMock).toHaveBeenCalledWith('/api/taskforce/workspace/ai-profiles/surface-type', expect.objectContaining({
427
+ method: 'POST',
428
+ credentials: 'include',
429
+ body: JSON.stringify({
430
+ profileId: 'ai-chatgpt',
431
+ surfaceType: 'cli'
432
+ })
433
+ }));
434
+ });
435
+ expect(await screen.findByText('AI profile category updated.')).toBeInTheDocument();
436
+ expect(screen.getByLabelText('Agent category').value).toBe('cli');
437
+ expect(screen.getAllByText('CLI').length).toBeGreaterThan(0);
438
+ });
439
+ it('shows archive permission errors inline near the roster status control', async () => {
190
440
  const confirmMock = vi.fn().mockReturnValue(true);
191
441
  const fetchMock = vi.fn()
192
442
  .mockResolvedValueOnce({
@@ -227,11 +477,10 @@ describe('AgentsModule', () => {
227
477
  vi.stubGlobal('confirm', confirmMock);
228
478
  render(_jsx(AgentsModule, { workspaceId: "workspace-1", cloudAuthConfigured: true, authSessionResolved: true, isAuthenticated: true, cloudAiProfileSeatUsage: { used: 1, limit: 3, remaining: 2 } }));
229
479
  expect(await screen.findByRole('button', { name: /codex/i })).toBeInTheDocument();
230
- fireEvent.click(screen.getByRole('button', { name: /remove from roster/i }));
480
+ fireEvent.change(screen.getByLabelText('Agent roster status'), { target: { value: 'retire' } });
231
481
  const inlineError = await screen.findByRole('alert');
232
482
  expect(inlineError).toHaveTextContent('Forbidden: owner or admin role required.');
233
- const removeButton = screen.getByRole('button', { name: /remove from roster/i });
234
- expect(removeButton.compareDocumentPosition(inlineError) & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy();
483
+ expect(screen.getByLabelText('Agent roster status')).toBeInTheDocument();
235
484
  });
236
485
  it('falls back to local seat usage only when cloud auth is not configured', async () => {
237
486
  const fetchMock = vi.fn()
@@ -8,6 +8,8 @@ type ShellProps = {
8
8
  requestedTarget?: OpenAnnotatedAttachmentDetail | null;
9
9
  requestedSessionId?: string | null;
10
10
  requestedOpenVersion?: number;
11
+ imageTrayOpen?: boolean;
12
+ onCloseImageTray?: () => void;
11
13
  resolveTaskReferenceLabel?: (taskId: string) => string;
12
14
  resolveImageReferenceLabel?: (assetId: string) => string;
13
15
  onRequestedTargetHandled?: () => void;
@@ -20,5 +22,5 @@ type ShellProps = {
20
22
  }) => void;
21
23
  onBackToTask?: (taskId: string) => void;
22
24
  };
23
- export declare function AnnotatedAttachmentWorkspaceShell({ runtimeMode, apiBaseUrl, cloudAuthBaseUrl, workspaceId, sessionLoadReady, requestedTarget, requestedSessionId, requestedOpenVersion, resolveTaskReferenceLabel, resolveImageReferenceLabel, onRequestedTargetHandled, onOpenTarget, onContextChange, onBackToTask, }: ShellProps): import("react/jsx-runtime").JSX.Element;
25
+ export declare function AnnotatedAttachmentWorkspaceShell({ runtimeMode, apiBaseUrl, cloudAuthBaseUrl, workspaceId, sessionLoadReady, requestedTarget, requestedSessionId, requestedOpenVersion, imageTrayOpen, onCloseImageTray, resolveTaskReferenceLabel, resolveImageReferenceLabel, onRequestedTargetHandled, onOpenTarget, onContextChange, onBackToTask, }: ShellProps): import("react/jsx-runtime").JSX.Element;
24
26
  export {};
@@ -1,6 +1,6 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
- import { ArrowDown, ArrowLeft, ArrowUpLeft, ArrowUp, Check, ClipboardPaste, Copy, FolderOpen, Hand, HelpCircle, Loader2, MessageSquareCode, MessageSquareText, MousePointer2, Pin, Plus, RotateCcw, Save, SquareMousePointer, Trash2, ZoomIn, ZoomOut, } from 'lucide-react';
3
+ import { ArrowDown, ArrowLeft, ArrowUpLeft, ArrowUp, Check, ChevronLeft, ClipboardPaste, Copy, FolderOpen, Hand, HelpCircle, Loader2, MessageSquareCode, MessageSquareText, MousePointer2, Pin, Plus, RotateCcw, Save, Search, SquareMousePointer, Trash2, ZoomIn, ZoomOut, } from 'lucide-react';
4
4
  import sharedStyles from '../../Taskforce.module.css';
5
5
  import { fetchTaskforceApi } from '../../utils/taskforceApiClient';
6
6
  import { logBootstrapDebug } from '../../hooks/workspace/workspaceLocalState';
@@ -132,6 +132,33 @@ function formatTimestamp(value) {
132
132
  return 'Unsaved';
133
133
  return parsed.toLocaleString();
134
134
  }
135
+ function formatFileSize(bytes) {
136
+ const normalized = typeof bytes === 'number' && Number.isFinite(bytes) ? Math.max(0, bytes) : 0;
137
+ if (normalized < 1024)
138
+ return `${normalized}B`;
139
+ if (normalized < 1024 * 1024)
140
+ return `${(normalized / 1024).toFixed(1)}KB`;
141
+ return `${(normalized / (1024 * 1024)).toFixed(1)}MB`;
142
+ }
143
+ function formatRelativeDate(isoDate) {
144
+ if (!isoDate)
145
+ return '';
146
+ const parsed = new Date(isoDate);
147
+ if (Number.isNaN(parsed.getTime()))
148
+ return '';
149
+ const now = new Date();
150
+ const diffMs = now.getTime() - parsed.getTime();
151
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
152
+ if (diffDays <= 0)
153
+ return 'Today';
154
+ if (diffDays === 1)
155
+ return 'Yesterday';
156
+ if (diffDays < 7)
157
+ return `${diffDays}d ago`;
158
+ if (diffDays < 30)
159
+ return `${Math.floor(diffDays / 7)}w ago`;
160
+ return parsed.toLocaleDateString();
161
+ }
135
162
  function getSessionCreatorLabel(session) {
136
163
  const createdByLabel = String(session.createdByActor?.label || '').trim();
137
164
  if (createdByLabel)
@@ -371,7 +398,7 @@ function buildDraftPayloadFromSession(session, fallbackTitle) {
371
398
  annotations: session?.annotations || [],
372
399
  });
373
400
  }
374
- export function AnnotatedAttachmentWorkspaceShell({ runtimeMode = 'local', apiBaseUrl = '', cloudAuthBaseUrl = '', workspaceId = 'default', sessionLoadReady = true, requestedTarget = null, requestedSessionId = null, requestedOpenVersion = 0, resolveTaskReferenceLabel, resolveImageReferenceLabel, onRequestedTargetHandled, onOpenTarget, onContextChange, onBackToTask, }) {
401
+ export function AnnotatedAttachmentWorkspaceShell({ runtimeMode = 'local', apiBaseUrl = '', cloudAuthBaseUrl = '', workspaceId = 'default', sessionLoadReady = true, requestedTarget = null, requestedSessionId = null, requestedOpenVersion = 0, imageTrayOpen, onCloseImageTray, resolveTaskReferenceLabel, resolveImageReferenceLabel, onRequestedTargetHandled, onOpenTarget, onContextChange, onBackToTask, }) {
375
402
  const resolvedApiBase = runtimeMode === 'cloud'
376
403
  ? (cloudAuthBaseUrl || apiBaseUrl || '')
377
404
  : '';
@@ -403,6 +430,10 @@ export function AnnotatedAttachmentWorkspaceShell({ runtimeMode = 'local', apiBa
403
430
  const [openImageModalOpen, setOpenImageModalOpen] = useState(false);
404
431
  const [openImageReference, setOpenImageReference] = useState('');
405
432
  const [openingImage, setOpeningImage] = useState(false);
433
+ const [imageTrayItems, setImageTrayItems] = useState([]);
434
+ const [imageTraySearchQuery, setImageTraySearchQuery] = useState('');
435
+ const [imageTrayLoading, setImageTrayLoading] = useState(false);
436
+ const [imageTrayError, setImageTrayError] = useState(null);
406
437
  const [imageLoading, setImageLoading] = useState(false);
407
438
  const [imageLoadError, setImageLoadError] = useState(false);
408
439
  const [zoomLevel, setZoomLevel] = useState(1);
@@ -486,6 +517,22 @@ export function AnnotatedAttachmentWorkspaceShell({ runtimeMode = 'local', apiBa
486
517
  hitRadius: 11,
487
518
  }), []);
488
519
  const arrowOverlayViewBox = useMemo(() => `0 0 ${Math.max(canvasMediaSize.width, 1)} ${Math.max(canvasMediaSize.height, 1)}`, [canvasMediaSize.height, canvasMediaSize.width]);
520
+ const showImageTray = typeof imageTrayOpen === 'boolean';
521
+ const filteredImageTrayItems = useMemo(() => {
522
+ const query = imageTraySearchQuery.trim().toLowerCase();
523
+ if (!query)
524
+ return imageTrayItems;
525
+ return imageTrayItems.filter((item) => {
526
+ const haystack = [
527
+ item.displayName,
528
+ item.originalFilename,
529
+ item.imageReferenceLabel,
530
+ item.taskReferenceLabel,
531
+ item.assetId,
532
+ ].filter(Boolean).join(' ').toLowerCase();
533
+ return haystack.includes(query);
534
+ });
535
+ }, [imageTrayItems, imageTraySearchQuery]);
489
536
  const canPan = useMemo(() => {
490
537
  const scroller = canvasScrollerRef.current;
491
538
  if (!scroller)
@@ -889,6 +936,26 @@ export function AnnotatedAttachmentWorkspaceShell({ runtimeMode = 'local', apiBa
889
936
  }
890
937
  }
891
938
  }, [clearPendingAutoSave, createSessionForTarget, requestHeaders, resolvedApiBase, syncDraftFromSession, workspaceId]);
939
+ const fetchImageTrayItems = useCallback(async () => {
940
+ setImageTrayLoading(true);
941
+ setImageTrayError(null);
942
+ try {
943
+ const params = new URLSearchParams();
944
+ params.set('workspaceId', String(workspaceId || 'default').trim() || 'default');
945
+ const res = await fetchTaskforceApi(`/api/taskforce/annotated-attachments/images?${params.toString()}`, { credentials: 'include', headers: requestHeaders, cache: 'no-store' }, resolvedApiBase);
946
+ const data = await res.json().catch(() => ({}));
947
+ if (!res.ok) {
948
+ throw new Error(String(data?.error || 'Failed to load images.'));
949
+ }
950
+ setImageTrayItems(Array.isArray(data?.images) ? data.images : []);
951
+ }
952
+ catch (requestError) {
953
+ setImageTrayError(requestError instanceof Error ? requestError.message : 'Failed to load images.');
954
+ }
955
+ finally {
956
+ setImageTrayLoading(false);
957
+ }
958
+ }, [requestHeaders, resolvedApiBase, workspaceId]);
892
959
  const pasteImageFromClipboard = useCallback(async () => {
893
960
  if (typeof navigator === 'undefined' || !navigator.clipboard || typeof navigator.clipboard.read !== 'function') {
894
961
  setError('Clipboard image paste is not supported in this environment.');
@@ -1016,6 +1083,33 @@ export function AnnotatedAttachmentWorkspaceShell({ runtimeMode = 'local', apiBa
1016
1083
  setOpeningImage(false);
1017
1084
  }
1018
1085
  }, [isTargetControlled, loadSessions, onOpenTarget, openImageReference, requestHeaders, workspaceId]);
1086
+ const openImageFromTray = useCallback(async (item) => {
1087
+ if (!item.assetId || !item.path)
1088
+ return;
1089
+ const flushed = await flushPendingDraft('session-switch');
1090
+ if (!flushed)
1091
+ return;
1092
+ const target = {
1093
+ assetId: item.assetId,
1094
+ path: item.path,
1095
+ displayName: String(item.displayName || item.originalFilename || 'Image attachment').trim() || 'Image attachment',
1096
+ ...(item.taskId ? { taskId: item.taskId } : {}),
1097
+ ...(item.taskReferenceLabel ? { taskReferenceLabel: item.taskReferenceLabel } : {}),
1098
+ ...(item.imageReferenceLabel ? { imageReferenceLabel: item.imageReferenceLabel } : {}),
1099
+ };
1100
+ pendingRequestedSessionIdRef.current = null;
1101
+ setError(null);
1102
+ if (isTargetControlled) {
1103
+ lastLoadedTargetKeyRef.current = '';
1104
+ onOpenTarget?.(target, { sessionId: null });
1105
+ }
1106
+ else {
1107
+ setUncontrolledTarget(target);
1108
+ lastLoadedTargetKeyRef.current = getAnnotatedTargetKey(target);
1109
+ resetForTargetLoad(target);
1110
+ void loadSessions(target, { autoCreateIfEmpty: true });
1111
+ }
1112
+ }, [flushPendingDraft, isTargetControlled, loadSessions, onOpenTarget, resetForTargetLoad]);
1019
1113
  const applySessionLocally = useCallback((session) => {
1020
1114
  setSessions((current) => {
1021
1115
  const existingIndex = current.findIndex((item) => item.id === session.id);
@@ -1207,6 +1301,11 @@ export function AnnotatedAttachmentWorkspaceShell({ runtimeMode = 'local', apiBa
1207
1301
  setLastSaveError(null);
1208
1302
  setSaveState('saved');
1209
1303
  }, [currentDraftSnapshot, saveState, selectedSessionId]);
1304
+ useEffect(() => {
1305
+ if (!showImageTray || !imageTrayOpen)
1306
+ return;
1307
+ void fetchImageTrayItems();
1308
+ }, [fetchImageTrayItems, imageTrayOpen, showImageTray]);
1210
1309
  useEffect(() => {
1211
1310
  if (typeof window === 'undefined')
1212
1311
  return undefined;
@@ -1802,7 +1901,22 @@ export function AnnotatedAttachmentWorkspaceShell({ runtimeMode = 'local', apiBa
1802
1901
  event.stopPropagation();
1803
1902
  }, [panMode, readPoint, toolMode]);
1804
1903
  const activeSessionTimestamp = selectedSession?.updatedAt ? formatTimestamp(selectedSession.updatedAt) : 'Not saved yet';
1805
- return (_jsxs("div", { className: styles.shell, children: [_jsxs("section", { className: `tf-surface-panel ${styles.panel} ${styles.sessionPanel}`, children: [_jsx("div", { className: styles.sessionContextBar, children: activeTarget?.taskId ? (_jsxs(_Fragment, { children: [_jsx("div", { className: styles.sessionContextLeft, children: onBackToTask ? (_jsx("button", { type: "button", className: `tf-button-ghost tf-button-compact ${styles.ghostBtn} ${styles.backToTaskBtn}`, onClick: () => {
1904
+ return (_jsxs("div", { className: `${styles.shell} ${showImageTray ? styles.shellWithImageTray : ''} ${showImageTray && imageTrayOpen ? styles.shellImageTrayOpen : ''}`.trim(), children: [showImageTray ? (_jsxs("aside", { className: `${styles.imageTrayPanel} ${imageTrayOpen ? styles.imageTrayPanelOpen : ''}`.trim(), "aria-label": "Image tray", "aria-hidden": !imageTrayOpen, children: [_jsxs("div", { className: styles.imageTrayHeader, children: [_jsxs("span", { className: styles.imageTrayTitle, children: [_jsx(FolderOpen, { size: 14 }), "Images"] }), _jsx("button", { type: "button", className: "tf-control-icon", onClick: onCloseImageTray, title: "Collapse image tray", "aria-label": "Collapse image tray", children: _jsx(ChevronLeft, { size: 16 }) })] }), _jsxs("div", { className: styles.imageTraySearch, children: [_jsx(Search, { size: 12, className: styles.imageTraySearchIcon }), _jsx("input", { type: "text", placeholder: "Search images...", value: imageTraySearchQuery, onChange: (event) => setImageTraySearchQuery(event.target.value), className: styles.imageTraySearchInput })] }), _jsx("div", { className: `tf-scrollbar ${styles.imageTrayList}`, children: imageTrayLoading ? (_jsx("div", { className: styles.imageTrayState, children: "Loading images..." })) : imageTrayError ? (_jsx("div", { className: styles.imageTrayStateError, children: imageTrayError })) : filteredImageTrayItems.length === 0 ? (_jsx("div", { className: styles.imageTrayState, children: imageTraySearchQuery.trim() ? 'No images match your search.' : 'No images found.' })) : filteredImageTrayItems.map((item) => {
1905
+ const isSelected = activeTarget?.assetId === item.assetId;
1906
+ const attachmentCount = typeof item.attachmentCount === 'number' && Number.isFinite(item.attachmentCount)
1907
+ ? Math.max(0, item.attachmentCount)
1908
+ : 0;
1909
+ const sessionCount = typeof item.sessionCount === 'number' && Number.isFinite(item.sessionCount)
1910
+ ? Math.max(0, item.sessionCount)
1911
+ : 0;
1912
+ const linkedLabel = attachmentCount > 1
1913
+ ? `${attachmentCount} tasks linked`
1914
+ : attachmentCount === 1
1915
+ ? item.taskReferenceLabel || '1 task linked'
1916
+ : 'Unattached';
1917
+ const imageName = String(item.displayName || item.originalFilename || 'Image attachment').trim() || 'Image attachment';
1918
+ return (_jsxs("button", { type: "button", className: `${styles.imageTrayItem} ${isSelected ? styles.imageTrayItemActive : ''}`.trim(), onClick: () => void openImageFromTray(item), title: imageName, children: [_jsx("span", { className: styles.imageTrayThumb, children: _jsx("img", { src: item.path, alt: "", loading: "lazy" }) }), _jsxs("span", { className: styles.imageTrayItemBody, children: [_jsx("span", { className: styles.imageTrayItemTitle, children: imageName }), _jsx("span", { className: styles.imageTrayItemTask, children: linkedLabel }), _jsxs("span", { className: styles.imageTrayItemMeta, children: [_jsxs("span", { className: styles.imageTrayItemMetaDetails, children: [formatFileSize(item.sizeBytes), item.updatedAt ? _jsxs(_Fragment, { children: [" \u00B7 ", formatRelativeDate(item.updatedAt)] }) : null, sessionCount > 0 ? _jsxs(_Fragment, { children: [" \u00B7 ", sessionCount, " session", sessionCount === 1 ? '' : 's'] }) : null] }), item.imageReferenceLabel ? (_jsx("span", { className: styles.imageTrayItemReference, children: item.imageReferenceLabel })) : null] })] })] }, item.assetId));
1919
+ }) })] })) : null, _jsxs("section", { className: `tf-surface-panel ${styles.panel} ${styles.sessionPanel}`, children: [_jsx("div", { className: styles.sessionContextBar, children: activeTarget?.taskId ? (_jsxs(_Fragment, { children: [_jsx("div", { className: styles.sessionContextLeft, children: onBackToTask ? (_jsx("button", { type: "button", className: `tf-button-ghost tf-button-compact ${styles.ghostBtn} ${styles.backToTaskBtn}`, onClick: () => {
1806
1920
  void flushPendingDraft('back-to-task').then((saved) => {
1807
1921
  if (!saved)
1808
1922
  return;
@@ -320,6 +320,58 @@ describe('AnnotatedAttachmentWorkspaceShell', () => {
320
320
  expect(screen.queryByAltText('Homepage shot')).not.toBeInTheDocument();
321
321
  });
322
322
  });
323
+ it('loads images from the image tray and opens the selected image', async () => {
324
+ const user = userEvent.setup();
325
+ fetchTaskforceApi
326
+ .mockResolvedValueOnce({
327
+ ok: true,
328
+ json: async () => ({
329
+ images: [
330
+ {
331
+ assetId: 'asset-image-24',
332
+ imageReferenceLabel: 'I-24',
333
+ path: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fasset-image-24.png',
334
+ displayName: 'Reference image',
335
+ originalFilename: 'reference.png',
336
+ sizeBytes: 2048,
337
+ updatedAt: '2026-03-25T12:00:00.000Z',
338
+ attachmentCount: 0,
339
+ sessionCount: 0,
340
+ },
341
+ ],
342
+ }),
343
+ })
344
+ .mockResolvedValueOnce({
345
+ ok: true,
346
+ json: async () => ({ sessions: [] }),
347
+ })
348
+ .mockResolvedValueOnce({
349
+ ok: true,
350
+ json: async () => ({
351
+ session: buildSessionResponse({
352
+ id: 'session-ref-1',
353
+ taskId: '',
354
+ baseImageAssetId: 'asset-image-24',
355
+ title: 'Reference image review',
356
+ }),
357
+ }),
358
+ });
359
+ function ControlledImageTrayHarness() {
360
+ const [target, setTarget] = React.useState(null);
361
+ const [sessionId, setSessionId] = React.useState(null);
362
+ return (_jsx(AnnotatedAttachmentWorkspaceShell, { workspaceId: "workspace-1", imageTrayOpen: true, requestedTarget: target, requestedSessionId: sessionId, onOpenTarget: (nextTarget, options) => {
363
+ setTarget(nextTarget);
364
+ setSessionId(options?.sessionId ?? null);
365
+ } }));
366
+ }
367
+ render(_jsx(ControlledImageTrayHarness, {}));
368
+ expect(await screen.findByRole('button', { name: /reference image/i })).toBeInTheDocument();
369
+ await user.click(screen.getByRole('button', { name: /reference image/i }));
370
+ await waitFor(() => expect(fetchTaskforceApi).toHaveBeenNthCalledWith(1, '/api/taskforce/annotated-attachments/images?workspaceId=workspace-1', expect.objectContaining({ cache: 'no-store' }), ''));
371
+ await waitFor(() => expect(fetchTaskforceApi).toHaveBeenNthCalledWith(2, '/api/taskforce/annotated-attachments/sessions?workspaceId=workspace-1&imageAssetId=asset-image-24', expect.any(Object), ''));
372
+ expect(await screen.findByAltText('Reference image')).toBeInTheDocument();
373
+ expect(await findSessionCard('Reference image review')).toBeInTheDocument();
374
+ });
323
375
  it('does not revert to the previous image while opening a pasted image in controlled mode', async () => {
324
376
  const user = userEvent.setup();
325
377
  const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
@@ -11,8 +11,8 @@ interface Props {
11
11
  }>;
12
12
  onSearchChange: (q: string) => void;
13
13
  onSelect: (doc: DocMeta) => void;
14
- onRefresh: () => void;
14
+ onCloseTray?: () => void;
15
15
  hasActiveFilters: boolean;
16
16
  }
17
- export declare function DocumentIndex({ documents, selectedDocId, loading, error, searchQuery, taskById, onSearchChange, onSelect, onRefresh, hasActiveFilters, }: Props): import("react/jsx-runtime").JSX.Element;
17
+ export declare function DocumentIndex({ documents, selectedDocId, loading, error, searchQuery, taskById, onSearchChange, onSelect, onCloseTray, hasActiveFilters, }: Props): import("react/jsx-runtime").JSX.Element;
18
18
  export {};
@@ -1,6 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Search, RefreshCw, FileText, ClipboardList, Eye, BookOpen, File } from 'lucide-react';
3
- import styles from '../../Taskforce.module.css';
2
+ import { Search, FileText, ClipboardList, Eye, BookOpen, File, ChevronLeft } from 'lucide-react';
4
3
  import indexStyles from './DocumentIndex.module.css';
5
4
  import { getEffectiveAttachmentState } from './documentWorkspaceModel';
6
5
  import { getReadableDocumentName } from '../../utils/documentNames';
@@ -42,8 +41,8 @@ function handleItemKeyDown(event, onSelect) {
42
41
  event.preventDefault();
43
42
  onSelect();
44
43
  }
45
- export function DocumentIndex({ documents, selectedDocId, loading, error, searchQuery, taskById, onSearchChange, onSelect, onRefresh, hasActiveFilters, }) {
46
- return (_jsxs("div", { className: indexStyles.index, children: [_jsxs("div", { className: indexStyles.header, children: [_jsxs("span", { className: indexStyles.title, children: [_jsx(FileText, { size: 14 }), "Documents"] }), _jsx("button", { className: indexStyles.refreshBtn, onClick: onRefresh, title: "Refresh document list", disabled: loading, children: _jsx(RefreshCw, { size: 13, className: loading ? styles.docSpinning : undefined }) })] }), _jsxs("div", { className: indexStyles.search, children: [_jsx(Search, { size: 12, className: indexStyles.searchIcon }), _jsx("input", { type: "text", placeholder: "Search documents\u2026", value: searchQuery, onChange: e => onSearchChange(e.target.value), className: indexStyles.searchInput })] }), _jsxs("div", { className: `${indexStyles.list} tf-scrollbar`, children: [loading && (_jsx("div", { className: indexStyles.state, children: "Loading documents\u2026" })), !loading && error && (_jsx("div", { className: indexStyles.stateError, children: error })), !loading && !error && documents.length === 0 && (_jsx("div", { className: indexStyles.state, children: hasActiveFilters
44
+ export function DocumentIndex({ documents, selectedDocId, loading, error, searchQuery, taskById, onSearchChange, onSelect, onCloseTray, hasActiveFilters, }) {
45
+ return (_jsxs("div", { className: indexStyles.index, children: [_jsxs("div", { className: indexStyles.header, children: [_jsxs("span", { className: indexStyles.title, children: [_jsx(FileText, { size: 14 }), "Documents"] }), onCloseTray ? (_jsx("button", { type: "button", className: "tf-control-icon", onClick: onCloseTray, title: "Collapse document tray", "aria-label": "Collapse document tray", children: _jsx(ChevronLeft, { size: 16 }) })) : null] }), _jsxs("div", { className: indexStyles.search, children: [_jsx(Search, { size: 12, className: indexStyles.searchIcon }), _jsx("input", { type: "text", placeholder: "Search documents\u2026", value: searchQuery, onChange: e => onSearchChange(e.target.value), className: indexStyles.searchInput })] }), _jsxs("div", { className: `${indexStyles.list} tf-scrollbar`, children: [loading && (_jsx("div", { className: indexStyles.state, children: "Loading documents\u2026" })), !loading && error && (_jsx("div", { className: indexStyles.stateError, children: error })), !loading && !error && documents.length === 0 && (_jsx("div", { className: indexStyles.state, children: hasActiveFilters
47
46
  ? 'No documents match your filters.'
48
47
  : 'No documents found. Documents added as task attachments will appear here.' })), !loading && documents.map(doc => {
49
48
  const task = doc.taskId ? taskById.get(doc.taskId) : null;
@@ -7,11 +7,13 @@ interface Props {
7
7
  tasks?: Array<{
8
8
  id: string;
9
9
  title: string;
10
+ referenceLabel?: string | null;
10
11
  }>;
11
12
  apiBaseUrl?: string;
12
13
  onSaved?: (updatedAt: string) => void;
13
14
  onDeleted?: () => void;
15
+ onBackToTask?: (taskId: string) => void;
14
16
  enableTaskGeneration?: boolean;
15
17
  }
16
- export declare function DocumentViewer({ doc, taskTitle, tasks, apiBaseUrl, onSaved, onDeleted, enableTaskGeneration }: Props): import("react/jsx-runtime").JSX.Element;
18
+ export declare function DocumentViewer({ doc, taskTitle, tasks, apiBaseUrl, onSaved, onDeleted, onBackToTask, enableTaskGeneration }: Props): import("react/jsx-runtime").JSX.Element;
17
19
  export {};