@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.
- package/README.md +0 -2
- package/dist/Taskforce.module.css +317 -27
- package/dist/TaskforceCore.js +1 -0
- package/dist/TaskforceCore.test.js +178 -0
- package/dist/components/activity/EntityActivityTimeline.d.ts +34 -0
- package/dist/components/activity/EntityActivityTimeline.js +81 -0
- package/dist/components/context/ContextAttachmentManager.js +15 -3
- package/dist/components/features/AgentsModule.d.ts +5 -1
- package/dist/components/features/AgentsModule.js +142 -39
- package/dist/components/features/AgentsModule.test.js +260 -11
- package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +3 -1
- package/dist/components/features/AnnotatedAttachmentWorkspace.js +118 -4
- package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +52 -0
- package/dist/components/features/DocumentIndex.d.ts +2 -2
- package/dist/components/features/DocumentIndex.js +3 -4
- package/dist/components/features/DocumentViewer.d.ts +3 -1
- package/dist/components/features/DocumentViewer.js +6 -5
- package/dist/components/features/DocumentWorkspace.d.ts +10 -2
- package/dist/components/features/DocumentWorkspace.js +15 -4
- package/dist/components/features/DocumentWorkspace.test.js +20 -3
- package/dist/components/features/TaskSettings.d.ts +2 -0
- package/dist/components/features/TaskSettings.js +83 -78
- package/dist/components/features/TaskSettings.test.js +171 -82
- package/dist/components/task/TaskActionHeader.js +11 -5
- package/dist/components/task/TaskActionHeader.test.js +10 -0
- package/dist/components/task/TaskCard.js +6 -2
- package/dist/components/task/TaskContextUpload.test.js +3 -3
- package/dist/components/task/TaskForm.js +14 -64
- package/dist/components/task/TaskKanban.js +0 -8
- package/dist/components/task/TaskList.js +5 -1
- package/dist/components/task/TaskList.test.js +4 -0
- package/dist/components/ui/HierarchyActionBadge.d.ts +10 -0
- package/dist/components/ui/HierarchyActionBadge.js +5 -0
- package/dist/components/views/AppShellHeader.d.ts +3 -1
- package/dist/components/views/AppShellHeader.js +27 -11
- package/dist/components/views/AppShellHeader.test.js +18 -1
- package/dist/components/views/PlanComparisonPage.d.ts +20 -15
- package/dist/components/views/PlanComparisonPage.js +221 -82
- package/dist/components/views/PlanComparisonPage.test.js +268 -14
- package/dist/components/views/PlansPage.js +16 -10
- package/dist/components/views/PlansPage.test.js +4 -2
- package/dist/components/views/StandaloneLayout.js +256 -69
- package/dist/components/views/WidgetView.js +10 -8
- package/dist/components/views/panels/PlanningDrawer.d.ts +19 -6
- package/dist/components/views/panels/PlanningDrawer.js +259 -51
- package/dist/components/views/panels/PlanningDrawer.test.js +392 -4
- package/dist/config/envSchema.js +1 -0
- package/dist/core/AiProfileService.d.ts +7 -0
- package/dist/core/AiProfileService.js +460 -35
- package/dist/core/AiProfiles.test.js +648 -2
- package/dist/core/AttachmentLinkService.d.ts +3 -0
- package/dist/core/AttachmentLinkService.js +58 -13
- package/dist/core/GlobalSettingsService.js +30 -0
- package/dist/core/GlobalSettingsService.test.d.ts +1 -0
- package/dist/core/GlobalSettingsService.test.js +45 -0
- package/dist/core/McpTokenService.d.ts +12 -0
- package/dist/core/McpTokenService.js +44 -1
- package/dist/core/PlanEntitlementService.d.ts +20 -0
- package/dist/core/PlanEntitlementService.js +215 -19
- package/dist/core/PlanFeatureCatalog.test.js +55 -0
- package/dist/core/PlanningEntities.test.js +366 -4
- package/dist/core/TaskActivityService.d.ts +28 -1
- package/dist/core/TaskActivityService.js +227 -46
- package/dist/core/TaskActivitySync.test.d.ts +1 -0
- package/dist/core/TaskActivitySync.test.js +90 -0
- package/dist/core/TaskAuditTimeline.test.js +96 -0
- package/dist/core/Taskforce.d.ts +204 -26
- package/dist/core/Taskforce.ids.test.js +58 -0
- package/dist/core/Taskforce.js +781 -71
- package/dist/core/Taskforce.mcpAuth.test.js +109 -0
- package/dist/core/types.d.ts +10 -0
- package/dist/hooks/auth/useTaskforceAuthBootstrap.js +2 -1
- package/dist/hooks/bootstrapTimeouts.d.ts +4 -0
- package/dist/hooks/bootstrapTimeouts.js +8 -0
- package/dist/hooks/bootstrapTimeouts.test.d.ts +1 -0
- package/dist/hooks/bootstrapTimeouts.test.js +12 -0
- package/dist/hooks/sync/orchestratorShared.d.ts +3 -3
- package/dist/hooks/sync/orchestratorShared.js +61 -1
- package/dist/hooks/sync/recovery.js +8 -0
- package/dist/hooks/sync/recovery.test.js +5 -5
- package/dist/hooks/sync/transfers.d.ts +2 -1
- package/dist/hooks/sync/transfers.js +36 -9
- package/dist/hooks/sync/transfers.test.js +534 -2
- package/dist/hooks/useFilterSort.js +13 -13
- package/dist/hooks/useFilterSort.test.js +87 -1
- package/dist/hooks/useSyncOrchestrator.js +50 -5
- package/dist/hooks/useTaskforce.d.ts +85 -50
- package/dist/hooks/useTaskforce.js +44 -14
- package/dist/hooks/useTaskforce.runtime-routing.test.js +94 -0
- package/dist/hooks/useTaskforce.sync-behavior.test.js +99 -0
- package/dist/hooks/useWorkspaceSyncController.js +7 -1
- package/dist/hooks/useWorkspaceSyncController.test.js +48 -0
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +14 -7
- package/dist/mcp/canonicalAssetHelpers.d.ts +16 -4
- package/dist/mcp/canonicalAssetHelpers.js +18 -10
- package/dist/mcp/canonicalAssetHelpers.test.d.ts +1 -0
- package/dist/mcp/canonicalAssetHelpers.test.js +67 -0
- package/dist/mcp/clientIntegrations.d.ts +1 -0
- package/dist/mcp/clientIntegrations.js +25 -0
- package/dist/mcp/clientIntegrations.test.js +14 -3
- package/dist/mcp/clientProfileDefaults.js +3 -3
- package/dist/mcp/collaborationRegistrar.js +7 -7
- package/dist/mcp/documentAssetRegistrar.js +113 -7
- package/dist/mcp/runtime.d.ts +1 -0
- package/dist/mcp/runtime.js +969 -122
- package/dist/mcp/runtime.test.js +1083 -11
- package/dist/mcp/taskAttachmentTypes.d.ts +1 -1
- package/dist/mcp/taskPlanningRegistrar.js +48 -0
- package/dist/mcp/toolRegistry.d.ts +56 -0
- package/dist/mcp/toolRegistry.js +8 -0
- package/dist/mcp/toolRegistry.test.js +4 -0
- package/dist/migrations/taskSchemaMigrations.js +58 -15
- package/dist/server/aiProfileAvatarAssets.d.ts +49 -0
- package/dist/server/aiProfileAvatarAssets.js +319 -0
- package/dist/server/aiProfileAvatarAssets.test.d.ts +1 -0
- package/dist/server/aiProfileAvatarAssets.test.js +213 -0
- package/dist/server/annotatedAttachmentsRoutes.test.js +66 -7
- package/dist/server/index.js +4 -0
- package/dist/server/index.test.js +6 -1
- package/dist/server/routes/admin.js +328 -9
- package/dist/server/routes/annotatedAttachments.js +51 -0
- package/dist/server/routes/auth.d.ts +11 -0
- package/dist/server/routes/auth.js +189 -27
- package/dist/server/routes/authSupport.d.ts +5 -0
- package/dist/server/routes/authSupport.js +13 -1
- package/dist/server/routes/billing.d.ts +1 -0
- package/dist/server/routes/billing.js +116 -63
- package/dist/server/routes/billing.test.js +162 -11
- package/dist/server/routes/documents.js +10 -0
- package/dist/server/routes/shared.js +0 -10
- package/dist/server/routes/sync.integration.test.js +221 -0
- package/dist/server/routes/sync.js +13 -3
- package/dist/server/routes/syncSnapshotRoutes.js +2 -0
- package/dist/server/routes/tasks.js +12 -9
- package/dist/server/routes.js +87 -8
- package/dist/server/routes.test.js +972 -23
- package/dist/shared/aiProfileSurfaceType.d.ts +1 -1
- package/dist/shared/aiProfileSurfaceType.js +10 -8
- package/dist/storage/objectStorage.d.ts +1 -0
- package/dist/storage/objectStorage.js +2 -1
- package/dist/storage/objectStorageClient.d.ts +1 -0
- package/dist/storage/objectStorageClient.js +41 -8
- package/dist/storage/objectStorageClient.test.js +26 -1
- package/dist/storage/objectStorageConfig.js +3 -1
- package/dist/storage/objectStorageConfig.test.js +13 -1
- package/dist/sync/collaborationSyncPayload.js +2 -0
- package/dist/sync/collaborationSyncPayload.test.js +1 -0
- package/dist/sync/collaborationSyncState.d.ts +3 -3
- package/dist/sync/collaborationSyncState.js +19 -12
- package/dist/sync/contentSyncPayload.d.ts +5 -1
- package/dist/sync/contentSyncPayload.js +9 -0
- package/dist/sync/contentSyncPayload.test.js +39 -0
- package/dist/sync/contentSyncState.d.ts +5 -1
- package/dist/sync/contentSyncState.js +231 -37
- package/dist/sync/planningSyncPayload.js +5 -0
- package/dist/sync/planningSyncPayload.test.js +24 -0
- package/dist/sync/syncApplyHandlers.d.ts +4 -4
- package/dist/sync/syncApplyHandlers.js +89 -31
- package/dist/sync/syncApplyHandlers.test.js +184 -4
- package/dist/sync/syncResourceAdapters.d.ts +2 -0
- package/dist/sync/syncResourceAdapters.js +2 -0
- package/dist/sync/syncService.d.ts +1 -0
- package/dist/sync/syncService.js +13 -1
- package/dist/sync/syncService.test.js +39 -0
- package/dist/sync/workspacePullFeed.d.ts +4 -0
- package/dist/sync/workspacePullFeed.js +129 -70
- package/dist/sync/workspacePullFeed.test.js +49 -0
- package/dist/sync/workspaceSyncModel.d.ts +28 -6
- package/dist/sync/workspaceSyncModel.js +159 -45
- package/dist/sync/workspaceSyncModel.test.js +269 -3
- package/dist/types.d.ts +33 -2
- package/dist/ui/agent-logos/ChatGPT.png +0 -0
- package/dist/ui/agent-logos/Gemini CLI.jpeg +0 -0
- package/dist/ui/agent-logos/antigravity.jpeg +0 -0
- package/dist/ui/agent-logos/claude-code.jpeg +0 -0
- package/dist/ui/agent-logos/codex.jpeg +0 -0
- package/dist/ui/agent-logos/cursor.png +0 -0
- package/dist/ui/agent-logos/openclaw.jpeg +0 -0
- package/dist/ui/agent-logos/windsurf.png +0 -0
- package/dist/ui/assets/AgentsModule-BS4Pi_nC.js +1 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-CCckzWYZ.js +3 -0
- package/dist/ui/assets/ContextAttachmentManager-CfG_ER7C.js +3 -0
- package/dist/ui/assets/DocumentWorkspace-BVaWPA25.js +252 -0
- package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +1 -0
- package/dist/ui/assets/EntityActivityTimeline-DSj0ThaK.js +1 -0
- package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
- package/dist/ui/assets/{InitiativesModule-9E6ParrY.js → InitiativesModule-AxMkWtxH.js} +1 -1
- package/dist/ui/assets/PlansPage-B_z-D6Nh.css +1 -0
- package/dist/ui/assets/PlansPage-Ndt7mYfo.js +1 -0
- package/dist/ui/assets/TaskContextUpload-Dw4rTF4i.js +1 -0
- package/dist/ui/assets/TaskSettings-f2ga42_V.js +9 -0
- package/dist/ui/assets/{WorkflowsModule-BA7jcEpH.js → WorkflowsModule-E4UL3mov.js} +4 -4
- package/dist/ui/assets/documentReferences-BOUcJm6-.js +1 -0
- package/dist/ui/assets/index-BvAbqx5Q.css +1 -0
- package/dist/ui/assets/index-MAHKvFqp.js +4 -0
- package/dist/ui/assets/{vendor-icons-DuEd_65c.js → vendor-icons-B32hGuF2.js} +1 -1
- package/dist/ui/index.html +3 -3
- package/dist/ui/og-image.png +0 -0
- package/dist/ui/product/agent manager.png +0 -0
- package/dist/ui/product/agent_manager_02.png +0 -0
- package/dist/utils/aiProfileEvents.d.ts +7 -0
- package/dist/utils/aiProfileEvents.js +6 -0
- package/dist/utils/assignees.d.ts +6 -0
- package/dist/utils/assignees.js +8 -1
- package/dist/utils/assignees.test.d.ts +1 -0
- package/dist/utils/assignees.test.js +25 -0
- package/dist/utils/avatarDisplay.d.ts +1 -0
- package/dist/utils/avatarDisplay.js +15 -0
- package/dist/utils/contextFiles.d.ts +3 -1
- package/dist/utils/contextFiles.js +2 -1
- package/dist/utils/taskActivity.d.ts +7 -1
- package/dist/utils/taskActivity.js +63 -1
- package/dist/utils/taskActivity.test.js +23 -1
- package/dist/utils/taskSearch.d.ts +22 -0
- package/dist/utils/taskSearch.js +87 -0
- package/dist/utils/taskSearch.test.d.ts +1 -0
- package/dist/utils/taskSearch.test.js +69 -0
- package/dist/utils/taskUtils.js +10 -3
- package/dist/utils/taskUtils.test.d.ts +1 -0
- package/dist/utils/taskUtils.test.js +19 -0
- package/package.json +1 -1
- package/dist/ui/assets/AgentsModule-DrLawwNl.js +0 -1
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +0 -1
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-Z9_whf7F.js +0 -3
- package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +0 -252
- package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +0 -1
- package/dist/ui/assets/PlansPage-CvfnMiWq.css +0 -1
- package/dist/ui/assets/PlansPage-DRXscYxH.js +0 -1
- package/dist/ui/assets/TaskSettings-DZX4jk7e.js +0 -9
- package/dist/ui/assets/index-9eT8e0Lu.css +0 -1
- 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.
|
|
94
|
+
expect(screen.getAllByText('Unclassified').length).toBeGreaterThan(0);
|
|
94
95
|
expect(screen.getAllByText('Codex')).toHaveLength(1);
|
|
95
|
-
expect(screen.getByText(/
|
|
96
|
-
expect(screen.
|
|
97
|
-
expect(screen.
|
|
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.
|
|
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('
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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("
|
|
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
|
-
|
|
14
|
+
onCloseTray?: () => void;
|
|
15
15
|
hasActiveFilters: boolean;
|
|
16
16
|
}
|
|
17
|
-
export declare function DocumentIndex({ documents, selectedDocId, loading, error, searchQuery, taskById, onSearchChange, onSelect,
|
|
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,
|
|
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,
|
|
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:
|
|
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 {};
|