authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 21:33:39-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 21:33:39-08:00
log4b3825e500594250cc9aeec9f6182a38059085f1
tree8f1a659b010e44858998fad7c8ed2bbd75f051ee
parente8dfc42f444600237beb2fa7772c93b618307e32
parentd48b42c0174f7ed26de8fdd1b0f4f75c82c71fe7

Merge remote-tracking branch 'origin/main' into codething/10276f55


19 files changed, 2346 insertions(+), 416 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+1-2
......@@ -215,14 +215,13 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
215215 Layer.provide(providerSessionDirectoryLayer),
216216 Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)),
217217 );
218 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));
219218
220219 const runtimeServicesLayer = Layer.mergeAll(
221220 orchestrationLayer,
222221 OrchestrationProjectionSnapshotQueryLive,
223222 ProjectionCheckpointRepositoryLive,
224223 ProjectionPendingApprovalRepositoryLive,
225 checkpointStoreLayer,
224 CheckpointStoreLive,
226225 providerLayer,
227226 );
228227 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+6-3
......@@ -233,11 +233,12 @@ describe("CheckpointReactor", () => {
233233 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
234234 Layer.provide(SqlitePersistenceMemory),
235235 );
236 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));
236
237237 const layer = CheckpointReactorLive.pipe(
238238 Layer.provideMerge(orchestrationLayer),
239239 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
240 Layer.provideMerge(checkpointStoreLayer),
240 Layer.provideMerge(CheckpointStoreLive),
241 Layer.provideMerge(NodeServices.layer),
241242 );
242243
243244 runtime = ManagedRuntime.make(layer);
......@@ -437,7 +438,9 @@ describe("CheckpointReactor", () => {
437438
438439 await Effect.runPromise(Effect.sleep("40 millis"));
439440 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
440 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
441 const midThread = midReadModel.threads.find(
442 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
443 );
441444 expect(midThread?.checkpoints).toHaveLength(0);
442445
443446 harness.provider.emit({
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+996
......@@ -32,6 +32,7 @@ import {
3232} from "./ProjectionPipeline.ts";
3333import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
3434import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
35import { ServerConfig, type ServerConfigShape } from "../../config.ts";
3536
3637const projectionLayer = it.layer(
3738 OrchestrationProjectionPipelineLive.pipe(
......@@ -158,6 +159,1001 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
158159 }),
159160 );
160161
162 it.effect("materializes message image attachments into stateDir and stores URL references", () =>
163 Effect.gen(function* () {
164 const projectionPipeline = yield* OrchestrationProjectionPipeline;
165 const eventStore = yield* OrchestrationEventStore;
166 const sql = yield* SqlClient.SqlClient;
167 const now = new Date().toISOString();
168 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"));
169
170 const serverConfig = {
171 mode: "web",
172 port: 0,
173 host: undefined,
174 cwd: "/tmp/project-attachments",
175 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
176 stateDir,
177 staticDir: undefined,
178 devUrl: undefined,
179 noBrowser: true,
180 authToken: undefined,
181 autoBootstrapProjectFromCwd: false,
182 logWebSocketEvents: false,
183 } satisfies ServerConfigShape;
184
185 yield* eventStore.append({
186 type: "thread.message-sent",
187 eventId: EventId.makeUnsafe("evt-attachments"),
188 aggregateKind: "thread",
189 aggregateId: ThreadId.makeUnsafe("thread-attachments"),
190 occurredAt: now,
191 commandId: CommandId.makeUnsafe("cmd-attachments"),
192 causationEventId: null,
193 correlationId: CommandId.makeUnsafe("cmd-attachments"),
194 metadata: {},
195 payload: {
196 threadId: ThreadId.makeUnsafe("thread-attachments"),
197 messageId: MessageId.makeUnsafe("message-attachments"),
198 role: "user",
199 text: "Inspect this",
200 attachments: [
201 {
202 type: "image",
203 name: "example.png",
204 mimeType: "image/png",
205 sizeBytes: 5,
206 dataUrl: "data:image/png;base64,SGVsbG8=",
207 },
208 ],
209 turnId: null,
210 streaming: false,
211 createdAt: now,
212 updatedAt: now,
213 },
214 });
215
216 yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig));
217
218 const rows = yield* sql<{
219 readonly attachmentsJson: string | null;
220 }>`
221 SELECT
222 attachments_json AS "attachmentsJson"
223 FROM projection_thread_messages
224 WHERE message_id = 'message-attachments'
225 `;
226 assert.equal(rows.length, 1);
227 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
228 {
229 type: "image",
230 name: "example.png",
231 mimeType: "image/png",
232 sizeBytes: 5,
233 dataUrl: "/attachments/thread-attachments/message-attachments-0.png",
234 },
235 ]);
236
237 const attachmentPath = path.join(
238 stateDir,
239 "attachments",
240 "thread-attachments",
241 "message-attachments-0.png",
242 );
243 assert.equal(fs.existsSync(attachmentPath), true);
244 assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("SGVsbG8=", "base64"));
245 fs.rmSync(stateDir, { recursive: true, force: true });
246 }),
247 );
248
249 it.effect("materializes only image data URLs and uses a safe extension whitelist", () =>
250 Effect.gen(function* () {
251 const projectionPipeline = yield* OrchestrationProjectionPipeline;
252 const eventStore = yield* OrchestrationEventStore;
253 const sql = yield* SqlClient.SqlClient;
254 const now = new Date().toISOString();
255 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"));
256
257 const serverConfig = {
258 mode: "web",
259 port: 0,
260 host: undefined,
261 cwd: "/tmp/project-attachments",
262 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
263 stateDir,
264 staticDir: undefined,
265 devUrl: undefined,
266 noBrowser: true,
267 authToken: undefined,
268 autoBootstrapProjectFromCwd: false,
269 logWebSocketEvents: false,
270 } satisfies ServerConfigShape;
271
272 yield* eventStore.append({
273 type: "thread.message-sent",
274 eventId: EventId.makeUnsafe("evt-attachments-safe"),
275 aggregateKind: "thread",
276 aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"),
277 occurredAt: now,
278 commandId: CommandId.makeUnsafe("cmd-attachments-safe"),
279 causationEventId: null,
280 correlationId: CommandId.makeUnsafe("cmd-attachments-safe"),
281 metadata: {},
282 payload: {
283 threadId: ThreadId.makeUnsafe("thread-attachments-safe"),
284 messageId: MessageId.makeUnsafe("message-attachments-safe"),
285 role: "user",
286 text: "Inspect this",
287 attachments: [
288 {
289 type: "image",
290 name: "untrusted.exe",
291 mimeType: "image/x-unknown",
292 sizeBytes: 5,
293 dataUrl: "data:image/x-unknown;base64,SGVsbG8=",
294 },
295 {
296 type: "image",
297 name: "not-image.png",
298 mimeType: "image/png",
299 sizeBytes: 5,
300 dataUrl: "data:text/plain;base64,SGVsbG8=",
301 },
302 ],
303 turnId: null,
304 streaming: false,
305 createdAt: now,
306 updatedAt: now,
307 },
308 });
309
310 yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig));
311
312 const rows = yield* sql<{
313 readonly attachmentsJson: string | null;
314 }>`
315 SELECT
316 attachments_json AS "attachmentsJson"
317 FROM projection_thread_messages
318 WHERE message_id = 'message-attachments-safe'
319 `;
320 assert.equal(rows.length, 1);
321 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
322 {
323 type: "image",
324 name: "untrusted.exe",
325 mimeType: "image/x-unknown",
326 sizeBytes: 5,
327 dataUrl: "/attachments/thread-attachments-safe/message-attachments-safe-0.bin",
328 },
329 {
330 type: "image",
331 name: "not-image.png",
332 mimeType: "image/png",
333 sizeBytes: 5,
334 dataUrl: "data:text/plain;base64,SGVsbG8=",
335 },
336 ]);
337
338 const firstAttachmentPath = path.join(
339 stateDir,
340 "attachments",
341 "thread-attachments-safe",
342 "message-attachments-safe-0.bin",
343 );
344 assert.equal(fs.existsSync(firstAttachmentPath), true);
345 assert.deepEqual(fs.readFileSync(firstAttachmentPath), Buffer.from("SGVsbG8=", "base64"));
346
347 const secondAttachmentPath = path.join(
348 stateDir,
349 "attachments",
350 "thread-attachments-safe",
351 "message-attachments-safe-1.png",
352 );
353 assert.equal(fs.existsSync(secondAttachmentPath), false);
354 fs.rmSync(stateDir, { recursive: true, force: true });
355 }),
356 );
357
358 it.effect(
359 "passes explicit empty attachment arrays through the projection pipeline to clear attachments",
360 () =>
361 Effect.gen(function* () {
362 const projectionPipeline = yield* OrchestrationProjectionPipeline;
363 const eventStore = yield* OrchestrationEventStore;
364 const sql = yield* SqlClient.SqlClient;
365 const now = new Date().toISOString();
366 const later = new Date(Date.now() + 1_000).toISOString();
367
368 yield* eventStore.append({
369 type: "project.created",
370 eventId: EventId.makeUnsafe("evt-clear-attachments-1"),
371 aggregateKind: "project",
372 aggregateId: ProjectId.makeUnsafe("project-clear-attachments"),
373 occurredAt: now,
374 commandId: CommandId.makeUnsafe("cmd-clear-attachments-1"),
375 causationEventId: null,
376 correlationId: CommandId.makeUnsafe("cmd-clear-attachments-1"),
377 metadata: {},
378 payload: {
379 projectId: ProjectId.makeUnsafe("project-clear-attachments"),
380 title: "Project Clear Attachments",
381 workspaceRoot: "/tmp/project-clear-attachments",
382 defaultModel: null,
383 scripts: [],
384 createdAt: now,
385 updatedAt: now,
386 },
387 });
388
389 yield* eventStore.append({
390 type: "thread.created",
391 eventId: EventId.makeUnsafe("evt-clear-attachments-2"),
392 aggregateKind: "thread",
393 aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"),
394 occurredAt: now,
395 commandId: CommandId.makeUnsafe("cmd-clear-attachments-2"),
396 causationEventId: null,
397 correlationId: CommandId.makeUnsafe("cmd-clear-attachments-2"),
398 metadata: {},
399 payload: {
400 threadId: ThreadId.makeUnsafe("thread-clear-attachments"),
401 projectId: ProjectId.makeUnsafe("project-clear-attachments"),
402 title: "Thread Clear Attachments",
403 model: "gpt-5-codex",
404 branch: null,
405 worktreePath: null,
406 createdAt: now,
407 updatedAt: now,
408 },
409 });
410
411 yield* eventStore.append({
412 type: "thread.message-sent",
413 eventId: EventId.makeUnsafe("evt-clear-attachments-3"),
414 aggregateKind: "thread",
415 aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"),
416 occurredAt: now,
417 commandId: CommandId.makeUnsafe("cmd-clear-attachments-3"),
418 causationEventId: null,
419 correlationId: CommandId.makeUnsafe("cmd-clear-attachments-3"),
420 metadata: {},
421 payload: {
422 threadId: ThreadId.makeUnsafe("thread-clear-attachments"),
423 messageId: MessageId.makeUnsafe("message-clear-attachments"),
424 role: "user",
425 text: "Has attachments",
426 attachments: [
427 {
428 type: "image",
429 name: "clear.png",
430 mimeType: "image/png",
431 sizeBytes: 5,
432 dataUrl: "data:image/png;base64,SGVsbG8=",
433 },
434 ],
435 turnId: null,
436 streaming: false,
437 createdAt: now,
438 updatedAt: now,
439 },
440 });
441
442 yield* eventStore.append({
443 type: "thread.message-sent",
444 eventId: EventId.makeUnsafe("evt-clear-attachments-4"),
445 aggregateKind: "thread",
446 aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"),
447 occurredAt: later,
448 commandId: CommandId.makeUnsafe("cmd-clear-attachments-4"),
449 causationEventId: null,
450 correlationId: CommandId.makeUnsafe("cmd-clear-attachments-4"),
451 metadata: {},
452 payload: {
453 threadId: ThreadId.makeUnsafe("thread-clear-attachments"),
454 messageId: MessageId.makeUnsafe("message-clear-attachments"),
455 role: "user",
456 text: "",
457 attachments: [],
458 turnId: null,
459 streaming: false,
460 createdAt: now,
461 updatedAt: later,
462 },
463 });
464
465 yield* projectionPipeline.bootstrap;
466
467 const rows = yield* sql<{
468 readonly attachmentsJson: string | null;
469 }>`
470 SELECT
471 attachments_json AS "attachmentsJson"
472 FROM projection_thread_messages
473 WHERE message_id = 'message-clear-attachments'
474 `;
475 assert.equal(rows.length, 1);
476 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), []);
477 }),
478 );
479
480 it.effect(
481 "overwrites attachment file bytes when a message updates the same attachment index",
482 () =>
483 Effect.gen(function* () {
484 const projectionPipeline = yield* OrchestrationProjectionPipeline;
485 const eventStore = yield* OrchestrationEventStore;
486 const stateDir = fs.mkdtempSync(
487 path.join(os.tmpdir(), "t3-projection-attachments-overwrite-"),
488 );
489 const now = new Date().toISOString();
490 const later = new Date(Date.now() + 1_000).toISOString();
491
492 const serverConfig = {
493 mode: "web",
494 port: 0,
495 host: undefined,
496 cwd: "/tmp/project-attachments",
497 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
498 stateDir,
499 staticDir: undefined,
500 devUrl: undefined,
501 noBrowser: true,
502 authToken: undefined,
503 autoBootstrapProjectFromCwd: false,
504 logWebSocketEvents: false,
505 } satisfies ServerConfigShape;
506
507 yield* eventStore.append({
508 type: "project.created",
509 eventId: EventId.makeUnsafe("evt-overwrite-1"),
510 aggregateKind: "project",
511 aggregateId: ProjectId.makeUnsafe("project-overwrite"),
512 occurredAt: now,
513 commandId: CommandId.makeUnsafe("cmd-overwrite-1"),
514 causationEventId: null,
515 correlationId: CommandId.makeUnsafe("cmd-overwrite-1"),
516 metadata: {},
517 payload: {
518 projectId: ProjectId.makeUnsafe("project-overwrite"),
519 title: "Project Overwrite",
520 workspaceRoot: "/tmp/project-overwrite",
521 defaultModel: null,
522 scripts: [],
523 createdAt: now,
524 updatedAt: now,
525 },
526 });
527
528 yield* eventStore.append({
529 type: "thread.created",
530 eventId: EventId.makeUnsafe("evt-overwrite-2"),
531 aggregateKind: "thread",
532 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
533 occurredAt: now,
534 commandId: CommandId.makeUnsafe("cmd-overwrite-2"),
535 causationEventId: null,
536 correlationId: CommandId.makeUnsafe("cmd-overwrite-2"),
537 metadata: {},
538 payload: {
539 threadId: ThreadId.makeUnsafe("thread-overwrite"),
540 projectId: ProjectId.makeUnsafe("project-overwrite"),
541 title: "Thread Overwrite",
542 model: "gpt-5-codex",
543 branch: null,
544 worktreePath: null,
545 createdAt: now,
546 updatedAt: now,
547 },
548 });
549
550 yield* eventStore.append({
551 type: "thread.message-sent",
552 eventId: EventId.makeUnsafe("evt-overwrite-3"),
553 aggregateKind: "thread",
554 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
555 occurredAt: now,
556 commandId: CommandId.makeUnsafe("cmd-overwrite-3"),
557 causationEventId: null,
558 correlationId: CommandId.makeUnsafe("cmd-overwrite-3"),
559 metadata: {},
560 payload: {
561 threadId: ThreadId.makeUnsafe("thread-overwrite"),
562 messageId: MessageId.makeUnsafe("message-overwrite"),
563 role: "user",
564 text: "first image",
565 attachments: [
566 {
567 type: "image",
568 name: "file.png",
569 mimeType: "image/png",
570 sizeBytes: 5,
571 dataUrl: "data:image/png;base64,SGVsbG8=",
572 },
573 ],
574 turnId: null,
575 streaming: false,
576 createdAt: now,
577 updatedAt: now,
578 },
579 });
580
581 yield* eventStore.append({
582 type: "thread.message-sent",
583 eventId: EventId.makeUnsafe("evt-overwrite-4"),
584 aggregateKind: "thread",
585 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
586 occurredAt: later,
587 commandId: CommandId.makeUnsafe("cmd-overwrite-4"),
588 causationEventId: null,
589 correlationId: CommandId.makeUnsafe("cmd-overwrite-4"),
590 metadata: {},
591 payload: {
592 threadId: ThreadId.makeUnsafe("thread-overwrite"),
593 messageId: MessageId.makeUnsafe("message-overwrite"),
594 role: "user",
595 text: "",
596 attachments: [
597 {
598 type: "image",
599 name: "file.png",
600 mimeType: "image/png",
601 sizeBytes: 5,
602 dataUrl: "data:image/png;base64,V29ybGQ=",
603 },
604 ],
605 turnId: null,
606 streaming: false,
607 createdAt: now,
608 updatedAt: later,
609 },
610 });
611
612 yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig));
613
614 const attachmentPath = path.join(
615 stateDir,
616 "attachments",
617 "thread-overwrite",
618 "message-overwrite-0.png",
619 );
620 assert.equal(fs.existsSync(attachmentPath), true);
621 assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("V29ybGQ=", "base64"));
622 fs.rmSync(stateDir, { recursive: true, force: true });
623 }),
624 );
625
626 it.effect("does not persist attachment files when projector transaction rolls back", () =>
627 Effect.gen(function* () {
628 const projectionPipeline = yield* OrchestrationProjectionPipeline;
629 const eventStore = yield* OrchestrationEventStore;
630 const sql = yield* SqlClient.SqlClient;
631 const stateDir = fs.mkdtempSync(
632 path.join(os.tmpdir(), "t3-projection-attachments-rollback-"),
633 );
634 const now = new Date().toISOString();
635
636 const serverConfig = {
637 mode: "web",
638 port: 0,
639 host: undefined,
640 cwd: "/tmp/project-attachments",
641 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
642 stateDir,
643 staticDir: undefined,
644 devUrl: undefined,
645 noBrowser: true,
646 authToken: undefined,
647 autoBootstrapProjectFromCwd: false,
648 logWebSocketEvents: false,
649 } satisfies ServerConfigShape;
650
651 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
652 eventStore.append(event).pipe(
653 Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)),
654 Effect.provideService(ServerConfig, serverConfig),
655 );
656
657 yield* appendAndProject({
658 type: "project.created",
659 eventId: EventId.makeUnsafe("evt-rollback-1"),
660 aggregateKind: "project",
661 aggregateId: ProjectId.makeUnsafe("project-rollback"),
662 occurredAt: now,
663 commandId: CommandId.makeUnsafe("cmd-rollback-1"),
664 causationEventId: null,
665 correlationId: CorrelationId.makeUnsafe("cmd-rollback-1"),
666 metadata: {},
667 payload: {
668 projectId: ProjectId.makeUnsafe("project-rollback"),
669 title: "Project Rollback",
670 workspaceRoot: "/tmp/project-rollback",
671 defaultModel: null,
672 scripts: [],
673 createdAt: now,
674 updatedAt: now,
675 },
676 });
677
678 yield* appendAndProject({
679 type: "thread.created",
680 eventId: EventId.makeUnsafe("evt-rollback-2"),
681 aggregateKind: "thread",
682 aggregateId: ThreadId.makeUnsafe("thread-rollback"),
683 occurredAt: now,
684 commandId: CommandId.makeUnsafe("cmd-rollback-2"),
685 causationEventId: null,
686 correlationId: CorrelationId.makeUnsafe("cmd-rollback-2"),
687 metadata: {},
688 payload: {
689 threadId: ThreadId.makeUnsafe("thread-rollback"),
690 projectId: ProjectId.makeUnsafe("project-rollback"),
691 title: "Thread Rollback",
692 model: "gpt-5-codex",
693 branch: null,
694 worktreePath: null,
695 createdAt: now,
696 updatedAt: now,
697 },
698 });
699
700 yield* sql`
701 CREATE TRIGGER fail_thread_messages_projection_state_update
702 BEFORE UPDATE ON projection_state
703 WHEN NEW.projector = 'projection.thread-messages'
704 BEGIN
705 SELECT RAISE(ABORT, 'forced-projection-state-failure');
706 END;
707 `;
708
709 const result = yield* Effect.result(
710 appendAndProject({
711 type: "thread.message-sent",
712 eventId: EventId.makeUnsafe("evt-rollback-3"),
713 aggregateKind: "thread",
714 aggregateId: ThreadId.makeUnsafe("thread-rollback"),
715 occurredAt: now,
716 commandId: CommandId.makeUnsafe("cmd-rollback-3"),
717 causationEventId: null,
718 correlationId: CorrelationId.makeUnsafe("cmd-rollback-3"),
719 metadata: {},
720 payload: {
721 threadId: ThreadId.makeUnsafe("thread-rollback"),
722 messageId: MessageId.makeUnsafe("message-rollback"),
723 role: "user",
724 text: "Rollback me",
725 attachments: [
726 {
727 type: "image",
728 name: "rollback.png",
729 mimeType: "image/png",
730 sizeBytes: 5,
731 dataUrl: "data:image/png;base64,SGVsbG8=",
732 },
733 ],
734 turnId: null,
735 streaming: false,
736 createdAt: now,
737 updatedAt: now,
738 },
739 }),
740 );
741 assert.equal(result._tag, "Failure");
742
743 const rows = yield* sql<{
744 readonly count: number;
745 }>`
746 SELECT COUNT(*) AS "count"
747 FROM projection_thread_messages
748 WHERE message_id = 'message-rollback'
749 `;
750 assert.equal(rows[0]?.count ?? 0, 0);
751
752 const attachmentPath = path.join(
753 stateDir,
754 "attachments",
755 "thread-rollback",
756 "message-rollback-0.png",
757 );
758 assert.equal(fs.existsSync(attachmentPath), false);
759 yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`;
760 fs.rmSync(stateDir, { recursive: true, force: true });
761 }),
762 );
763
764 it.effect("removes unreferenced attachment files when a thread is reverted", () =>
765 Effect.gen(function* () {
766 const projectionPipeline = yield* OrchestrationProjectionPipeline;
767 const eventStore = yield* OrchestrationEventStore;
768 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-revert-"));
769 const now = new Date().toISOString();
770
771 const serverConfig = {
772 mode: "web",
773 port: 0,
774 host: undefined,
775 cwd: "/tmp/project-attachments",
776 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
777 stateDir,
778 staticDir: undefined,
779 devUrl: undefined,
780 noBrowser: true,
781 authToken: undefined,
782 autoBootstrapProjectFromCwd: false,
783 logWebSocketEvents: false,
784 } satisfies ServerConfigShape;
785
786 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
787 eventStore.append(event).pipe(
788 Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)),
789 Effect.provideService(ServerConfig, serverConfig),
790 );
791
792 yield* appendAndProject({
793 type: "project.created",
794 eventId: EventId.makeUnsafe("evt-revert-files-1"),
795 aggregateKind: "project",
796 aggregateId: ProjectId.makeUnsafe("project-revert-files"),
797 occurredAt: now,
798 commandId: CommandId.makeUnsafe("cmd-revert-files-1"),
799 causationEventId: null,
800 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"),
801 metadata: {},
802 payload: {
803 projectId: ProjectId.makeUnsafe("project-revert-files"),
804 title: "Project Revert Files",
805 workspaceRoot: "/tmp/project-revert-files",
806 defaultModel: null,
807 scripts: [],
808 createdAt: now,
809 updatedAt: now,
810 },
811 });
812
813 yield* appendAndProject({
814 type: "thread.created",
815 eventId: EventId.makeUnsafe("evt-revert-files-2"),
816 aggregateKind: "thread",
817 aggregateId: ThreadId.makeUnsafe("thread-revert-files"),
818 occurredAt: now,
819 commandId: CommandId.makeUnsafe("cmd-revert-files-2"),
820 causationEventId: null,
821 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"),
822 metadata: {},
823 payload: {
824 threadId: ThreadId.makeUnsafe("thread-revert-files"),
825 projectId: ProjectId.makeUnsafe("project-revert-files"),
826 title: "Thread Revert Files",
827 model: "gpt-5-codex",
828 branch: null,
829 worktreePath: null,
830 createdAt: now,
831 updatedAt: now,
832 },
833 });
834
835 yield* appendAndProject({
836 type: "thread.turn-diff-completed",
837 eventId: EventId.makeUnsafe("evt-revert-files-3"),
838 aggregateKind: "thread",
839 aggregateId: ThreadId.makeUnsafe("thread-revert-files"),
840 occurredAt: now,
841 commandId: CommandId.makeUnsafe("cmd-revert-files-3"),
842 causationEventId: null,
843 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"),
844 metadata: {},
845 payload: {
846 threadId: ThreadId.makeUnsafe("thread-revert-files"),
847 turnId: TurnId.makeUnsafe("turn-keep"),
848 checkpointTurnCount: 1,
849 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/1"),
850 status: "ready",
851 files: [],
852 assistantMessageId: MessageId.makeUnsafe("message-keep"),
853 completedAt: now,
854 },
855 });
856
857 yield* appendAndProject({
858 type: "thread.message-sent",
859 eventId: EventId.makeUnsafe("evt-revert-files-4"),
860 aggregateKind: "thread",
861 aggregateId: ThreadId.makeUnsafe("thread-revert-files"),
862 occurredAt: now,
863 commandId: CommandId.makeUnsafe("cmd-revert-files-4"),
864 causationEventId: null,
865 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"),
866 metadata: {},
867 payload: {
868 threadId: ThreadId.makeUnsafe("thread-revert-files"),
869 messageId: MessageId.makeUnsafe("message-keep"),
870 role: "assistant",
871 text: "Keep",
872 attachments: [
873 {
874 type: "image",
875 name: "keep.png",
876 mimeType: "image/png",
877 sizeBytes: 5,
878 dataUrl: "data:image/png;base64,SGVsbG8=",
879 },
880 ],
881 turnId: TurnId.makeUnsafe("turn-keep"),
882 streaming: false,
883 createdAt: now,
884 updatedAt: now,
885 },
886 });
887
888 yield* appendAndProject({
889 type: "thread.turn-diff-completed",
890 eventId: EventId.makeUnsafe("evt-revert-files-5"),
891 aggregateKind: "thread",
892 aggregateId: ThreadId.makeUnsafe("thread-revert-files"),
893 occurredAt: now,
894 commandId: CommandId.makeUnsafe("cmd-revert-files-5"),
895 causationEventId: null,
896 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"),
897 metadata: {},
898 payload: {
899 threadId: ThreadId.makeUnsafe("thread-revert-files"),
900 turnId: TurnId.makeUnsafe("turn-remove"),
901 checkpointTurnCount: 2,
902 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/2"),
903 status: "ready",
904 files: [],
905 assistantMessageId: MessageId.makeUnsafe("message-remove"),
906 completedAt: now,
907 },
908 });
909
910 yield* appendAndProject({
911 type: "thread.message-sent",
912 eventId: EventId.makeUnsafe("evt-revert-files-6"),
913 aggregateKind: "thread",
914 aggregateId: ThreadId.makeUnsafe("thread-revert-files"),
915 occurredAt: now,
916 commandId: CommandId.makeUnsafe("cmd-revert-files-6"),
917 causationEventId: null,
918 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"),
919 metadata: {},
920 payload: {
921 threadId: ThreadId.makeUnsafe("thread-revert-files"),
922 messageId: MessageId.makeUnsafe("message-remove"),
923 role: "assistant",
924 text: "Remove",
925 attachments: [
926 {
927 type: "image",
928 name: "remove.png",
929 mimeType: "image/png",
930 sizeBytes: 5,
931 dataUrl: "data:image/png;base64,V29ybGQ=",
932 },
933 ],
934 turnId: TurnId.makeUnsafe("turn-remove"),
935 streaming: false,
936 createdAt: now,
937 updatedAt: now,
938 },
939 });
940
941 const keepPath = path.join(
942 stateDir,
943 "attachments",
944 "thread-revert-files",
945 "message-keep-0.png",
946 );
947 const removePath = path.join(
948 stateDir,
949 "attachments",
950 "thread-revert-files",
951 "message-remove-0.png",
952 );
953 assert.equal(fs.existsSync(keepPath), true);
954 assert.equal(fs.existsSync(removePath), true);
955
956 yield* appendAndProject({
957 type: "thread.reverted",
958 eventId: EventId.makeUnsafe("evt-revert-files-7"),
959 aggregateKind: "thread",
960 aggregateId: ThreadId.makeUnsafe("thread-revert-files"),
961 occurredAt: now,
962 commandId: CommandId.makeUnsafe("cmd-revert-files-7"),
963 causationEventId: null,
964 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"),
965 metadata: {},
966 payload: {
967 threadId: ThreadId.makeUnsafe("thread-revert-files"),
968 turnCount: 1,
969 },
970 });
971
972 assert.equal(fs.existsSync(keepPath), true);
973 assert.equal(fs.existsSync(removePath), false);
974 fs.rmSync(stateDir, { recursive: true, force: true });
975 }),
976 );
977
978 it.effect("removes thread attachment directory when thread is deleted", () =>
979 Effect.gen(function* () {
980 const projectionPipeline = yield* OrchestrationProjectionPipeline;
981 const eventStore = yield* OrchestrationEventStore;
982 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-delete-"));
983 const now = new Date().toISOString();
984
985 const serverConfig = {
986 mode: "web",
987 port: 0,
988 host: undefined,
989 cwd: "/tmp/project-attachments",
990 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
991 stateDir,
992 staticDir: undefined,
993 devUrl: undefined,
994 noBrowser: true,
995 authToken: undefined,
996 autoBootstrapProjectFromCwd: false,
997 logWebSocketEvents: false,
998 } satisfies ServerConfigShape;
999
1000 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
1001 eventStore.append(event).pipe(
1002 Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)),
1003 Effect.provideService(ServerConfig, serverConfig),
1004 );
1005
1006 yield* appendAndProject({
1007 type: "project.created",
1008 eventId: EventId.makeUnsafe("evt-delete-files-1"),
1009 aggregateKind: "project",
1010 aggregateId: ProjectId.makeUnsafe("project-delete-files"),
1011 occurredAt: now,
1012 commandId: CommandId.makeUnsafe("cmd-delete-files-1"),
1013 causationEventId: null,
1014 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"),
1015 metadata: {},
1016 payload: {
1017 projectId: ProjectId.makeUnsafe("project-delete-files"),
1018 title: "Project Delete Files",
1019 workspaceRoot: "/tmp/project-delete-files",
1020 defaultModel: null,
1021 scripts: [],
1022 createdAt: now,
1023 updatedAt: now,
1024 },
1025 });
1026
1027 yield* appendAndProject({
1028 type: "thread.created",
1029 eventId: EventId.makeUnsafe("evt-delete-files-2"),
1030 aggregateKind: "thread",
1031 aggregateId: ThreadId.makeUnsafe("thread-delete-files"),
1032 occurredAt: now,
1033 commandId: CommandId.makeUnsafe("cmd-delete-files-2"),
1034 causationEventId: null,
1035 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"),
1036 metadata: {},
1037 payload: {
1038 threadId: ThreadId.makeUnsafe("thread-delete-files"),
1039 projectId: ProjectId.makeUnsafe("project-delete-files"),
1040 title: "Thread Delete Files",
1041 model: "gpt-5-codex",
1042 branch: null,
1043 worktreePath: null,
1044 createdAt: now,
1045 updatedAt: now,
1046 },
1047 });
1048
1049 yield* appendAndProject({
1050 type: "thread.message-sent",
1051 eventId: EventId.makeUnsafe("evt-delete-files-3"),
1052 aggregateKind: "thread",
1053 aggregateId: ThreadId.makeUnsafe("thread-delete-files"),
1054 occurredAt: now,
1055 commandId: CommandId.makeUnsafe("cmd-delete-files-3"),
1056 causationEventId: null,
1057 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"),
1058 metadata: {},
1059 payload: {
1060 threadId: ThreadId.makeUnsafe("thread-delete-files"),
1061 messageId: MessageId.makeUnsafe("message-delete-files"),
1062 role: "user",
1063 text: "Delete",
1064 attachments: [
1065 {
1066 type: "image",
1067 name: "delete.png",
1068 mimeType: "image/png",
1069 sizeBytes: 5,
1070 dataUrl: "data:image/png;base64,SGVsbG8=",
1071 },
1072 ],
1073 turnId: null,
1074 streaming: false,
1075 createdAt: now,
1076 updatedAt: now,
1077 },
1078 });
1079
1080 const threadAttachmentDir = path.join(stateDir, "attachments", "thread-delete-files");
1081 assert.equal(fs.existsSync(threadAttachmentDir), true);
1082
1083 yield* appendAndProject({
1084 type: "thread.deleted",
1085 eventId: EventId.makeUnsafe("evt-delete-files-4"),
1086 aggregateKind: "thread",
1087 aggregateId: ThreadId.makeUnsafe("thread-delete-files"),
1088 occurredAt: now,
1089 commandId: CommandId.makeUnsafe("cmd-delete-files-4"),
1090 causationEventId: null,
1091 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"),
1092 metadata: {},
1093 payload: {
1094 threadId: ThreadId.makeUnsafe("thread-delete-files"),
1095 deletedAt: now,
1096 },
1097 });
1098
1099 assert.equal(fs.existsSync(threadAttachmentDir), false);
1100 fs.rmSync(stateDir, { recursive: true, force: true });
1101 }),
1102 );
1103
1104 it.effect("ignores unsafe thread ids for attachment cleanup paths", () =>
1105 Effect.gen(function* () {
1106 const projectionPipeline = yield* OrchestrationProjectionPipeline;
1107 const eventStore = yield* OrchestrationEventStore;
1108 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-unsafe-"));
1109 const now = new Date().toISOString();
1110 const attachmentsRootDir = path.join(stateDir, "attachments");
1111 const attachmentsSentinelPath = path.join(attachmentsRootDir, "sentinel.txt");
1112 const stateDirSentinelPath = path.join(stateDir, "state-sentinel.txt");
1113 fs.mkdirSync(attachmentsRootDir, { recursive: true });
1114 fs.writeFileSync(attachmentsSentinelPath, "keep-attachments-root", "utf8");
1115 fs.writeFileSync(stateDirSentinelPath, "keep-state-dir", "utf8");
1116
1117 const serverConfig = {
1118 mode: "web",
1119 port: 0,
1120 host: undefined,
1121 cwd: "/tmp/project-attachments",
1122 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
1123 stateDir,
1124 staticDir: undefined,
1125 devUrl: undefined,
1126 noBrowser: true,
1127 authToken: undefined,
1128 autoBootstrapProjectFromCwd: false,
1129 logWebSocketEvents: false,
1130 } satisfies ServerConfigShape;
1131
1132 yield* eventStore.append({
1133 type: "thread.deleted",
1134 eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"),
1135 aggregateKind: "thread",
1136 aggregateId: ThreadId.makeUnsafe(".."),
1137 occurredAt: now,
1138 commandId: CommandId.makeUnsafe("cmd-unsafe-thread-delete"),
1139 causationEventId: null,
1140 correlationId: CorrelationId.makeUnsafe("cmd-unsafe-thread-delete"),
1141 metadata: {},
1142 payload: {
1143 threadId: ThreadId.makeUnsafe(".."),
1144 deletedAt: now,
1145 },
1146 });
1147
1148 yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig));
1149
1150 assert.equal(fs.existsSync(attachmentsRootDir), true);
1151 assert.equal(fs.existsSync(attachmentsSentinelPath), true);
1152 assert.equal(fs.existsSync(stateDirSentinelPath), true);
1153 fs.rmSync(stateDir, { recursive: true, force: true });
1154 }),
1155 );
1156
1611157 it.effect("resumes from projector last_applied_sequence without replaying older events", () =>
1621158 Effect.gen(function* () {
1631159 const projectionPipeline = yield* OrchestrationProjectionPipeline;
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+334-18
......@@ -1,5 +1,11 @@
1import { ApprovalRequestId, type OrchestrationEvent } from "@t3tools/contracts";
2import { Effect, Layer, Option, Stream } from "effect";
1import Mime from "@effect/platform-node/Mime";
2import {
3 ApprovalRequestId,
4 type ChatAttachment,
5 type OrchestrationEvent,
6} from "@t3tools/contracts";
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect";
39import * as SqlClient from "effect/unstable/sql/SqlClient";
410
511import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts";
......@@ -27,10 +33,16 @@ import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/
2733import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts";
2834import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
2935import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts";
36import { ServerConfig } from "../../config.ts";
3037import {
3138 OrchestrationProjectionPipeline,
3239 type OrchestrationProjectionPipelineShape,
3340} from "../Services/ProjectionPipeline.ts";
41import {
42 ATTACHMENTS_ROUTE_PREFIX,
43 IMAGE_EXTENSION_BY_MIME_TYPE,
44 SAFE_IMAGE_FILE_EXTENSIONS,
45} from "../../projectFaviconRoute.ts";
3446
3547export const ORCHESTRATION_PROJECTOR_NAMES = {
3648 projects: "projection.projects",
......@@ -48,9 +60,122 @@ type ProjectorName =
4860
4961interface ProjectorDefinition {
5062 readonly name: ProjectorName;
51 readonly apply: (event: OrchestrationEvent) => Effect.Effect<void, ProjectionRepositoryError>;
63 readonly apply: (
64 event: OrchestrationEvent,
65 attachmentSideEffects: AttachmentSideEffects,
66 ) => Effect.Effect<void, ProjectionRepositoryError>;
67}
68
69interface PendingAttachmentWrite {
70 readonly absolutePath: string;
71 readonly bytes: Uint8Array;
72}
73
74interface AttachmentSideEffects {
75 readonly writes: Array<PendingAttachmentWrite>;
76 readonly deletedThreadIds: Set<string>;
77 readonly prunedThreadRelativePaths: Map<string, Set<string>>;
78}
79
80function toSafeThreadAttachmentSegment(threadId: string): string | null {
81 const segment = encodeURIComponent(threadId);
82 if (
83 segment.length === 0 ||
84 segment === "." ||
85 segment === ".." ||
86 segment.includes("/") ||
87 segment.includes("\\") ||
88 segment.includes("\0")
89 ) {
90 return null;
91 }
92 return segment;
93}
94
95function parseBase64DataUrl(
96 dataUrl: string,
97): { readonly mimeType: string; readonly base64: string } | null {
98 const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(dataUrl.trim());
99 if (!match) return null;
100
101 const mimeType = match[1]?.trim().toLowerCase();
102 const base64 = match[2]?.replace(/\s+/g, "");
103 if (!mimeType || !base64) return null;
104
105 return { mimeType, base64 };
106}
107
108function inferImageExtension(attachment: Extract<ChatAttachment, { type: "image" }>): string {
109 const normalizedMimeType = attachment.mimeType.toLowerCase();
110 if (normalizedMimeType.startsWith("image/")) {
111 const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[normalizedMimeType];
112 if (fromMime) return fromMime;
113 }
114 const ext = Mime.getExtension(attachment.mimeType);
115 if (ext && SAFE_IMAGE_FILE_EXTENSIONS.has(ext)) return ext;
116 return ".bin";
52117}
53118
119const materializeAttachmentsForProjection = Effect.fn(function* (input: {
120 readonly threadId: string;
121 readonly messageId: string;
122 readonly attachments: ReadonlyArray<ChatAttachment>;
123 readonly stageFileWrite: (pendingWrite: PendingAttachmentWrite) => void;
124}) {
125 if (input.attachments.length === 0) return [];
126
127 const serverConfig = yield* Effect.serviceOption(ServerConfig);
128 if (Option.isNone(serverConfig)) return input.attachments;
129
130 const path = yield* Path.Path;
131 const attachmentsRootDir = path.join(serverConfig.value.stateDir, "attachments");
132 const threadSegment = toSafeThreadAttachmentSegment(input.threadId);
133 if (!threadSegment) {
134 yield* Effect.logWarning("skipping attachment materialization for unsafe thread id", {
135 threadId: input.threadId,
136 messageId: input.messageId,
137 });
138 return input.attachments;
139 }
140 const messageSegment = encodeURIComponent(input.messageId);
141
142 return yield* Effect.forEach(
143 input.attachments,
144 (attachment, index) =>
145 Effect.sync(() => {
146 if (attachment.type !== "image" || !attachment.dataUrl.startsWith("data:")) {
147 return attachment;
148 }
149
150 const parsed = parseBase64DataUrl(attachment.dataUrl);
151 if (!parsed) {
152 return attachment;
153 }
154 if (!parsed.mimeType.startsWith("image/")) {
155 return attachment;
156 }
157
158 const bytes = Buffer.from(parsed.base64, "base64");
159 if (bytes.byteLength === 0) return attachment;
160
161 const fileName = `${index}${inferImageExtension({
162 ...attachment,
163 mimeType: parsed.mimeType,
164 })}`;
165 const uniqueFileName = `${messageSegment}-${fileName}`;
166 const relativePath = `${threadSegment}/${uniqueFileName}`;
167 const absolutePath = path.join(attachmentsRootDir, threadSegment, uniqueFileName);
168 input.stageFileWrite({ absolutePath, bytes });
169
170 return {
171 ...attachment,
172 dataUrl: `${ATTACHMENTS_ROUTE_PREFIX}/${relativePath}`,
173 } satisfies ChatAttachment;
174 }),
175 { concurrency: 1 },
176 );
177});
178
54179function extractActivityRequestId(payload: unknown): ApprovalRequestId | null {
55180 if (typeof payload !== "object" || payload === null) {
56181 return null;
......@@ -163,6 +288,138 @@ function retainProjectionActivitiesAfterRevert(
163288 );
164289}
165290
291function toAttachmentRelativePath(dataUrl: string): string | null {
292 const prefix = `${ATTACHMENTS_ROUTE_PREFIX}/`;
293 if (!dataUrl.startsWith(prefix)) {
294 return null;
295 }
296 const relativePath = dataUrl.slice(prefix.length).replace(/^[/\\]+/, "");
297 if (relativePath.length === 0 || relativePath.startsWith("..")) {
298 return null;
299 }
300 return relativePath.replace(/\\/g, "/");
301}
302
303function collectThreadAttachmentRelativePaths(
304 threadId: string,
305 messages: ReadonlyArray<ProjectionThreadMessage>,
306): Set<string> {
307 const threadSegment = toSafeThreadAttachmentSegment(threadId);
308 if (!threadSegment) {
309 return new Set();
310 }
311 const threadPrefix = `${threadSegment}/`;
312 const relativePaths = new Set<string>();
313 for (const message of messages) {
314 for (const attachment of message.attachments ?? []) {
315 if (attachment.type !== "image") {
316 continue;
317 }
318 const relativePath = toAttachmentRelativePath(attachment.dataUrl);
319 if (!relativePath || !relativePath.startsWith(threadPrefix)) {
320 continue;
321 }
322 const threadRelativePath = relativePath.slice(threadPrefix.length).replace(/^[/\\]+/, "");
323 if (threadRelativePath.length === 0 || threadRelativePath.startsWith("..")) {
324 continue;
325 }
326 relativePaths.add(threadRelativePath.replace(/\\/g, "/"));
327 }
328 }
329 return relativePaths;
330}
331
332const runAttachmentSideEffects = Effect.fn(function* (input: {
333 readonly sideEffects: AttachmentSideEffects;
334 readonly fileSystem: FileSystem.FileSystem;
335 readonly path: Path.Path;
336}) {
337 const serverConfig = yield* Effect.serviceOption(ServerConfig);
338 if (Option.isNone(serverConfig)) {
339 return;
340 }
341
342 const attachmentsRootDir = input.path.join(serverConfig.value.stateDir, "attachments");
343
344 yield* Effect.forEach(
345 input.sideEffects.deletedThreadIds,
346 (threadId) =>
347 Effect.gen(function* () {
348 const threadSegment = toSafeThreadAttachmentSegment(threadId);
349 if (!threadSegment) {
350 yield* Effect.logWarning("skipping attachment cleanup for unsafe thread id", {
351 threadId,
352 });
353 return;
354 }
355 const threadDir = input.path.join(attachmentsRootDir, threadSegment);
356 yield* input.fileSystem.remove(threadDir, { recursive: true, force: true });
357 }),
358 { concurrency: 1 },
359 );
360
361 yield* Effect.forEach(
362 input.sideEffects.prunedThreadRelativePaths.entries(),
363 ([threadId, keptThreadRelativePaths]) => {
364 if (input.sideEffects.deletedThreadIds.has(threadId)) {
365 return Effect.void;
366 }
367 return Effect.gen(function* () {
368 const threadSegment = toSafeThreadAttachmentSegment(threadId);
369 if (!threadSegment) {
370 yield* Effect.logWarning("skipping attachment prune for unsafe thread id", { threadId });
371 return;
372 }
373 const threadDir = input.path.join(attachmentsRootDir, threadSegment);
374 const entries = yield* input.fileSystem
375 .readDirectory(threadDir, { recursive: true })
376 .pipe(Effect.catch(() => Effect.succeed([] as Array<string>)));
377 yield* Effect.forEach(
378 entries,
379 (entry) =>
380 Effect.gen(function* () {
381 const threadRelativePath = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/");
382 if (threadRelativePath.length === 0 || threadRelativePath.startsWith("..")) {
383 return;
384 }
385
386 const absolutePath = input.path.join(threadDir, threadRelativePath);
387 const fileInfo = yield* input.fileSystem
388 .stat(absolutePath)
389 .pipe(Effect.catch(() => Effect.succeed(null)));
390 if (!fileInfo || fileInfo.type !== "File") {
391 return;
392 }
393
394 if (!keptThreadRelativePaths.has(threadRelativePath)) {
395 yield* input.fileSystem.remove(absolutePath, { force: true });
396 }
397 }),
398 { concurrency: 1 },
399 );
400 });
401 },
402 { concurrency: 1 },
403 );
404
405 const writesByPath = new Map<string, Uint8Array>();
406 for (const pendingWrite of input.sideEffects.writes) {
407 // Last write wins for a path so updated attachment contents replace stale bytes.
408 writesByPath.set(pendingWrite.absolutePath, pendingWrite.bytes);
409 }
410 yield* Effect.forEach(
411 writesByPath.entries(),
412 ([absolutePath, bytes]) =>
413 Effect.gen(function* () {
414 yield* input.fileSystem.makeDirectory(input.path.dirname(absolutePath), {
415 recursive: true,
416 });
417 yield* input.fileSystem.writeFile(absolutePath, bytes);
418 }),
419 { concurrency: 1 },
420 );
421});
422
166423const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
167424 const sql = yield* SqlClient.SqlClient;
168425 const eventStore = yield* OrchestrationEventStore;
......@@ -175,7 +432,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
175432 const projectionTurnRepository = yield* ProjectionTurnRepository;
176433 const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository;
177434
178 const applyProjectsProjection: ProjectorDefinition["apply"] = (event) =>
435 const fileSystem = yield* FileSystem.FileSystem;
436 const path = yield* Path.Path;
437
438 const applyProjectsProjection: ProjectorDefinition["apply"] = (event, _attachmentSideEffects) =>
179439 Effect.gen(function* () {
180440 switch (event.type) {
181441 case "project.created":
......@@ -233,7 +493,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
233493 }
234494 });
235495
236 const applyThreadsProjection: ProjectorDefinition["apply"] = (event) =>
496 const applyThreadsProjection: ProjectorDefinition["apply"] = (event, attachmentSideEffects) =>
237497 Effect.gen(function* () {
238498 switch (event.type) {
239499 case "thread.created":
......@@ -272,6 +532,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
272532 }
273533
274534 case "thread.deleted": {
535 attachmentSideEffects.deletedThreadIds.add(event.payload.threadId);
275536 const existingRow = yield* projectionThreadRepository.getById({
276537 threadId: event.payload.threadId,
277538 });
......@@ -351,7 +612,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
351612 }
352613 });
353614
354 const applyThreadMessagesProjection: ProjectorDefinition["apply"] = (event) =>
615 const applyThreadMessagesProjection: ProjectorDefinition["apply"] = (
616 event,
617 attachmentSideEffects,
618 ) =>
355619 Effect.gen(function* () {
356620 switch (event.type) {
357621 case "thread.message-sent": {
......@@ -367,12 +631,24 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
367631 : existingMessage && event.payload.text.length === 0
368632 ? existingMessage.text
369633 : event.payload.text;
634 const nextAttachments =
635 event.payload.attachments !== undefined
636 ? yield* materializeAttachmentsForProjection({
637 threadId: event.payload.threadId,
638 messageId: event.payload.messageId,
639 attachments: event.payload.attachments,
640 stageFileWrite: (pendingWrite) => {
641 attachmentSideEffects.writes.push(pendingWrite);
642 },
643 }).pipe(Effect.provideService(Path.Path, path))
644 : existingMessage?.attachments;
370645 yield* projectionThreadMessageRepository.upsert({
371646 messageId: event.payload.messageId,
372647 threadId: event.payload.threadId,
373648 turnId: event.payload.turnId,
374649 role: event.payload.role,
375650 text: nextText,
651 ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}),
376652 isStreaming: event.payload.streaming,
377653 createdAt: existingMessage?.createdAt ?? event.payload.createdAt,
378654 updatedAt: event.payload.updatedAt,
......@@ -406,6 +682,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
406682 yield* Effect.forEach(keptRows, projectionThreadMessageRepository.upsert, {
407683 concurrency: 1,
408684 }).pipe(Effect.asVoid);
685 attachmentSideEffects.prunedThreadRelativePaths.set(
686 event.payload.threadId,
687 collectThreadAttachmentRelativePaths(event.payload.threadId, keptRows),
688 );
409689 return;
410690 }
411691
......@@ -414,7 +694,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
414694 }
415695 });
416696
417 const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = (event) =>
697 const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = (
698 event,
699 _attachmentSideEffects,
700 ) =>
418701 Effect.gen(function* () {
419702 switch (event.type) {
420703 case "thread.activity-appended":
......@@ -462,7 +745,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
462745 }
463746 });
464747
465 const applyThreadSessionsProjection: ProjectorDefinition["apply"] = (event) =>
748 const applyThreadSessionsProjection: ProjectorDefinition["apply"] = (
749 event,
750 _attachmentSideEffects,
751 ) =>
466752 Effect.gen(function* () {
467753 if (event.type !== "thread.session-set") {
468754 return;
......@@ -481,7 +767,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
481767 });
482768 });
483769
484 const applyThreadTurnsProjection: ProjectorDefinition["apply"] = (event) =>
770 const applyThreadTurnsProjection: ProjectorDefinition["apply"] = (
771 event,
772 _attachmentSideEffects,
773 ) =>
485774 Effect.gen(function* () {
486775 switch (event.type) {
487776 case "thread.turn-start-requested": {
......@@ -714,7 +1003,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
7141003
7151004 const applyCheckpointsProjection: ProjectorDefinition["apply"] = () => Effect.void;
7161005
717 const applyPendingApprovalsProjection: ProjectorDefinition["apply"] = (event) =>
1006 const applyPendingApprovalsProjection: ProjectorDefinition["apply"] = (
1007 event,
1008 _attachmentSideEffects,
1009 ) =>
7181010 Effect.gen(function* () {
7191011 switch (event.type) {
7201012 case "thread.activity-appended": {
......@@ -837,17 +1129,40 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
8371129 ];
8381130
8391131 const runProjectorForEvent = (projector: ProjectorDefinition, event: OrchestrationEvent) =>
840 sql.withTransaction(
841 projector.apply(event).pipe(
842 Effect.flatMap(() =>
843 projectionStateRepository.upsert({
1132 Effect.gen(function* () {
1133 const attachmentSideEffects: AttachmentSideEffects = {
1134 writes: [],
1135 deletedThreadIds: new Set<string>(),
1136 prunedThreadRelativePaths: new Map<string, Set<string>>(),
1137 };
1138
1139 yield* sql.withTransaction(
1140 projector.apply(event, attachmentSideEffects).pipe(
1141 Effect.flatMap(() =>
1142 projectionStateRepository.upsert({
1143 projector: projector.name,
1144 lastAppliedSequence: event.sequence,
1145 updatedAt: event.occurredAt,
1146 }),
1147 ),
1148 ),
1149 );
1150
1151 yield* runAttachmentSideEffects({
1152 sideEffects: attachmentSideEffects,
1153 fileSystem,
1154 path,
1155 }).pipe(
1156 Effect.catch((cause) =>
1157 Effect.logWarning("failed to apply projected attachment side-effects", {
8441158 projector: projector.name,
845 lastAppliedSequence: event.sequence,
846 updatedAt: event.occurredAt,
1159 sequence: event.sequence,
1160 eventType: event.type,
1161 cause,
8471162 }),
8481163 ),
849 ),
850 );
1164 );
1165 });
8511166
8521167 const bootstrapProjector = (projector: ProjectorDefinition) =>
8531168 projectionStateRepository
......@@ -901,6 +1216,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect(
9011216 OrchestrationProjectionPipeline,
9021217 makeOrchestrationProjectionPipeline,
9031218).pipe(
1219 Layer.provideMerge(NodeServices.layer),
9041220 Layer.provideMerge(ProjectionProjectRepositoryLive),
9051221 Layer.provideMerge(ProjectionThreadRepositoryLive),
9061222 Layer.provideMerge(ProjectionThreadMessageRepositoryLive),
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+5-1
......@@ -1,4 +1,5 @@
11import {
2 ChatAttachment,
23 IsoDateTime,
34 MessageId,
45 OrchestrationCheckpointFile,
......@@ -42,12 +43,13 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
4243 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
4344 }),
4445);
45const ProjectionThreadDbRowSchema = ProjectionThread;
4646const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(
4747 Struct.assign({
4848 isStreaming: Schema.Number,
49 attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))),
4950 }),
5051);
52const ProjectionThreadDbRowSchema = ProjectionThread;
5153const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
5254 Struct.assign({
5355 payload: Schema.fromJsonString(Schema.Unknown),
......@@ -171,6 +173,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
171173 turn_id AS "turnId",
172174 role,
173175 text,
176 attachments_json AS "attachments",
174177 is_streaming AS "isStreaming",
175178 created_at AS "createdAt",
176179 updated_at AS "updatedAt"
......@@ -376,6 +379,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
376379 id: row.messageId,
377380 role: row.role,
378381 text: row.text,
382 ...(row.attachments !== null ? { attachments: row.attachments } : {}),
379383 turnId: row.turnId,
380384 streaming: row.isStreaming === 1,
381385 createdAt: row.createdAt,
apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts created+106
......@@ -0,0 +1,106 @@
1import { MessageId, ThreadId } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";
3import { Effect, Layer } from "effect";
4
5import { ProjectionThreadMessageRepository } from "../Services/ProjectionThreadMessages.ts";
6import { ProjectionThreadMessageRepositoryLive } from "./ProjectionThreadMessages.ts";
7import { SqlitePersistenceMemory } from "./Sqlite.ts";
8
9const layer = it.layer(
10 ProjectionThreadMessageRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
11);
12
13layer("ProjectionThreadMessageRepository", (it) => {
14 it.effect("preserves existing attachments when upsert omits attachments", () =>
15 Effect.gen(function* () {
16 const repository = yield* ProjectionThreadMessageRepository;
17 const threadId = ThreadId.makeUnsafe("thread-preserve-attachments");
18 const messageId = MessageId.makeUnsafe("message-preserve-attachments");
19 const createdAt = "2026-02-28T19:00:00.000Z";
20 const updatedAt = "2026-02-28T19:00:01.000Z";
21 const persistedAttachments = [
22 {
23 type: "image" as const,
24 name: "example.png",
25 mimeType: "image/png",
26 sizeBytes: 5,
27 dataUrl: "/attachments/thread-preserve-attachments/message-preserve-attachments/0.png",
28 },
29 ];
30
31 yield* repository.upsert({
32 messageId,
33 threadId,
34 turnId: null,
35 role: "user",
36 text: "initial",
37 attachments: persistedAttachments,
38 isStreaming: false,
39 createdAt,
40 updatedAt,
41 });
42
43 yield* repository.upsert({
44 messageId,
45 threadId,
46 turnId: null,
47 role: "user",
48 text: "updated",
49 isStreaming: false,
50 createdAt,
51 updatedAt: "2026-02-28T19:00:02.000Z",
52 });
53
54 const rows = yield* repository.listByThreadId({ threadId });
55 assert.equal(rows.length, 1);
56 assert.equal(rows[0]?.text, "updated");
57 assert.deepEqual(rows[0]?.attachments, persistedAttachments);
58 }),
59 );
60
61 it.effect("allows explicit attachment clearing with an empty array", () =>
62 Effect.gen(function* () {
63 const repository = yield* ProjectionThreadMessageRepository;
64 const threadId = ThreadId.makeUnsafe("thread-clear-attachments");
65 const messageId = MessageId.makeUnsafe("message-clear-attachments");
66 const createdAt = "2026-02-28T19:10:00.000Z";
67
68 yield* repository.upsert({
69 messageId,
70 threadId,
71 turnId: null,
72 role: "assistant",
73 text: "with attachment",
74 attachments: [
75 {
76 type: "image",
77 name: "example.png",
78 mimeType: "image/png",
79 sizeBytes: 5,
80 dataUrl: "/attachments/thread-clear-attachments/message-clear-attachments/0.png",
81 },
82 ],
83 isStreaming: false,
84 createdAt,
85 updatedAt: "2026-02-28T19:10:01.000Z",
86 });
87
88 yield* repository.upsert({
89 messageId,
90 threadId,
91 turnId: null,
92 role: "assistant",
93 text: "cleared",
94 attachments: [],
95 isStreaming: false,
96 createdAt,
97 updatedAt: "2026-02-28T19:10:02.000Z",
98 });
99
100 const rows = yield* repository.listByThreadId({ threadId });
101 assert.equal(rows.length, 1);
102 assert.equal(rows[0]?.text, "cleared");
103 assert.deepEqual(rows[0]?.attachments, []);
104 }),
105 );
106});
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts+30-4
......@@ -1,6 +1,7 @@
11import * as SqlClient from "effect/unstable/sql/SqlClient";
22import * as SqlSchema from "effect/unstable/sql/SqlSchema";
33import { Effect, Layer, Schema, Struct } from "effect";
4import { ChatAttachment } from "@t3tools/contracts";
45
56import { toPersistenceSqlError } from "../Errors.ts";
67import {
......@@ -14,6 +15,7 @@ import {
1415const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(
1516 Struct.assign({
1617 isStreaming: Schema.Number,
18 attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))),
1719 }),
1820);
1921
......@@ -22,14 +24,17 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {
2224
2325 const upsertProjectionThreadMessageRow = SqlSchema.void({
2426 Request: ProjectionThreadMessage,
25 execute: (row) =>
26 sql`
27 execute: (row) => {
28 const nextAttachmentsJson =
29 row.attachments !== undefined ? JSON.stringify(row.attachments) : null;
30 return sql`
2731 INSERT INTO projection_thread_messages (
2832 message_id,
2933 thread_id,
3034 turn_id,
3135 role,
3236 text,
37 attachments_json,
3338 is_streaming,
3439 created_at,
3540 updated_at
......@@ -40,6 +45,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {
4045 ${row.turnId},
4146 ${row.role},
4247 ${row.text},
48 COALESCE(
49 ${nextAttachmentsJson},
50 (
51 SELECT attachments_json
52 FROM projection_thread_messages
53 WHERE message_id = ${row.messageId}
54 )
55 ),
4356 ${row.isStreaming ? 1 : 0},
4457 ${row.createdAt},
4558 ${row.updatedAt}
......@@ -50,10 +63,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {
5063 turn_id = excluded.turn_id,
5164 role = excluded.role,
5265 text = excluded.text,
66 attachments_json = COALESCE(
67 excluded.attachments_json,
68 projection_thread_messages.attachments_json
69 ),
5370 is_streaming = excluded.is_streaming,
5471 created_at = excluded.created_at,
5572 updated_at = excluded.updated_at
56 `,
73 `;
74 },
5775 });
5876
5977 const listProjectionThreadMessageRows = SqlSchema.findAll({
......@@ -67,6 +85,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {
6785 turn_id AS "turnId",
6886 role,
6987 text,
88 attachments_json AS "attachments",
7089 is_streaming AS "isStreaming",
7190 created_at AS "createdAt",
7291 updated_at AS "updatedAt"
......@@ -97,8 +116,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {
97116 ),
98117 Effect.map((rows) =>
99118 rows.map((row) => ({
100 ...row,
119 messageId: row.messageId,
120 threadId: row.threadId,
121 turnId: row.turnId,
122 role: row.role,
123 text: row.text,
101124 isStreaming: row.isStreaming === 1,
125 createdAt: row.createdAt,
126 updatedAt: row.updatedAt,
127 ...(row.attachments !== null ? { attachments: row.attachments } : {}),
102128 })),
103129 ),
104130 );
apps/server/src/persistence/Migrations.ts+2
......@@ -18,6 +18,7 @@ import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts";
1818import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts";
1919import Migration0005 from "./Migrations/005_Projections.ts";
2020import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts";
21import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts";
2122
2223/**
2324 * Migration loader with all migrations defined inline.
......@@ -36,6 +37,7 @@ const loader = Migrator.fromRecord({
3637 "4_ProviderSessionRuntime": Migration0004,
3738 "5_Projections": Migration0005,
3839 "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006,
40 "7_ProjectionThreadMessageAttachments": Migration0007,
3941});
4042
4143/**
apps/server/src/persistence/Migrations/007_ProjectionThreadMessageAttachments.ts created+11
......@@ -0,0 +1,11 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 ALTER TABLE projection_thread_messages
9 ADD COLUMN attachments_json TEXT
10 `;
11});
apps/server/src/persistence/Services/ProjectionThreadMessages.ts+2
......@@ -7,6 +7,7 @@
77 * @module ProjectionThreadMessageRepository
88 */
99import {
10 ChatAttachment,
1011 OrchestrationMessageRole,
1112 MessageId,
1213 ThreadId,
......@@ -24,6 +25,7 @@ export const ProjectionThreadMessage = Schema.Struct({
2425 turnId: Schema.NullOr(TurnId),
2526 role: OrchestrationMessageRole,
2627 text: Schema.String,
28 attachments: Schema.optional(Schema.Array(ChatAttachment)),
2729 isStreaming: Schema.Boolean,
2830 createdAt: IsoDateTime,
2931 updatedAt: IsoDateTime,
apps/server/src/projectFaviconRoute.ts+26
......@@ -2,6 +2,32 @@ import fs from "node:fs";
22import http from "node:http";
33import path from "node:path";
44
5export const ATTACHMENTS_ROUTE_PREFIX = "/attachments";
6
7export const IMAGE_EXTENSION_BY_MIME_TYPE: Record<string, string> = {
8 "image/png": ".png",
9 "image/jpeg": ".jpg",
10 "image/jpg": ".jpg",
11 "image/gif": ".gif",
12 "image/webp": ".webp",
13 "image/svg+xml": ".svg",
14 "image/bmp": ".bmp",
15 "image/tiff": ".tiff",
16 "image/heic": ".heic",
17};
18
19export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([
20 ".jpg",
21 ".jpeg",
22 ".png",
23 ".gif",
24 ".webp",
25 ".bmp",
26 ".tiff",
27 ".svg",
28 ".ico",
29]);
30
531const FAVICON_MIME_TYPES: Record<string, string> = {
632 ".png": "image/png",
733 ".jpg": "image/jpeg",
apps/server/src/provider/Layers/ProviderService.test.ts+34
......@@ -378,6 +378,7 @@ it.effect(
378378 [
379379 {
380380 provider: "codex",
381 cwd: "/tmp/project",
381382 resumeThreadId: startedSession.threadId,
382383 },
383384 ],
......@@ -465,6 +466,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
465466 [
466467 {
467468 provider: "codex",
469 cwd: "/tmp/project",
468470 resumeThreadId: initial.threadId,
469471 },
470472 ],
......@@ -475,6 +477,38 @@ routing.layer("ProviderServiceLive routing", (it) => {
475477 }),
476478 );
477479
480 it.effect("recovers stale sessions for sendTurn using persisted cwd", () =>
481 Effect.gen(function* () {
482 const provider = yield* ProviderService;
483
484 const initial = yield* provider.startSession(asThreadId("thread-1"), {
485 provider: "codex",
486 cwd: "/tmp/project-send-turn",
487 });
488
489 yield* provider.stopAll();
490 routing.codex.startSession.mockClear();
491 routing.codex.sendTurn.mockClear();
492
493 yield* provider.sendTurn({
494 sessionId: initial.sessionId,
495 input: "resume",
496 attachments: [],
497 });
498
499 assert.deepEqual(routing.codex.startSession.mock.calls, [
500 [
501 {
502 provider: "codex",
503 cwd: "/tmp/project-send-turn",
504 resumeThreadId: initial.threadId,
505 },
506 ],
507 ]);
508 assert.equal(routing.codex.sendTurn.mock.calls.length, 1);
509 }),
510 );
511
478512 it.effect("stops all sessions and clears adapter runtime", () =>
479513 Effect.gen(function* () {
480514 const provider = yield* ProviderService;
apps/server/src/provider/Layers/ProviderService.ts+15
......@@ -93,6 +93,18 @@ function toRuntimePayloadFromSession(session: ProviderSession): Record<string, u
9393 };
9494}
9595
96function readPersistedCwd(
97 runtimePayload: ProviderSessionBinding["runtimePayload"],
98): string | undefined {
99 if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) {
100 return undefined;
101 }
102 const rawCwd = "cwd" in runtimePayload ? runtimePayload.cwd : undefined;
103 if (typeof rawCwd !== "string") return undefined;
104 const trimmed = rawCwd.trim();
105 return trimmed.length > 0 ? trimmed : undefined;
106}
107
96108const makeProviderService = (options?: ProviderServiceLiveOptions) =>
97109 Effect.gen(function* () {
98110 const canonicalEventLogger =
......@@ -266,8 +278,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
266278 );
267279 }
268280
281 const persistedCwd = readPersistedCwd(input.binding.runtimePayload);
282
269283 const resumed = yield* adapter.startSession({
270284 provider: input.binding.provider,
285 ...(persistedCwd ? { cwd: persistedCwd } : {}),
271286 ...(resumeThreadId ? { resumeThreadId } : {}),
272287 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),
273288 });
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+2-1
......@@ -26,6 +26,7 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli
2626 return Layer.mergeAll(
2727 runtimeRepositoryLayer,
2828 ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)),
29 NodeServices.layer,
2930 );
3031}
3132
......@@ -223,5 +224,5 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
223224 }).pipe(Effect.provide(directoryLayer));
224225
225226 fs.rmSync(tempDir, { recursive: true, force: true });
226 }).pipe(Effect.provide(NodeServices.layer)));
227 }));
227228});
apps/server/src/serverLayers.ts+3-3
......@@ -61,16 +61,16 @@ export function makeServerRuntimeServicesLayer() {
6161 Layer.provide(OrchestrationEventStoreLive),
6262 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
6363 );
64 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));
64
6565 const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe(
6666 Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),
67 Layer.provideMerge(checkpointStoreLayer),
67 Layer.provideMerge(CheckpointStoreLive),
6868 );
6969
7070 const runtimeServicesLayer = Layer.mergeAll(
7171 orchestrationLayer,
7272 OrchestrationProjectionSnapshotQueryLive,
73 checkpointStoreLayer,
73 CheckpointStoreLive,
7474 checkpointDiffQueryLayer,
7575 );
7676 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
apps/server/src/wsServer.test.ts+116-7
......@@ -4,7 +4,7 @@ import os from "node:os";
44import path from "node:path";
55
66import * as NodeServices from "@effect/platform-node/NodeServices";
7import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect";
7import { Effect, Exit, Layer, PlatformError, PubSub, Scope, Stream } from "effect";
88import { describe, expect, it, afterEach, vi } from "vitest";
99import { createServer } from "./wsServer";
1010import WebSocket from "ws";
......@@ -40,13 +40,14 @@ import type {
4040} from "@t3tools/contracts";
4141import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager";
4242import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
43import { SqlClient } from "effect/unstable/sql";
43import { SqlClient, SqlError } from "effect/unstable/sql";
4444import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
4545import { Open, type OpenShape } from "./open";
4646import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
4747import type { GitCoreShape } from "./git/Services/GitCore.ts";
4848import { GitCore } from "./git/Services/GitCore.ts";
4949import { GitCommandError, GitManagerError } from "./git/Errors.ts";
50import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";
5051
5152interface PendingMessages {
5253 queue: unknown[];
......@@ -296,6 +297,36 @@ async function waitForPush(
296297 return take(maxMessages);
297298}
298299
300async function requestPath(
301 port: number,
302 requestPath: string,
303): Promise<{ statusCode: number; body: string }> {
304 return new Promise((resolve, reject) => {
305 const req = Http.request(
306 {
307 hostname: "127.0.0.1",
308 port,
309 path: requestPath,
310 method: "GET",
311 },
312 (res) => {
313 const chunks: Buffer[] = [];
314 res.on("data", (chunk) => {
315 chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
316 });
317 res.on("end", () => {
318 resolve({
319 statusCode: res.statusCode ?? 0,
320 body: Buffer.concat(chunks).toString("utf8"),
321 });
322 });
323 },
324 );
325 req.once("error", reject);
326 req.end();
327 });
328}
329
299330function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {
300331 const resolved: Array<ResolvedKeybindingsConfig[number]> = [];
301332 for (const binding of bindings) {
......@@ -324,13 +355,17 @@ describe("WebSocket Server", () => {
324355
325356 async function createTestServer(
326357 options: {
327 persistenceLayer?: Layer.Layer<SqlClient.SqlClient, never>;
358 persistenceLayer?: Layer.Layer<
359 SqlClient.SqlClient,
360 SqlError.SqlError | MigrationError | PlatformError.PlatformError
361 >;
328362 cwd?: string;
329363 autoBootstrapProjectFromCwd?: boolean;
330364 logWebSocketEvents?: boolean;
331365 devUrl?: string;
332366 authToken?: string;
333367 stateDir?: string;
368 staticDir?: string;
334369 providerLayer?: Layer.Layer<ProviderService, never>;
335370 open?: OpenShape;
336371 gitManager?: GitManagerShape;
......@@ -354,7 +389,7 @@ describe("WebSocket Server", () => {
354389 cwd: options.cwd ?? "/test/project",
355390 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
356391 stateDir,
357 staticDir: undefined,
392 staticDir: options.staticDir,
358393 devUrl: options.devUrl ? new URL(options.devUrl) : undefined,
359394 noBrowser: true,
360395 authToken: options.authToken,
......@@ -438,6 +473,80 @@ describe("WebSocket Server", () => {
438473 });
439474 });
440475
476 it("serves persisted attachments from stateDir", async () => {
477 const stateDir = makeTempDir("t3code-state-attachments-");
478 const attachmentPath = path.join(stateDir, "attachments", "thread-a", "message-a", "0.png");
479 fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });
480 fs.writeFileSync(attachmentPath, Buffer.from("hello-attachment"));
481
482 server = await createTestServer({ cwd: "/test/project", stateDir });
483 const addr = server.address();
484 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
485 expect(port).toBeGreaterThan(0);
486
487 const response = await fetch(`http://127.0.0.1:${port}/attachments/thread-a/message-a/0.png`);
488 expect(response.status).toBe(200);
489 expect(response.headers.get("content-type")).toContain("image/png");
490 const bytes = Buffer.from(await response.arrayBuffer());
491 expect(bytes).toEqual(Buffer.from("hello-attachment"));
492 });
493
494 it("serves persisted attachments for URL-encoded paths", async () => {
495 const stateDir = makeTempDir("t3code-state-attachments-encoded-");
496 const attachmentPath = path.join(
497 stateDir,
498 "attachments",
499 "thread%20folder",
500 "message%20folder",
501 "file%20name.png",
502 );
503 fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });
504 fs.writeFileSync(attachmentPath, Buffer.from("hello-encoded-attachment"));
505
506 server = await createTestServer({ cwd: "/test/project", stateDir });
507 const addr = server.address();
508 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
509 expect(port).toBeGreaterThan(0);
510
511 const response = await fetch(
512 `http://127.0.0.1:${port}/attachments/thread%20folder/message%20folder/file%20name.png`,
513 );
514 expect(response.status).toBe(200);
515 expect(response.headers.get("content-type")).toContain("image/png");
516 const bytes = Buffer.from(await response.arrayBuffer());
517 expect(bytes).toEqual(Buffer.from("hello-encoded-attachment"));
518 });
519
520 it("serves static index for root path", async () => {
521 const stateDir = makeTempDir("t3code-state-static-root-");
522 const staticDir = makeTempDir("t3code-static-root-");
523 fs.writeFileSync(path.join(staticDir, "index.html"), "<h1>static-root</h1>", "utf8");
524
525 server = await createTestServer({ cwd: "/test/project", stateDir, staticDir });
526 const addr = server.address();
527 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
528 expect(port).toBeGreaterThan(0);
529
530 const response = await fetch(`http://127.0.0.1:${port}/`);
531 expect(response.status).toBe(200);
532 expect(await response.text()).toContain("static-root");
533 });
534
535 it("rejects static path traversal attempts", async () => {
536 const stateDir = makeTempDir("t3code-state-static-traversal-");
537 const staticDir = makeTempDir("t3code-static-traversal-");
538 fs.writeFileSync(path.join(staticDir, "index.html"), "<h1>safe</h1>", "utf8");
539
540 server = await createTestServer({ cwd: "/test/project", stateDir, staticDir });
541 const addr = server.address();
542 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
543 expect(port).toBeGreaterThan(0);
544
545 const response = await requestPath(port, "/..%2f..%2fetc/passwd");
546 expect(response.statusCode).toBe(400);
547 expect(response.body).toBe("Invalid static file path");
548 });
549
441550 it("bootstraps the cwd project on startup when enabled", async () => {
442551 server = await createTestServer({
443552 cwd: "/test/bootstrap-workspace",
......@@ -509,9 +618,9 @@ describe("WebSocket Server", () => {
509618
510619 it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => {
511620 const stateDir = makeTempDir("t3code-state-bootstrap-existing-");
512 const persistenceLayer = makeSqlitePersistenceLive(
513 path.join(stateDir, "state.sqlite"),
514 ).pipe(Layer.provide(NodeServices.layer)) as unknown as Layer.Layer<SqlClient.SqlClient, never>;
621 const persistenceLayer = makeSqlitePersistenceLive(path.join(stateDir, "state.sqlite")).pipe(
622 Layer.provide(NodeServices.layer),
623 );
515624 const cwd = "/test/bootstrap-existing";
516625
517626 server = await createTestServer({
apps/server/src/wsServer.ts+159-65
......@@ -6,11 +6,10 @@
66 *
77 * @module Server
88 */
9import fs from "node:fs";
109import http from "node:http";
11import path from "node:path";
1210import type { Duplex } from "node:stream";
1311
12import Mime from "@effect/platform-node/Mime";
1413import {
1514 CommandId,
1615 ORCHESTRATION_WS_CHANNELS,
......@@ -25,7 +24,20 @@ import {
2524 WsResponse,
2625} from "@t3tools/contracts";
2726import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
28import { Cause, Effect, Exit, Layer, Ref, Schema, Scope, ServiceMap, Stream, Struct } from "effect";
27import {
28 Cause,
29 Effect,
30 Exit,
31 FileSystem,
32 Layer,
33 Path,
34 Ref,
35 Schema,
36 Scope,
37 ServiceMap,
38 Stream,
39 Struct,
40} from "effect";
2941import { WebSocketServer, type WebSocket } from "ws";
3042
3143import { createLogger } from "./logger";
......@@ -42,22 +54,7 @@ import { clamp } from "effect/Number";
4254import { Open } from "./open";
4355import { ServerConfig } from "./config";
4456import { GitCore } from "./git/Services/GitCore.ts";
45import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
46
47const MIME_TYPES: Record<string, string> = {
48 ".html": "text/html; charset=utf-8",
49 ".js": "application/javascript; charset=utf-8",
50 ".css": "text/css; charset=utf-8",
51 ".json": "application/json; charset=utf-8",
52 ".png": "image/png",
53 ".jpg": "image/jpeg",
54 ".svg": "image/svg+xml",
55 ".ico": "image/x-icon",
56 ".woff": "font/woff",
57 ".woff2": "font/woff2",
58 ".ttf": "font/ttf",
59 ".map": "application/json",
60};
57import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
6158
6259/**
6360 * ServerShape - Service API for server lifecycle control.
......@@ -69,7 +66,7 @@ export interface ServerShape {
6966 readonly start: Effect.Effect<
7067 http.Server,
7168 ServerLifecycleError,
72 Scope.Scope | ServerRuntimeServices | ServerConfig
69 Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path
7370 >;
7471
7572 /**
......@@ -168,7 +165,7 @@ class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("Ro
168165export const createServer = Effect.fn(function* (): Effect.fn.Return<
169166 http.Server,
170167 ServerLifecycleError,
171 Scope.Scope | ServerRuntimeServices | ServerConfig
168 Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path
172169> {
173170 const serverConfig = yield* ServerConfig;
174171 const {
......@@ -187,6 +184,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
187184 const terminalManager = yield* TerminalManager;
188185 const keybindingsManager = yield* Keybindings;
189186 const git = yield* GitCore;
187 const fileSystem = yield* FileSystem.FileSystem;
188 const path = yield* Path.Path;
190189
191190 yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe(
192191 Effect.catch((error) =>
......@@ -233,62 +232,157 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
233232
234233 // HTTP server — serves static files or redirects to Vite dev server
235234 const httpServer = http.createServer((req, res) => {
236 const url = new URL(req.url ?? "/", `http://localhost:${port}`);
235 const respond = (
236 statusCode: number,
237 headers: Record<string, string>,
238 body?: string | Uint8Array,
239 ) => {
240 res.writeHead(statusCode, headers);
241 res.end(body);
242 };
237243
238 if (tryHandleProjectFaviconRequest(url, res)) {
239 return;
240 }
244 void Effect.runPromise(
245 Effect.gen(function* () {
246 const url = new URL(req.url ?? "/", `http://localhost:${port}`);
247 if (tryHandleProjectFaviconRequest(url, res)) {
248 return;
249 }
241250
242 // In dev mode, redirect to Vite dev server
243 if (devUrl) {
244 res.writeHead(302, { Location: devUrl.href });
245 res.end();
246 return;
247 }
251 if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) {
252 const attachmentsRoot = path.resolve(path.join(serverConfig.stateDir, "attachments"));
253 const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);
254 const normalizedRelativePath = path.normalize(rawRelativePath).replace(/^[/\\]+/, "");
255
256 if (
257 normalizedRelativePath.length === 0 ||
258 normalizedRelativePath.startsWith("..") ||
259 normalizedRelativePath.includes("\0")
260 ) {
261 respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path");
262 return;
263 }
248264
249 // Serve static files from the web app build
250 if (!staticDir) {
251 res.writeHead(503, { "Content-Type": "text/plain" });
252 res.end("No static directory configured and no dev URL set.");
253 return;
254 }
265 const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath));
266 if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) {
267 respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path");
268 return;
269 }
255270
256 let filePath = path.join(staticDir, url.pathname);
271 const fileInfo = yield* fileSystem
272 .stat(filePath)
273 .pipe(Effect.catch(() => Effect.succeed(null)));
274 if (!fileInfo || fileInfo.type !== "File") {
275 respond(404, { "Content-Type": "text/plain" }, "Not Found");
276 return;
277 }
257278
258 // SPA fallback: if no file extension and not found, serve index.html
259 const ext = path.extname(filePath);
260 if (!ext) {
261 filePath = path.join(filePath, "index.html");
262 }
279 const contentType = Mime.getType(filePath) ?? "application/octet-stream";
280 res.writeHead(200, {
281 "Content-Type": contentType,
282 "Cache-Control": "public, max-age=31536000, immutable",
283 });
284 const streamExit = yield* Stream.runForEach(fileSystem.stream(filePath), (chunk) =>
285 Effect.sync(() => {
286 if (!res.destroyed) {
287 res.write(chunk);
288 }
289 }),
290 ).pipe(Effect.exit);
291 if (streamExit._tag === "Failure") {
292 if (!res.destroyed) {
293 res.destroy();
294 }
295 return;
296 }
297 if (!res.writableEnded) {
298 res.end();
299 }
300 return;
301 }
263302
264 fs.stat(filePath, (err, stats) => {
265 if (err || !stats?.isFile()) {
266 // SPA fallback
267 const indexPath = path.join(staticDir, "index.html");
268 fs.readFile(indexPath, (readErr, data) => {
269 if (readErr) {
270 res.writeHead(404, { "Content-Type": "text/plain" });
271 res.end("Not Found");
303 // In dev mode, redirect to Vite dev server
304 if (devUrl) {
305 respond(302, { Location: devUrl.href });
306 return;
307 }
308
309 // Serve static files from the web app build
310 if (!staticDir) {
311 respond(
312 503,
313 { "Content-Type": "text/plain" },
314 "No static directory configured and no dev URL set.",
315 );
316 return;
317 }
318
319 const staticRoot = path.resolve(staticDir);
320 const staticRequestPath = url.pathname === "/" ? "/index.html" : url.pathname;
321 const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, "");
322 const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith("..");
323 const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, "");
324 const hasPathTraversalSegment = staticRelativePath.startsWith("..");
325 if (
326 staticRelativePath.length === 0 ||
327 hasRawLeadingParentSegment ||
328 hasPathTraversalSegment ||
329 staticRelativePath.includes("\0")
330 ) {
331 respond(400, { "Content-Type": "text/plain" }, "Invalid static file path");
332 return;
333 }
334
335 const isWithinStaticRoot = (candidate: string) =>
336 candidate === staticRoot ||
337 candidate.startsWith(
338 staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`,
339 );
340
341 let filePath = path.resolve(staticRoot, staticRelativePath);
342 if (!isWithinStaticRoot(filePath)) {
343 respond(400, { "Content-Type": "text/plain" }, "Invalid static file path");
344 return;
345 }
346
347 const ext = path.extname(filePath);
348 if (!ext) {
349 filePath = path.resolve(filePath, "index.html");
350 if (!isWithinStaticRoot(filePath)) {
351 respond(400, { "Content-Type": "text/plain" }, "Invalid static file path");
272352 return;
273353 }
274 res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
275 res.end(data);
276 });
277 return;
278 }
354 }
279355
280 const fileExt = path.extname(filePath);
281 const contentType = MIME_TYPES[fileExt] ?? "application/octet-stream";
356 const fileInfo = yield* fileSystem
357 .stat(filePath)
358 .pipe(Effect.catch(() => Effect.succeed(null)));
359 if (!fileInfo || fileInfo.type !== "File") {
360 const indexPath = path.resolve(staticRoot, "index.html");
361 const indexData = yield* fileSystem
362 .readFile(indexPath)
363 .pipe(Effect.catch(() => Effect.succeed(null)));
364 if (!indexData) {
365 respond(404, { "Content-Type": "text/plain" }, "Not Found");
366 return;
367 }
368 respond(200, { "Content-Type": "text/html; charset=utf-8" }, indexData);
369 return;
370 }
282371
283 fs.readFile(filePath, (readErr, data) => {
284 if (readErr) {
285 res.writeHead(500, { "Content-Type": "text/plain" });
286 res.end("Internal Server Error");
372 const contentType = Mime.getType(filePath) ?? "application/octet-stream";
373 const data = yield* fileSystem
374 .readFile(filePath)
375 .pipe(Effect.catch(() => Effect.succeed(null)));
376 if (!data) {
377 respond(500, { "Content-Type": "text/plain" }, "Internal Server Error");
287378 return;
288379 }
289 res.writeHead(200, { "Content-Type": contentType });
290 res.end(data);
291 });
380 respond(200, { "Content-Type": contentType }, data);
381 }),
382 ).catch(() => {
383 if (!res.headersSent) {
384 respond(500, { "Content-Type": "text/plain" }, "Internal Server Error");
385 }
292386 });
293387 });
294388
apps/web/src/components/ChatView.tsx+460-311
......@@ -74,6 +74,8 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
7474import {
7575 BotIcon,
7676 ChevronDownIcon,
77 ChevronLeftIcon,
78 ChevronRightIcon,
7779 CircleAlertIcon,
7880 FileIcon,
7981 FolderIcon,
......@@ -111,6 +113,7 @@ import { SidebarTrigger } from "./ui/sidebar";
111113import { newCommandId, newMessageId } from "~/lib/utils";
112114import { readNativeApi } from "~/nativeApi";
113115import { useAppSettings } from "../appSettings";
116import { clamp } from "effect/Number";
114117
115118function formatMessageMeta(createdAt: string, duration: string | null): string {
116119 if (!duration) return formatTimestamp(createdAt);
......@@ -165,11 +168,36 @@ interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"
165168 file: File;
166169}
167170
168interface ExpandedImagePreview {
171interface ExpandedImageItem {
169172 src: string;
170173 name: string;
171174}
172175
176interface ExpandedImagePreview {
177 images: ExpandedImageItem[];
178 index: number;
179}
180
181function buildExpandedImagePreview(
182 images: ReadonlyArray<{ id: string; name: string; previewUrl?: string }>,
183 selectedImageId: string,
184): ExpandedImagePreview | null {
185 const previewableImages = images.flatMap((image) =>
186 image.previewUrl ? [{ id: image.id, src: image.previewUrl, name: image.name }] : [],
187 );
188 if (previewableImages.length === 0) {
189 return null;
190 }
191 const selectedIndex = previewableImages.findIndex((image) => image.id === selectedImageId);
192 if (selectedIndex < 0) {
193 return null;
194 }
195 return {
196 images: previewableImages.map((image) => ({ src: image.src, name: image.name })),
197 index: selectedIndex,
198 };
199}
200
173201type ComposerCommandItem =
174202 | {
175203 id: string;
......@@ -1070,22 +1098,53 @@ export default function ChatView({ threadId }: ChatViewProps) {
10701098 };
10711099 }, [revokePreviewUrls]);
10721100
1073 /**
1074 * Close expanded image on Escape key
1075 */
1101 const closeExpandedImage = useCallback(() => {
1102 setExpandedImage(null);
1103 }, []);
1104 const navigateExpandedImage = useCallback((direction: -1 | 1) => {
1105 setExpandedImage((existing) => {
1106 if (!existing || existing.images.length <= 1) {
1107 return existing;
1108 }
1109 const nextIndex =
1110 (existing.index + direction + existing.images.length) % existing.images.length;
1111 if (nextIndex === existing.index) {
1112 return existing;
1113 }
1114 return { ...existing, index: nextIndex };
1115 });
1116 }, []);
1117
10761118 useEffect(() => {
10771119 if (!expandedImage) {
10781120 return;
10791121 }
10801122
10811123 const onKeyDown = (event: globalThis.KeyboardEvent) => {
1082 if (event.key !== "Escape") return;
1083 setExpandedImage(null);
1124 if (event.key === "Escape") {
1125 event.preventDefault();
1126 event.stopPropagation();
1127 closeExpandedImage();
1128 return;
1129 }
1130 if (expandedImage.images.length <= 1) {
1131 return;
1132 }
1133 if (event.key === "ArrowLeft") {
1134 event.preventDefault();
1135 event.stopPropagation();
1136 navigateExpandedImage(-1);
1137 return;
1138 }
1139 if (event.key !== "ArrowRight") return;
1140 event.preventDefault();
1141 event.stopPropagation();
1142 navigateExpandedImage(1);
10841143 };
10851144
10861145 window.addEventListener("keydown", onKeyDown);
10871146 return () => window.removeEventListener("keydown", onKeyDown);
1088 }, [expandedImage]);
1147 }, [closeExpandedImage, expandedImage, navigateExpandedImage]);
10891148
10901149 const activeWorktreePath = activeThread?.worktreePath;
10911150
......@@ -1722,9 +1781,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
17221781 [groupId]: !existing[groupId],
17231782 }));
17241783 }, []);
1725 const onExpandTimelineImage = useCallback((image: ExpandedImagePreview) => {
1726 setExpandedImage(image);
1784 const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => {
1785 setExpandedImage(preview);
17271786 }, []);
1787 const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null;
17281788 const onOpenTurnDiff = useCallback(
17291789 (turnId: TurnId, filePath?: string) => {
17301790 void navigate({
......@@ -1824,6 +1884,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
18241884 <MessagesTimeline
18251885 hasMessages={timelineMessages.length > 0}
18261886 isWorking={isWorking}
1887 activeTurnInProgress={!latestTurnSettled}
1888 activeTurnStartedAt={activeLatestTurn?.startedAt ?? null}
18271889 scrollContainerRef={messagesScrollRef}
18281890 timelineEntries={timelineEntries}
18291891 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
......@@ -1885,9 +1947,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
18851947 src={image.previewUrl}
18861948 alt={image.name}
18871949 className="h-full w-full cursor-zoom-in object-cover"
1888 onClick={() =>
1889 setExpandedImage({ src: image.previewUrl, name: image.name })
1890 }
1950 onClick={() => {
1951 const preview = buildExpandedImagePreview(composerImages, image.id);
1952 if (!preview) return;
1953 setExpandedImage(preview);
1954 }}
18911955 />
18921956 ) : (
18931957 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">
......@@ -2111,14 +2175,29 @@ export default function ChatView({ threadId }: ChatViewProps) {
21112175 );
21122176 })()}
21132177
2114 {expandedImage && (
2178 {expandedImage && expandedImageItem && (
21152179 <div
21162180 className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]"
21172181 role="dialog"
21182182 aria-modal="true"
21192183 aria-label="Expanded image preview"
2120 onClick={() => setExpandedImage(null)}
2184 onClick={closeExpandedImage}
21212185 >
2186 {expandedImage.images.length > 1 && (
2187 <Button
2188 type="button"
2189 size="icon"
2190 variant="ghost"
2191 className="absolute left-2 top-1/2 z-10 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-6"
2192 aria-label="Previous image"
2193 onClick={(event) => {
2194 event.stopPropagation();
2195 navigateExpandedImage(-1);
2196 }}
2197 >
2198 <ChevronLeftIcon className="size-5" />
2199 </Button>
2200 )}
21222201 <div
21232202 className="relative isolate max-h-[92vh] max-w-[92vw]"
21242203 onClick={(event) => event.stopPropagation()}
......@@ -2128,21 +2207,39 @@ export default function ChatView({ threadId }: ChatViewProps) {
21282207 size="icon-xs"
21292208 variant="ghost"
21302209 className="absolute right-2 top-2"
2131 onClick={() => setExpandedImage(null)}
2210 onClick={closeExpandedImage}
21322211 aria-label="Close image preview"
21332212 >
21342213 <XIcon />
21352214 </Button>
21362215 <img
2137 src={expandedImage.src}
2138 alt={expandedImage.name}
2216 src={expandedImageItem.src}
2217 alt={expandedImageItem.name}
21392218 className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl"
21402219 draggable={false}
21412220 />
21422221 <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80">
2143 {expandedImage.name}
2222 {expandedImageItem.name}
2223 {expandedImage.images.length > 1
2224 ? ` (${expandedImage.index + 1}/${expandedImage.images.length})`
2225 : ""}
21442226 </p>
21452227 </div>
2228 {expandedImage.images.length > 1 && (
2229 <Button
2230 type="button"
2231 size="icon"
2232 variant="ghost"
2233 className="absolute right-2 top-1/2 z-10 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-6"
2234 aria-label="Next image"
2235 onClick={(event) => {
2236 event.stopPropagation();
2237 navigateExpandedImage(1);
2238 }}
2239 >
2240 <ChevronRightIcon className="size-5" />
2241 </Button>
2242 )}
21462243 </div>
21472244 )}
21482245 </div>
......@@ -2336,13 +2433,7 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri
23362433 }, [text]);
23372434
23382435 return (
2339 <Button
2340 type="button"
2341 size="xs"
2342 variant="outline"
2343 onClick={handleCopy}
2344 title="Copy message"
2345 >
2436 <Button type="button" size="xs" variant="outline" onClick={handleCopy} title="Copy message">
23462437 {copied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
23472438 </Button>
23482439 );
......@@ -2351,6 +2442,8 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri
23512442interface MessagesTimelineProps {
23522443 hasMessages: boolean;
23532444 isWorking: boolean;
2445 activeTurnInProgress: boolean;
2446 activeTurnStartedAt: string | null;
23542447 scrollContainerRef: React.RefObject<HTMLDivElement | null>;
23552448 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
23562449 completionDividerBeforeEntryId: string | null;
......@@ -2363,7 +2456,7 @@ interface MessagesTimelineProps {
23632456 revertTurnCountByUserMessageId: Map<MessageId, number>;
23642457 onRevertUserMessage: (messageId: MessageId) => void;
23652458 isRevertingCheckpoint: boolean;
2366 onImageExpand: (image: ExpandedImagePreview) => void;
2459 onImageExpand: (preview: ExpandedImagePreview) => void;
23672460 markdownCwd: string | undefined;
23682461}
23692462
......@@ -2374,19 +2467,23 @@ type TimelineRow =
23742467 | {
23752468 kind: "work";
23762469 id: string;
2470 createdAt: string;
23772471 groupedEntries: TimelineWorkEntry[];
23782472 }
23792473 | {
23802474 kind: "message";
23812475 id: string;
2476 createdAt: string;
23822477 message: TimelineMessage;
23832478 showCompletionDivider: boolean;
23842479 }
2385 | { kind: "working"; id: string };
2480 | { kind: "working"; id: string; createdAt: string | null };
23862481
23872482const MessagesTimeline = memo(function MessagesTimeline({
23882483 hasMessages,
23892484 isWorking,
2485 activeTurnInProgress,
2486 activeTurnStartedAt,
23902487 scrollContainerRef,
23912488 timelineEntries,
23922489 completionDividerBeforeEntryId,
......@@ -2423,6 +2520,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
24232520 nextRows.push({
24242521 kind: "work",
24252522 id: timelineEntry.id,
2523 createdAt: timelineEntry.createdAt,
24262524 groupedEntries,
24272525 });
24282526 index = cursor - 1;
......@@ -2432,6 +2530,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
24322530 nextRows.push({
24332531 kind: "message",
24342532 id: timelineEntry.id,
2533 createdAt: timelineEntry.createdAt,
24352534 message: timelineEntry.message,
24362535 showCompletionDivider:
24372536 timelineEntry.message.role === "assistant" &&
......@@ -2440,14 +2539,62 @@ const MessagesTimeline = memo(function MessagesTimeline({
24402539 }
24412540
24422541 if (isWorking) {
2443 nextRows.push({ kind: "working", id: "working-indicator-row" });
2542 nextRows.push({
2543 kind: "working",
2544 id: "working-indicator-row",
2545 createdAt: activeTurnStartedAt,
2546 });
24442547 }
24452548
24462549 return nextRows;
2447 }, [timelineEntries, completionDividerBeforeEntryId, isWorking]);
2550 }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]);
2551
2552 const firstUnvirtualizedRowIndex = useMemo(() => {
2553 if (!activeTurnInProgress) return rows.length;
2554
2555 const turnStartedAtMs =
2556 typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN;
2557 let firstCurrentTurnRowIndex = -1;
2558 if (!Number.isNaN(turnStartedAtMs)) {
2559 firstCurrentTurnRowIndex = rows.findIndex((row) => {
2560 if (row.kind === "working") return true;
2561 if (!row.createdAt) return false;
2562 const rowCreatedAtMs = Date.parse(row.createdAt);
2563 return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs;
2564 });
2565 }
2566
2567 if (firstCurrentTurnRowIndex < 0) {
2568 firstCurrentTurnRowIndex = rows.findIndex(
2569 (row) => row.kind === "message" && row.message.streaming,
2570 );
2571 }
2572
2573 if (firstCurrentTurnRowIndex < 0) {
2574 return rows.length;
2575 }
2576
2577 for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) {
2578 const previousRow = rows[index];
2579 if (!previousRow || previousRow.kind !== "message") continue;
2580 if (previousRow.message.role === "user") {
2581 return index;
2582 }
2583 if (previousRow.message.role === "assistant" && !previousRow.message.streaming) {
2584 break;
2585 }
2586 }
2587
2588 return firstCurrentTurnRowIndex;
2589 }, [activeTurnInProgress, activeTurnStartedAt, rows]);
2590
2591 const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, {
2592 minimum: 0,
2593 maximum: rows.length,
2594 });
24482595
24492596 const rowVirtualizer = useVirtualizer({
2450 count: rows.length,
2597 count: virtualizedRowCount,
24512598 getScrollElement: () => scrollContainerRef.current,
24522599 estimateSize: (index: number) => {
24532600 const row = rows[index];
......@@ -2461,303 +2608,305 @@ const MessagesTimeline = memo(function MessagesTimeline({
24612608 });
24622609
24632610 const virtualRows = rowVirtualizer.getVirtualItems();
2464
2465 if (!hasMessages && !isWorking) {
2466 return (
2467 <div className="flex h-full items-center justify-center">
2468 <p className="text-sm text-muted-foreground/30">
2469 Send a message to start the conversation.
2470 </p>
2471 </div>
2472 );
2473 }
2474
2475 return (
2476 <div
2477 className="relative mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden"
2478 style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
2479 >
2480 {virtualRows.map((virtualRow: VirtualItem) => {
2481 const row = rows[virtualRow.index];
2482 if (!row) return null;
2483
2484 return (
2485 <div
2486 key={row.id}
2487 data-index={virtualRow.index}
2488 ref={rowVirtualizer.measureElement}
2489 className="absolute left-0 top-0 w-full"
2490 style={{ transform: `translateY(${virtualRow.start}px)` }}
2491 >
2492 <div className="pb-4">
2493 {row.kind === "work" &&
2494 (() => {
2495 const groupId = row.id;
2496 const groupedEntries = row.groupedEntries;
2497 const isExpanded = expandedWorkGroups[groupId] ?? false;
2498 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
2499 const visibleEntries =
2500 hasOverflow && !isExpanded
2501 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
2502 : groupedEntries;
2503 const hiddenCount = groupedEntries.length - visibleEntries.length;
2504 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
2505 const groupLabel = onlyToolEntries
2506 ? groupedEntries.length === 1
2507 ? "Tool call"
2508 : `Tool calls (${groupedEntries.length})`
2509 : groupedEntries.length === 1
2510 ? "Work event"
2511 : `Work log (${groupedEntries.length})`;
2512
2611 const nonVirtualizedRows = rows.slice(virtualizedRowCount);
2612
2613 const renderRowContent = (row: TimelineRow) => (
2614 <div className="pb-4">
2615 {row.kind === "work" &&
2616 (() => {
2617 const groupId = row.id;
2618 const groupedEntries = row.groupedEntries;
2619 const isExpanded = expandedWorkGroups[groupId] ?? false;
2620 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
2621 const visibleEntries =
2622 hasOverflow && !isExpanded
2623 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
2624 : groupedEntries;
2625 const hiddenCount = groupedEntries.length - visibleEntries.length;
2626 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
2627 const groupLabel = onlyToolEntries
2628 ? groupedEntries.length === 1
2629 ? "Tool call"
2630 : `Tool calls (${groupedEntries.length})`
2631 : groupedEntries.length === 1
2632 ? "Work event"
2633 : `Work log (${groupedEntries.length})`;
2634
2635 return (
2636 <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">
2637 <div className="mb-1.5 flex items-center justify-between gap-3">
2638 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
2639 {groupLabel}
2640 </p>
2641 {hasOverflow && (
2642 <button
2643 type="button"
2644 className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"
2645 onClick={() => onToggleWorkGroup(groupId)}
2646 >
2647 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
2648 </button>
2649 )}
2650 </div>
2651 <div className="space-y-1">
2652 {visibleEntries.map((workEntry) => (
2653 <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5">
2654 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
2655 <p
2656 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}
2657 >
2658 {workEntry.detail ? (
2659 <>
2660 {workEntry.label}
2661 <span
2662 className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"
2663 title={workEntry.detail}
2664 >
2665 {workEntry.detail}
2666 </span>
2667 </>
2668 ) : (
2669 workEntry.label
2670 )}
2671 </p>
2672 </div>
2673 ))}
2674 </div>
2675 </div>
2676 );
2677 })()}
2678
2679 {row.kind === "message" &&
2680 row.message.role === "user" &&
2681 (() => {
2682 const userImages = row.message.attachments ?? [];
2683 const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id);
2684 return (
2685 <div className="flex justify-end">
2686 <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
2687 {userImages.length > 0 && (
2688 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
2689 {userImages.map(
2690 (image: NonNullable<TimelineMessage["attachments"]>[number]) => (
2691 <div
2692 key={image.id}
2693 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
2694 >
2695 {image.previewUrl ? (
2696 <img
2697 src={image.previewUrl}
2698 alt={image.name}
2699 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
2700 onClick={() => {
2701 const preview = buildExpandedImagePreview(userImages, image.id);
2702 if (!preview) return;
2703 onImageExpand(preview);
2704 }}
2705 />
2706 ) : (
2707 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
2708 {image.name}
2709 </div>
2710 )}
2711 </div>
2712 ),
2713 )}
2714 </div>
2715 )}
2716 {row.message.text && (
2717 <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">
2718 {row.message.text}
2719 </pre>
2720 )}
2721 <div className="mt-1.5 flex items-center justify-end gap-2">
2722 <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
2723 {row.message.text && <MessageCopyButton text={row.message.text} />}
2724 {canRevertAgentWork && (
2725 <Button
2726 type="button"
2727 size="xs"
2728 variant="outline"
2729 disabled={isRevertingCheckpoint || isWorking}
2730 onClick={() => onRevertUserMessage(row.message.id)}
2731 title="Revert to this message"
2732 >
2733 <Undo2Icon className="size-3" />
2734 </Button>
2735 )}
2736 </div>
2737 <p className="text-right text-[10px] text-muted-foreground/30">
2738 {formatTimestamp(row.message.createdAt)}
2739 </p>
2740 </div>
2741 </div>
2742 </div>
2743 );
2744 })()}
2745
2746 {row.kind === "message" &&
2747 row.message.role === "assistant" &&
2748 (() => {
2749 const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");
2750 return (
2751 <>
2752 {row.showCompletionDivider && (
2753 <div className="my-3 flex items-center gap-3">
2754 <span className="h-px flex-1 bg-border" />
2755 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
2756 {completionSummary ? `Response • ${completionSummary}` : "Response"}
2757 </span>
2758 <span className="h-px flex-1 bg-border" />
2759 </div>
2760 )}
2761 <div className="min-w-0 px-1 py-0.5">
2762 <ChatMarkdown text={messageText} cwd={markdownCwd} />
2763 {(() => {
2764 const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id);
2765 if (!turnSummary) return null;
2766 const checkpointFiles = turnSummary.files;
2767 if (checkpointFiles.length === 0) return null;
2768 const summaryStat = checkpointFiles.reduce(
2769 (acc, file) => {
2770 if (
2771 typeof file.additions !== "number" ||
2772 typeof file.deletions !== "number"
2773 ) {
2774 return acc;
2775 }
2776 return {
2777 additions: acc.additions + file.additions,
2778 deletions: acc.deletions + file.deletions,
2779 };
2780 },
2781 { additions: 0, deletions: 0 },
2782 );
2783 const changedFileCountLabel = String(checkpointFiles.length);
25132784 return (
2514 <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">
2515 <div className="mb-1.5 flex items-center justify-between gap-3">
2785 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
2786 <div className="mb-1.5 flex items-center justify-between gap-2">
25162787 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
2517 {groupLabel}
2788 <span>Changed files ({changedFileCountLabel})</span>
2789 {(summaryStat.additions > 0 || summaryStat.deletions > 0) && (
2790 <>
2791 <span className="mx-1">•</span>
2792 <span className="text-success">+{summaryStat.additions}</span>
2793 <span className="mx-0.5 text-muted-foreground/70">/</span>
2794 <span className="text-destructive">-{summaryStat.deletions}</span>
2795 </>
2796 )}
25182797 </p>
2519 {hasOverflow && (
2798 <Button
2799 type="button"
2800 size="xs"
2801 variant="outline"
2802 onClick={() =>
2803 onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)
2804 }
2805 >
2806 View diff
2807 </Button>
2808 </div>
2809 <div className="flex flex-wrap gap-1.5">
2810 {checkpointFiles.map((file) => (
25202811 <button
2812 key={`${turnSummary.turnId}:${file.path}`}
25212813 type="button"
2522 className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"
2523 onClick={() => onToggleWorkGroup(groupId)}
2814 className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90"
2815 onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)}
25242816 >
2525 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
2526 </button>
2527 )}
2528 </div>
2529 <div className="space-y-1">
2530 {visibleEntries.map((workEntry) => (
2531 <div
2532 key={`work-row:${workEntry.id}`}
2533 className="flex items-start gap-2 py-0.5"
2534 >
2535 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
2536 <p
2537 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}
2538 >
2539 {workEntry.detail ? (
2817 {(() => {
2818 const stat =
2819 typeof file.additions === "number" &&
2820 typeof file.deletions === "number"
2821 ? {
2822 additions: file.additions,
2823 deletions: file.deletions,
2824 }
2825 : null;
2826 if (!stat) {
2827 return file.path;
2828 }
2829 return (
25402830 <>
2541 {workEntry.label}
2542 <span
2543 className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"
2544 title={workEntry.detail}
2545 >
2546 {workEntry.detail}
2547 </span>
2831 <span>{file.path}</span>
2832 <span className="ml-1 text-muted-foreground/70">(</span>
2833 <span className="text-success">+{stat.additions}</span>
2834 <span className="mx-0.5 text-muted-foreground/70">/</span>
2835 <span className="text-destructive">-{stat.deletions}</span>
2836 <span className="text-muted-foreground/70">)</span>
25482837 </>
2549 ) : (
2550 workEntry.label
2551 )}
2552 </p>
2553 </div>
2838 );
2839 })()}
2840 </button>
25542841 ))}
25552842 </div>
25562843 </div>
25572844 );
25582845 })()}
2846 <p className="mt-1.5 text-[10px] text-muted-foreground/30">
2847 {formatMessageMeta(
2848 row.message.createdAt,
2849 row.message.streaming
2850 ? formatElapsed(row.message.createdAt, nowIso)
2851 : formatElapsed(row.message.createdAt, row.message.completedAt),
2852 )}
2853 </p>
2854 </div>
2855 </>
2856 );
2857 })()}
2858
2859 {row.kind === "working" && (
2860 <div className="flex items-center gap-2 py-0.5 pl-1.5">
2861 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
2862 <div className="flex items-center pt-1">
2863 <span className="inline-flex items-center gap-[3px]">
2864 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
2865 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
2866 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
2867 </span>
2868 </div>
2869 </div>
2870 )}
2871 </div>
2872 );
25592873
2560 {row.kind === "message" &&
2561 row.message.role === "user" &&
2562 (() => {
2563 const userImages = row.message.attachments ?? [];
2564 const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id);
2565 return (
2566 <div className="flex justify-end">
2567 <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
2568 {userImages.length > 0 && (
2569 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
2570 {userImages.map(
2571 (image: NonNullable<TimelineMessage["attachments"]>[number]) => (
2572 <div
2573 key={image.id}
2574 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
2575 >
2576 {image.previewUrl ? (
2577 <img
2578 src={image.previewUrl}
2579 alt={image.name}
2580 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
2581 onClick={() =>
2582 onImageExpand({ src: image.previewUrl!, name: image.name })
2583 }
2584 />
2585 ) : (
2586 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
2587 {image.name}
2588 </div>
2589 )}
2590 </div>
2591 ),
2592 )}
2593 </div>
2594 )}
2595 {row.message.text && (
2596 <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">
2597 {row.message.text}
2598 </pre>
2599 )}
2600 <div className="mt-1.5 flex items-center justify-end gap-2">
2601 <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
2602 {row.message.text && <MessageCopyButton text={row.message.text} />}
2603 {canRevertAgentWork && (
2604 <Button
2605 type="button"
2606 size="xs"
2607 variant="outline"
2608 disabled={isRevertingCheckpoint || isWorking}
2609 onClick={() => onRevertUserMessage(row.message.id)}
2610 title="Revert to this message"
2611 >
2612 <Undo2Icon className="size-3" />
2613 </Button>
2614 )}
2615 </div>
2616 <p className="text-right text-[10px] text-muted-foreground/30">
2617 {formatTimestamp(row.message.createdAt)}
2618 </p>
2619 </div>
2620 </div>
2621 </div>
2622 );
2623 })()}
2874 if (!hasMessages && !isWorking) {
2875 return (
2876 <div className="flex h-full items-center justify-center">
2877 <p className="text-sm text-muted-foreground/30">
2878 Send a message to start the conversation.
2879 </p>
2880 </div>
2881 );
2882 }
26242883
2625 {row.kind === "message" &&
2626 row.message.role === "assistant" &&
2627 (() => {
2628 const messageText =
2629 row.message.text || (row.message.streaming ? "" : "(empty response)");
2630 return (
2631 <>
2632 {row.showCompletionDivider && (
2633 <div className="my-3 flex items-center gap-3">
2634 <span className="h-px flex-1 bg-border" />
2635 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
2636 {completionSummary ? `Response • ${completionSummary}` : "Response"}
2637 </span>
2638 <span className="h-px flex-1 bg-border" />
2639 </div>
2640 )}
2641 <div className="min-w-0 px-1 py-0.5">
2642 <ChatMarkdown text={messageText} cwd={markdownCwd} />
2643 {(() => {
2644 const turnSummary = turnDiffSummaryByAssistantMessageId.get(
2645 row.message.id,
2646 );
2647 if (!turnSummary) return null;
2648 const checkpointFiles = turnSummary.files;
2649 if (checkpointFiles.length === 0) return null;
2650 const summaryStat = checkpointFiles.reduce(
2651 (acc, file) => {
2652 if (
2653 typeof file.additions !== "number" ||
2654 typeof file.deletions !== "number"
2655 ) {
2656 return acc;
2657 }
2658 return {
2659 additions: acc.additions + file.additions,
2660 deletions: acc.deletions + file.deletions,
2661 };
2662 },
2663 { additions: 0, deletions: 0 },
2664 );
2665 const changedFileCountLabel = String(checkpointFiles.length);
2666 return (
2667 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
2668 <div className="mb-1.5 flex items-center justify-between gap-2">
2669 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
2670 <span>Changed files ({changedFileCountLabel})</span>
2671 {(summaryStat.additions > 0 || summaryStat.deletions > 0) && (
2672 <>
2673 <span className="mx-1">•</span>
2674 <span className="text-success">+{summaryStat.additions}</span>
2675 <span className="mx-0.5 text-muted-foreground/70">/</span>
2676 <span className="text-destructive">
2677 -{summaryStat.deletions}
2678 </span>
2679 </>
2680 )}
2681 </p>
2682 <Button
2683 type="button"
2684 size="xs"
2685 variant="outline"
2686 onClick={() =>
2687 onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)
2688 }
2689 >
2690 View diff
2691 </Button>
2692 </div>
2693 <div className="flex flex-wrap gap-1.5">
2694 {checkpointFiles.map((file) => (
2695 <button
2696 key={`${turnSummary.turnId}:${file.path}`}
2697 type="button"
2698 className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90"
2699 onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)}
2700 >
2701 {(() => {
2702 const stat =
2703 typeof file.additions === "number" &&
2704 typeof file.deletions === "number"
2705 ? {
2706 additions: file.additions,
2707 deletions: file.deletions,
2708 }
2709 : null;
2710 if (!stat) {
2711 return file.path;
2712 }
2713 return (
2714 <>
2715 <span>{file.path}</span>
2716 <span className="ml-1 text-muted-foreground/70">(</span>
2717 <span className="text-success">+{stat.additions}</span>
2718 <span className="mx-0.5 text-muted-foreground/70">/</span>
2719 <span className="text-destructive">
2720 -{stat.deletions}
2721 </span>
2722 <span className="text-muted-foreground/70">)</span>
2723 </>
2724 );
2725 })()}
2726 </button>
2727 ))}
2728 </div>
2729 </div>
2730 );
2731 })()}
2732 <p className="mt-1.5 text-[10px] text-muted-foreground/30">
2733 {formatMessageMeta(
2734 row.message.createdAt,
2735 row.message.streaming
2736 ? formatElapsed(row.message.createdAt, nowIso)
2737 : formatElapsed(row.message.createdAt, row.message.completedAt),
2738 )}
2739 </p>
2740 </div>
2741 </>
2742 );
2743 })()}
2884 return (
2885 <div className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden">
2886 {virtualizedRowCount > 0 && (
2887 <div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
2888 {virtualRows.map((virtualRow: VirtualItem) => {
2889 const row = rows[virtualRow.index];
2890 if (!row) return null;
2891
2892 return (
2893 <div
2894 key={`virtual-row:${row.id}`}
2895 data-index={virtualRow.index}
2896 ref={rowVirtualizer.measureElement}
2897 className="absolute left-0 top-0 w-full"
2898 style={{ transform: `translateY(${virtualRow.start}px)` }}
2899 >
2900 {renderRowContent(row)}
2901 </div>
2902 );
2903 })}
2904 </div>
2905 )}
27442906
2745 {row.kind === "working" && (
2746 <div className="flex items-center gap-2 py-0.5 pl-1.5">
2747 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
2748 <div className="flex items-center pt-1">
2749 <span className="inline-flex items-center gap-[3px]">
2750 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
2751 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
2752 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
2753 </span>
2754 </div>
2755 </div>
2756 )}
2757 </div>
2758 </div>
2759 );
2760 })}
2907 {nonVirtualizedRows.map((row) => (
2908 <div key={`non-virtual-row:${row.id}`}>{renderRowContent(row)}</div>
2909 ))}
27612910 </div>
27622911 );
27632912});
apps/web/src/store.ts+38-1
......@@ -192,6 +192,43 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" {
192192 return providerName === "claudeCode" ? "claudeCode" : "codex";
193193}
194194
195function resolveWsHttpOrigin(): string {
196 if (typeof window === "undefined") {
197 return "";
198 }
199 const bridgeWsUrl = window.desktopBridge?.getWsUrl?.();
200 const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined;
201 const wsCandidate =
202 typeof bridgeWsUrl === "string" && bridgeWsUrl.length > 0
203 ? bridgeWsUrl
204 : typeof envWsUrl === "string" && envWsUrl.length > 0
205 ? envWsUrl
206 : null;
207 if (!wsCandidate) {
208 return window.location.origin;
209 }
210
211 try {
212 const wsUrl = new URL(wsCandidate);
213 const protocol =
214 wsUrl.protocol === "wss:"
215 ? "https:"
216 : wsUrl.protocol === "ws:"
217 ? "http:"
218 : wsUrl.protocol;
219 return `${protocol}//${wsUrl.host}`;
220 } catch {
221 return window.location.origin;
222 }
223}
224
225function toAttachmentPreviewUrl(rawUrl: string): string {
226 if (rawUrl.startsWith("/")) {
227 return `${resolveWsHttpOrigin()}${rawUrl}`;
228 }
229 return rawUrl;
230}
231
195232function normalizeTerminalIds(terminalIds: string[]): string[] {
196233 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);
197234 const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT);
......@@ -456,7 +493,7 @@ export function reducer(state: AppState, action: Action): AppState {
456493 name: attachment.name,
457494 mimeType: attachment.mimeType,
458495 sizeBytes: attachment.sizeBytes,
459 previewUrl: attachment.dataUrl,
496 previewUrl: toAttachmentPreviewUrl(attachment.dataUrl),
460497 }));
461498 const normalizedMessage: ChatMessage = {
462499 id: message.id,