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* () {...@@ -215,14 +215,13 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
215 Layer.provide(providerSessionDirectoryLayer),215 Layer.provide(providerSessionDirectoryLayer),
216 Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)),216 Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)),
217 );217 );
218 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));
219218
220 const runtimeServicesLayer = Layer.mergeAll(219 const runtimeServicesLayer = Layer.mergeAll(
221 orchestrationLayer,220 orchestrationLayer,
222 OrchestrationProjectionSnapshotQueryLive,221 OrchestrationProjectionSnapshotQueryLive,
223 ProjectionCheckpointRepositoryLive,222 ProjectionCheckpointRepositoryLive,
224 ProjectionPendingApprovalRepositoryLive,223 ProjectionPendingApprovalRepositoryLive,
225 checkpointStoreLayer,224 CheckpointStoreLive,
226 providerLayer,225 providerLayer,
227 );226 );
228 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(227 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+6-3
...@@ -233,11 +233,12 @@ describe("CheckpointReactor", () => {...@@ -233,11 +233,12 @@ describe("CheckpointReactor", () => {
233 Layer.provide(OrchestrationCommandReceiptRepositoryLive),233 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
234 Layer.provide(SqlitePersistenceMemory),234 Layer.provide(SqlitePersistenceMemory),
235 );235 );
236 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));236
237 const layer = CheckpointReactorLive.pipe(237 const layer = CheckpointReactorLive.pipe(
238 Layer.provideMerge(orchestrationLayer),238 Layer.provideMerge(orchestrationLayer),
239 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),239 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
240 Layer.provideMerge(checkpointStoreLayer),240 Layer.provideMerge(CheckpointStoreLive),
241 Layer.provideMerge(NodeServices.layer),
241 );242 );
242243
243 runtime = ManagedRuntime.make(layer);244 runtime = ManagedRuntime.make(layer);
...@@ -437,7 +438,9 @@ describe("CheckpointReactor", () => {...@@ -437,7 +438,9 @@ describe("CheckpointReactor", () => {
437438
438 await Effect.runPromise(Effect.sleep("40 millis"));439 await Effect.runPromise(Effect.sleep("40 millis"));
439 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());440 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 );
441 expect(midThread?.checkpoints).toHaveLength(0);444 expect(midThread?.checkpoints).toHaveLength(0);
442445
443 harness.provider.emit({446 harness.provider.emit({
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+996
...@@ -32,6 +32,7 @@ import {...@@ -32,6 +32,7 @@ import {
32} from "./ProjectionPipeline.ts";32} from "./ProjectionPipeline.ts";
33import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";33import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
34import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";34import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
35import { ServerConfig, type ServerConfigShape } from "../../config.ts";
3536
36const projectionLayer = it.layer(37const projectionLayer = it.layer(
37 OrchestrationProjectionPipelineLive.pipe(38 OrchestrationProjectionPipelineLive.pipe(
...@@ -158,6 +159,1001 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -158,6 +159,1001 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
158 }),159 }),
159 );160 );
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
161 it.effect("resumes from projector last_applied_sequence without replaying older events", () =>1157 it.effect("resumes from projector last_applied_sequence without replaying older events", () =>
162 Effect.gen(function* () {1158 Effect.gen(function* () {
163 const projectionPipeline = yield* OrchestrationProjectionPipeline;1159 const projectionPipeline = yield* OrchestrationProjectionPipeline;
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+334-18
...@@ -1,5 +1,11 @@...@@ -1,5 +1,11 @@
1import { ApprovalRequestId, type OrchestrationEvent } from "@t3tools/contracts";1import Mime from "@effect/platform-node/Mime";
2import { Effect, Layer, Option, Stream } from "effect";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";
3import * as SqlClient from "effect/unstable/sql/SqlClient";9import * as SqlClient from "effect/unstable/sql/SqlClient";
410
5import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts";11import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts";
...@@ -27,10 +33,16 @@ import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/...@@ -27,10 +33,16 @@ import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/
27import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts";33import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts";
28import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";34import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
29import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts";35import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts";
36import { ServerConfig } from "../../config.ts";
30import {37import {
31 OrchestrationProjectionPipeline,38 OrchestrationProjectionPipeline,
32 type OrchestrationProjectionPipelineShape,39 type OrchestrationProjectionPipelineShape,
33} from "../Services/ProjectionPipeline.ts";40} 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
35export const ORCHESTRATION_PROJECTOR_NAMES = {47export const ORCHESTRATION_PROJECTOR_NAMES = {
36 projects: "projection.projects",48 projects: "projection.projects",
...@@ -48,9 +60,122 @@ type ProjectorName =...@@ -48,9 +60,122 @@ type ProjectorName =
4860
49interface ProjectorDefinition {61interface ProjectorDefinition {
50 readonly name: ProjectorName;62 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";
52}117}
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
54function extractActivityRequestId(payload: unknown): ApprovalRequestId | null {179function extractActivityRequestId(payload: unknown): ApprovalRequestId | null {
55 if (typeof payload !== "object" || payload === null) {180 if (typeof payload !== "object" || payload === null) {
56 return null;181 return null;
...@@ -163,6 +288,138 @@ function retainProjectionActivitiesAfterRevert(...@@ -163,6 +288,138 @@ function retainProjectionActivitiesAfterRevert(
163 );288 );
164}289}
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
166const makeOrchestrationProjectionPipeline = Effect.gen(function* () {423const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
167 const sql = yield* SqlClient.SqlClient;424 const sql = yield* SqlClient.SqlClient;
168 const eventStore = yield* OrchestrationEventStore;425 const eventStore = yield* OrchestrationEventStore;
...@@ -175,7 +432,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -175,7 +432,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
175 const projectionTurnRepository = yield* ProjectionTurnRepository;432 const projectionTurnRepository = yield* ProjectionTurnRepository;
176 const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository;433 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) =>
179 Effect.gen(function* () {439 Effect.gen(function* () {
180 switch (event.type) {440 switch (event.type) {
181 case "project.created":441 case "project.created":
...@@ -233,7 +493,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -233,7 +493,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
233 }493 }
234 });494 });
235495
236 const applyThreadsProjection: ProjectorDefinition["apply"] = (event) =>496 const applyThreadsProjection: ProjectorDefinition["apply"] = (event, attachmentSideEffects) =>
237 Effect.gen(function* () {497 Effect.gen(function* () {
238 switch (event.type) {498 switch (event.type) {
239 case "thread.created":499 case "thread.created":
...@@ -272,6 +532,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -272,6 +532,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
272 }532 }
273533
274 case "thread.deleted": {534 case "thread.deleted": {
535 attachmentSideEffects.deletedThreadIds.add(event.payload.threadId);
275 const existingRow = yield* projectionThreadRepository.getById({536 const existingRow = yield* projectionThreadRepository.getById({
276 threadId: event.payload.threadId,537 threadId: event.payload.threadId,
277 });538 });
...@@ -351,7 +612,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -351,7 +612,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
351 }612 }
352 });613 });
353614
354 const applyThreadMessagesProjection: ProjectorDefinition["apply"] = (event) =>615 const applyThreadMessagesProjection: ProjectorDefinition["apply"] = (
616 event,
617 attachmentSideEffects,
618 ) =>
355 Effect.gen(function* () {619 Effect.gen(function* () {
356 switch (event.type) {620 switch (event.type) {
357 case "thread.message-sent": {621 case "thread.message-sent": {
...@@ -367,12 +631,24 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -367,12 +631,24 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
367 : existingMessage && event.payload.text.length === 0631 : existingMessage && event.payload.text.length === 0
368 ? existingMessage.text632 ? existingMessage.text
369 : event.payload.text;633 : 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;
370 yield* projectionThreadMessageRepository.upsert({645 yield* projectionThreadMessageRepository.upsert({
371 messageId: event.payload.messageId,646 messageId: event.payload.messageId,
372 threadId: event.payload.threadId,647 threadId: event.payload.threadId,
373 turnId: event.payload.turnId,648 turnId: event.payload.turnId,
374 role: event.payload.role,649 role: event.payload.role,
375 text: nextText,650 text: nextText,
651 ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}),
376 isStreaming: event.payload.streaming,652 isStreaming: event.payload.streaming,
377 createdAt: existingMessage?.createdAt ?? event.payload.createdAt,653 createdAt: existingMessage?.createdAt ?? event.payload.createdAt,
378 updatedAt: event.payload.updatedAt,654 updatedAt: event.payload.updatedAt,
...@@ -406,6 +682,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -406,6 +682,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
406 yield* Effect.forEach(keptRows, projectionThreadMessageRepository.upsert, {682 yield* Effect.forEach(keptRows, projectionThreadMessageRepository.upsert, {
407 concurrency: 1,683 concurrency: 1,
408 }).pipe(Effect.asVoid);684 }).pipe(Effect.asVoid);
685 attachmentSideEffects.prunedThreadRelativePaths.set(
686 event.payload.threadId,
687 collectThreadAttachmentRelativePaths(event.payload.threadId, keptRows),
688 );
409 return;689 return;
410 }690 }
411691
...@@ -414,7 +694,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -414,7 +694,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
414 }694 }
415 });695 });
416696
417 const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = (event) =>697 const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = (
698 event,
699 _attachmentSideEffects,
700 ) =>
418 Effect.gen(function* () {701 Effect.gen(function* () {
419 switch (event.type) {702 switch (event.type) {
420 case "thread.activity-appended":703 case "thread.activity-appended":
...@@ -462,7 +745,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -462,7 +745,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
462 }745 }
463 });746 });
464747
465 const applyThreadSessionsProjection: ProjectorDefinition["apply"] = (event) =>748 const applyThreadSessionsProjection: ProjectorDefinition["apply"] = (
749 event,
750 _attachmentSideEffects,
751 ) =>
466 Effect.gen(function* () {752 Effect.gen(function* () {
467 if (event.type !== "thread.session-set") {753 if (event.type !== "thread.session-set") {
468 return;754 return;
...@@ -481,7 +767,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -481,7 +767,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
481 });767 });
482 });768 });
483769
484 const applyThreadTurnsProjection: ProjectorDefinition["apply"] = (event) =>770 const applyThreadTurnsProjection: ProjectorDefinition["apply"] = (
771 event,
772 _attachmentSideEffects,
773 ) =>
485 Effect.gen(function* () {774 Effect.gen(function* () {
486 switch (event.type) {775 switch (event.type) {
487 case "thread.turn-start-requested": {776 case "thread.turn-start-requested": {
...@@ -714,7 +1003,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -714,7 +1003,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
7141003
715 const applyCheckpointsProjection: ProjectorDefinition["apply"] = () => Effect.void;1004 const applyCheckpointsProjection: ProjectorDefinition["apply"] = () => Effect.void;
7161005
717 const applyPendingApprovalsProjection: ProjectorDefinition["apply"] = (event) =>1006 const applyPendingApprovalsProjection: ProjectorDefinition["apply"] = (
1007 event,
1008 _attachmentSideEffects,
1009 ) =>
718 Effect.gen(function* () {1010 Effect.gen(function* () {
719 switch (event.type) {1011 switch (event.type) {
720 case "thread.activity-appended": {1012 case "thread.activity-appended": {
...@@ -837,17 +1129,40 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -837,17 +1129,40 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
837 ];1129 ];
8381130
839 const runProjectorForEvent = (projector: ProjectorDefinition, event: OrchestrationEvent) =>1131 const runProjectorForEvent = (projector: ProjectorDefinition, event: OrchestrationEvent) =>
840 sql.withTransaction(1132 Effect.gen(function* () {
841 projector.apply(event).pipe(1133 const attachmentSideEffects: AttachmentSideEffects = {
842 Effect.flatMap(() =>1134 writes: [],
843 projectionStateRepository.upsert({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", {
844 projector: projector.name,1158 projector: projector.name,
845 lastAppliedSequence: event.sequence,1159 sequence: event.sequence,
846 updatedAt: event.occurredAt,1160 eventType: event.type,
1161 cause,
847 }),1162 }),
848 ),1163 ),
849 ),1164 );
850 );1165 });
8511166
852 const bootstrapProjector = (projector: ProjectorDefinition) =>1167 const bootstrapProjector = (projector: ProjectorDefinition) =>
853 projectionStateRepository1168 projectionStateRepository
...@@ -901,6 +1216,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect(...@@ -901,6 +1216,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect(
901 OrchestrationProjectionPipeline,1216 OrchestrationProjectionPipeline,
902 makeOrchestrationProjectionPipeline,1217 makeOrchestrationProjectionPipeline,
903).pipe(1218).pipe(
1219 Layer.provideMerge(NodeServices.layer),
904 Layer.provideMerge(ProjectionProjectRepositoryLive),1220 Layer.provideMerge(ProjectionProjectRepositoryLive),
905 Layer.provideMerge(ProjectionThreadRepositoryLive),1221 Layer.provideMerge(ProjectionThreadRepositoryLive),
906 Layer.provideMerge(ProjectionThreadMessageRepositoryLive),1222 Layer.provideMerge(ProjectionThreadMessageRepositoryLive),
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+5-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 ChatAttachment,
2 IsoDateTime,3 IsoDateTime,
3 MessageId,4 MessageId,
4 OrchestrationCheckpointFile,5 OrchestrationCheckpointFile,
...@@ -42,12 +43,13 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(...@@ -42,12 +43,13 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
42 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),43 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
43 }),44 }),
44);45);
45const ProjectionThreadDbRowSchema = ProjectionThread;
46const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(46const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(
47 Struct.assign({47 Struct.assign({
48 isStreaming: Schema.Number,48 isStreaming: Schema.Number,
49 attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))),
49 }),50 }),
50);51);
52const ProjectionThreadDbRowSchema = ProjectionThread;
51const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(53const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
52 Struct.assign({54 Struct.assign({
53 payload: Schema.fromJsonString(Schema.Unknown),55 payload: Schema.fromJsonString(Schema.Unknown),
...@@ -171,6 +173,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -171,6 +173,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
171 turn_id AS "turnId",173 turn_id AS "turnId",
172 role,174 role,
173 text,175 text,
176 attachments_json AS "attachments",
174 is_streaming AS "isStreaming",177 is_streaming AS "isStreaming",
175 created_at AS "createdAt",178 created_at AS "createdAt",
176 updated_at AS "updatedAt"179 updated_at AS "updatedAt"
...@@ -376,6 +379,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -376,6 +379,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
376 id: row.messageId,379 id: row.messageId,
377 role: row.role,380 role: row.role,
378 text: row.text,381 text: row.text,
382 ...(row.attachments !== null ? { attachments: row.attachments } : {}),
379 turnId: row.turnId,383 turnId: row.turnId,
380 streaming: row.isStreaming === 1,384 streaming: row.isStreaming === 1,
381 createdAt: row.createdAt,385 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 @@...@@ -1,6 +1,7 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer, Schema, Struct } from "effect";3import { Effect, Layer, Schema, Struct } from "effect";
4import { ChatAttachment } from "@t3tools/contracts";
45
5import { toPersistenceSqlError } from "../Errors.ts";6import { toPersistenceSqlError } from "../Errors.ts";
6import {7import {
...@@ -14,6 +15,7 @@ import {...@@ -14,6 +15,7 @@ import {
14const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(15const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(
15 Struct.assign({16 Struct.assign({
16 isStreaming: Schema.Number,17 isStreaming: Schema.Number,
18 attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))),
17 }),19 }),
18);20);
1921
...@@ -22,14 +24,17 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {...@@ -22,14 +24,17 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {
2224
23 const upsertProjectionThreadMessageRow = SqlSchema.void({25 const upsertProjectionThreadMessageRow = SqlSchema.void({
24 Request: ProjectionThreadMessage,26 Request: ProjectionThreadMessage,
25 execute: (row) =>27 execute: (row) => {
26 sql`28 const nextAttachmentsJson =
29 row.attachments !== undefined ? JSON.stringify(row.attachments) : null;
30 return sql`
27 INSERT INTO projection_thread_messages (31 INSERT INTO projection_thread_messages (
28 message_id,32 message_id,
29 thread_id,33 thread_id,
30 turn_id,34 turn_id,
31 role,35 role,
32 text,36 text,
37 attachments_json,
33 is_streaming,38 is_streaming,
34 created_at,39 created_at,
35 updated_at40 updated_at
...@@ -40,6 +45,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {...@@ -40,6 +45,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {
40 ${row.turnId},45 ${row.turnId},
41 ${row.role},46 ${row.role},
42 ${row.text},47 ${row.text},
48 COALESCE(
49 ${nextAttachmentsJson},
50 (
51 SELECT attachments_json
52 FROM projection_thread_messages
53 WHERE message_id = ${row.messageId}
54 )
55 ),
43 ${row.isStreaming ? 1 : 0},56 ${row.isStreaming ? 1 : 0},
44 ${row.createdAt},57 ${row.createdAt},
45 ${row.updatedAt}58 ${row.updatedAt}
...@@ -50,10 +63,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {...@@ -50,10 +63,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {
50 turn_id = excluded.turn_id,63 turn_id = excluded.turn_id,
51 role = excluded.role,64 role = excluded.role,
52 text = excluded.text,65 text = excluded.text,
66 attachments_json = COALESCE(
67 excluded.attachments_json,
68 projection_thread_messages.attachments_json
69 ),
53 is_streaming = excluded.is_streaming,70 is_streaming = excluded.is_streaming,
54 created_at = excluded.created_at,71 created_at = excluded.created_at,
55 updated_at = excluded.updated_at72 updated_at = excluded.updated_at
56 `,73 `;
74 },
57 });75 });
5876
59 const listProjectionThreadMessageRows = SqlSchema.findAll({77 const listProjectionThreadMessageRows = SqlSchema.findAll({
...@@ -67,6 +85,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {...@@ -67,6 +85,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {
67 turn_id AS "turnId",85 turn_id AS "turnId",
68 role,86 role,
69 text,87 text,
88 attachments_json AS "attachments",
70 is_streaming AS "isStreaming",89 is_streaming AS "isStreaming",
71 created_at AS "createdAt",90 created_at AS "createdAt",
72 updated_at AS "updatedAt"91 updated_at AS "updatedAt"
...@@ -97,8 +116,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {...@@ -97,8 +116,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () {
97 ),116 ),
98 Effect.map((rows) =>117 Effect.map((rows) =>
99 rows.map((row) => ({118 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,
101 isStreaming: row.isStreaming === 1,124 isStreaming: row.isStreaming === 1,
125 createdAt: row.createdAt,
126 updatedAt: row.updatedAt,
127 ...(row.attachments !== null ? { attachments: row.attachments } : {}),
102 })),128 })),
103 ),129 ),
104 );130 );
apps/server/src/persistence/Migrations.ts+2
...@@ -18,6 +18,7 @@ import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts";...@@ -18,6 +18,7 @@ import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts";
18import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts";18import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts";
19import Migration0005 from "./Migrations/005_Projections.ts";19import Migration0005 from "./Migrations/005_Projections.ts";
20import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts";20import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts";
21import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts";
2122
22/**23/**
23 * Migration loader with all migrations defined inline.24 * Migration loader with all migrations defined inline.
...@@ -36,6 +37,7 @@ const loader = Migrator.fromRecord({...@@ -36,6 +37,7 @@ const loader = Migrator.fromRecord({
36 "4_ProviderSessionRuntime": Migration0004,37 "4_ProviderSessionRuntime": Migration0004,
37 "5_Projections": Migration0005,38 "5_Projections": Migration0005,
38 "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006,39 "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006,
40 "7_ProjectionThreadMessageAttachments": Migration0007,
39});41});
4042
41/**43/**
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 @@...@@ -7,6 +7,7 @@
7 * @module ProjectionThreadMessageRepository7 * @module ProjectionThreadMessageRepository
8 */8 */
9import {9import {
10 ChatAttachment,
10 OrchestrationMessageRole,11 OrchestrationMessageRole,
11 MessageId,12 MessageId,
12 ThreadId,13 ThreadId,
...@@ -24,6 +25,7 @@ export const ProjectionThreadMessage = Schema.Struct({...@@ -24,6 +25,7 @@ export const ProjectionThreadMessage = Schema.Struct({
24 turnId: Schema.NullOr(TurnId),25 turnId: Schema.NullOr(TurnId),
25 role: OrchestrationMessageRole,26 role: OrchestrationMessageRole,
26 text: Schema.String,27 text: Schema.String,
28 attachments: Schema.optional(Schema.Array(ChatAttachment)),
27 isStreaming: Schema.Boolean,29 isStreaming: Schema.Boolean,
28 createdAt: IsoDateTime,30 createdAt: IsoDateTime,
29 updatedAt: IsoDateTime,31 updatedAt: IsoDateTime,
apps/server/src/projectFaviconRoute.ts+26
...@@ -2,6 +2,32 @@ import fs from "node:fs";...@@ -2,6 +2,32 @@ import fs from "node:fs";
2import http from "node:http";2import http from "node:http";
3import path from "node:path";3import 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
5const FAVICON_MIME_TYPES: Record<string, string> = {31const FAVICON_MIME_TYPES: Record<string, string> = {
6 ".png": "image/png",32 ".png": "image/png",
7 ".jpg": "image/jpeg",33 ".jpg": "image/jpeg",
apps/server/src/provider/Layers/ProviderService.test.ts+34
...@@ -378,6 +378,7 @@ it.effect(...@@ -378,6 +378,7 @@ it.effect(
378 [378 [
379 {379 {
380 provider: "codex",380 provider: "codex",
381 cwd: "/tmp/project",
381 resumeThreadId: startedSession.threadId,382 resumeThreadId: startedSession.threadId,
382 },383 },
383 ],384 ],
...@@ -465,6 +466,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -465,6 +466,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
465 [466 [
466 {467 {
467 provider: "codex",468 provider: "codex",
469 cwd: "/tmp/project",
468 resumeThreadId: initial.threadId,470 resumeThreadId: initial.threadId,
469 },471 },
470 ],472 ],
...@@ -475,6 +477,38 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -475,6 +477,38 @@ routing.layer("ProviderServiceLive routing", (it) => {
475 }),477 }),
476 );478 );
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
478 it.effect("stops all sessions and clears adapter runtime", () =>512 it.effect("stops all sessions and clears adapter runtime", () =>
479 Effect.gen(function* () {513 Effect.gen(function* () {
480 const provider = yield* ProviderService;514 const provider = yield* ProviderService;
apps/server/src/provider/Layers/ProviderService.ts+15
...@@ -93,6 +93,18 @@ function toRuntimePayloadFromSession(session: ProviderSession): Record<string, u...@@ -93,6 +93,18 @@ function toRuntimePayloadFromSession(session: ProviderSession): Record<string, u
93 };93 };
94}94}
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
96const makeProviderService = (options?: ProviderServiceLiveOptions) =>108const makeProviderService = (options?: ProviderServiceLiveOptions) =>
97 Effect.gen(function* () {109 Effect.gen(function* () {
98 const canonicalEventLogger =110 const canonicalEventLogger =
...@@ -266,8 +278,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -266,8 +278,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
266 );278 );
267 }279 }
268280
281 const persistedCwd = readPersistedCwd(input.binding.runtimePayload);
282
269 const resumed = yield* adapter.startSession({283 const resumed = yield* adapter.startSession({
270 provider: input.binding.provider,284 provider: input.binding.provider,
285 ...(persistedCwd ? { cwd: persistedCwd } : {}),
271 ...(resumeThreadId ? { resumeThreadId } : {}),286 ...(resumeThreadId ? { resumeThreadId } : {}),
272 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),287 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),
273 });288 });
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+2-1
...@@ -26,6 +26,7 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli...@@ -26,6 +26,7 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli
26 return Layer.mergeAll(26 return Layer.mergeAll(
27 runtimeRepositoryLayer,27 runtimeRepositoryLayer,
28 ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)),28 ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)),
29 NodeServices.layer,
29 );30 );
30}31}
3132
...@@ -223,5 +224,5 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -223,5 +224,5 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
223 }).pipe(Effect.provide(directoryLayer));224 }).pipe(Effect.provide(directoryLayer));
224225
225 fs.rmSync(tempDir, { recursive: true, force: true });226 fs.rmSync(tempDir, { recursive: true, force: true });
226 }).pipe(Effect.provide(NodeServices.layer)));227 }));
227});228});
apps/server/src/serverLayers.ts+3-3
...@@ -61,16 +61,16 @@ export function makeServerRuntimeServicesLayer() {...@@ -61,16 +61,16 @@ export function makeServerRuntimeServicesLayer() {
61 Layer.provide(OrchestrationEventStoreLive),61 Layer.provide(OrchestrationEventStoreLive),
62 Layer.provide(OrchestrationCommandReceiptRepositoryLive),62 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
63 );63 );
64 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));64
65 const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe(65 const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe(
66 Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),66 Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),
67 Layer.provideMerge(checkpointStoreLayer),67 Layer.provideMerge(CheckpointStoreLive),
68 );68 );
6969
70 const runtimeServicesLayer = Layer.mergeAll(70 const runtimeServicesLayer = Layer.mergeAll(
71 orchestrationLayer,71 orchestrationLayer,
72 OrchestrationProjectionSnapshotQueryLive,72 OrchestrationProjectionSnapshotQueryLive,
73 checkpointStoreLayer,73 CheckpointStoreLive,
74 checkpointDiffQueryLayer,74 checkpointDiffQueryLayer,
75 );75 );
76 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(76 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
apps/server/src/wsServer.test.ts+116-7
...@@ -4,7 +4,7 @@ import os from "node:os";...@@ -4,7 +4,7 @@ import os from "node:os";
4import path from "node:path";4import path from "node:path";
55
6import * as NodeServices from "@effect/platform-node/NodeServices";6import * 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";
8import { describe, expect, it, afterEach, vi } from "vitest";8import { describe, expect, it, afterEach, vi } from "vitest";
9import { createServer } from "./wsServer";9import { createServer } from "./wsServer";
10import WebSocket from "ws";10import WebSocket from "ws";
...@@ -40,13 +40,14 @@ import type {...@@ -40,13 +40,14 @@ import type {
40} from "@t3tools/contracts";40} from "@t3tools/contracts";
41import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager";41import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager";
42import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";42import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
43import { SqlClient } from "effect/unstable/sql";43import { SqlClient, SqlError } from "effect/unstable/sql";
44import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";44import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
45import { Open, type OpenShape } from "./open";45import { Open, type OpenShape } from "./open";
46import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";46import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
47import type { GitCoreShape } from "./git/Services/GitCore.ts";47import type { GitCoreShape } from "./git/Services/GitCore.ts";
48import { GitCore } from "./git/Services/GitCore.ts";48import { GitCore } from "./git/Services/GitCore.ts";
49import { GitCommandError, GitManagerError } from "./git/Errors.ts";49import { GitCommandError, GitManagerError } from "./git/Errors.ts";
50import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";
5051
51interface PendingMessages {52interface PendingMessages {
52 queue: unknown[];53 queue: unknown[];
...@@ -296,6 +297,36 @@ async function waitForPush(...@@ -296,6 +297,36 @@ async function waitForPush(
296 return take(maxMessages);297 return take(maxMessages);
297}298}
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
299function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {330function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {
300 const resolved: Array<ResolvedKeybindingsConfig[number]> = [];331 const resolved: Array<ResolvedKeybindingsConfig[number]> = [];
301 for (const binding of bindings) {332 for (const binding of bindings) {
...@@ -324,13 +355,17 @@ describe("WebSocket Server", () => {...@@ -324,13 +355,17 @@ describe("WebSocket Server", () => {
324355
325 async function createTestServer(356 async function createTestServer(
326 options: {357 options: {
327 persistenceLayer?: Layer.Layer<SqlClient.SqlClient, never>;358 persistenceLayer?: Layer.Layer<
359 SqlClient.SqlClient,
360 SqlError.SqlError | MigrationError | PlatformError.PlatformError
361 >;
328 cwd?: string;362 cwd?: string;
329 autoBootstrapProjectFromCwd?: boolean;363 autoBootstrapProjectFromCwd?: boolean;
330 logWebSocketEvents?: boolean;364 logWebSocketEvents?: boolean;
331 devUrl?: string;365 devUrl?: string;
332 authToken?: string;366 authToken?: string;
333 stateDir?: string;367 stateDir?: string;
368 staticDir?: string;
334 providerLayer?: Layer.Layer<ProviderService, never>;369 providerLayer?: Layer.Layer<ProviderService, never>;
335 open?: OpenShape;370 open?: OpenShape;
336 gitManager?: GitManagerShape;371 gitManager?: GitManagerShape;
...@@ -354,7 +389,7 @@ describe("WebSocket Server", () => {...@@ -354,7 +389,7 @@ describe("WebSocket Server", () => {
354 cwd: options.cwd ?? "/test/project",389 cwd: options.cwd ?? "/test/project",
355 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),390 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
356 stateDir,391 stateDir,
357 staticDir: undefined,392 staticDir: options.staticDir,
358 devUrl: options.devUrl ? new URL(options.devUrl) : undefined,393 devUrl: options.devUrl ? new URL(options.devUrl) : undefined,
359 noBrowser: true,394 noBrowser: true,
360 authToken: options.authToken,395 authToken: options.authToken,
...@@ -438,6 +473,80 @@ describe("WebSocket Server", () => {...@@ -438,6 +473,80 @@ describe("WebSocket Server", () => {
438 });473 });
439 });474 });
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
441 it("bootstraps the cwd project on startup when enabled", async () => {550 it("bootstraps the cwd project on startup when enabled", async () => {
442 server = await createTestServer({551 server = await createTestServer({
443 cwd: "/test/bootstrap-workspace",552 cwd: "/test/bootstrap-workspace",
...@@ -509,9 +618,9 @@ describe("WebSocket Server", () => {...@@ -509,9 +618,9 @@ describe("WebSocket Server", () => {
509618
510 it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => {619 it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => {
511 const stateDir = makeTempDir("t3code-state-bootstrap-existing-");620 const stateDir = makeTempDir("t3code-state-bootstrap-existing-");
512 const persistenceLayer = makeSqlitePersistenceLive(621 const persistenceLayer = makeSqlitePersistenceLive(path.join(stateDir, "state.sqlite")).pipe(
513 path.join(stateDir, "state.sqlite"),622 Layer.provide(NodeServices.layer),
514 ).pipe(Layer.provide(NodeServices.layer)) as unknown as Layer.Layer<SqlClient.SqlClient, never>;623 );
515 const cwd = "/test/bootstrap-existing";624 const cwd = "/test/bootstrap-existing";
516625
517 server = await createTestServer({626 server = await createTestServer({
apps/server/src/wsServer.ts+159-65
...@@ -6,11 +6,10 @@...@@ -6,11 +6,10 @@
6 *6 *
7 * @module Server7 * @module Server
8 */8 */
9import fs from "node:fs";
10import http from "node:http";9import http from "node:http";
11import path from "node:path";
12import type { Duplex } from "node:stream";10import type { Duplex } from "node:stream";
1311
12import Mime from "@effect/platform-node/Mime";
14import {13import {
15 CommandId,14 CommandId,
16 ORCHESTRATION_WS_CHANNELS,15 ORCHESTRATION_WS_CHANNELS,
...@@ -25,7 +24,20 @@ import {...@@ -25,7 +24,20 @@ import {
25 WsResponse,24 WsResponse,
26} from "@t3tools/contracts";25} from "@t3tools/contracts";
27import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";26import * 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";
29import { WebSocketServer, type WebSocket } from "ws";41import { WebSocketServer, type WebSocket } from "ws";
3042
31import { createLogger } from "./logger";43import { createLogger } from "./logger";
...@@ -42,22 +54,7 @@ import { clamp } from "effect/Number";...@@ -42,22 +54,7 @@ import { clamp } from "effect/Number";
42import { Open } from "./open";54import { Open } from "./open";
43import { ServerConfig } from "./config";55import { ServerConfig } from "./config";
44import { GitCore } from "./git/Services/GitCore.ts";56import { GitCore } from "./git/Services/GitCore.ts";
45import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";57import { ATTACHMENTS_ROUTE_PREFIX, 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};
6158
62/**59/**
63 * ServerShape - Service API for server lifecycle control.60 * ServerShape - Service API for server lifecycle control.
...@@ -69,7 +66,7 @@ export interface ServerShape {...@@ -69,7 +66,7 @@ export interface ServerShape {
69 readonly start: Effect.Effect<66 readonly start: Effect.Effect<
70 http.Server,67 http.Server,
71 ServerLifecycleError,68 ServerLifecycleError,
72 Scope.Scope | ServerRuntimeServices | ServerConfig69 Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path
73 >;70 >;
7471
75 /**72 /**
...@@ -168,7 +165,7 @@ class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("Ro...@@ -168,7 +165,7 @@ class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("Ro
168export const createServer = Effect.fn(function* (): Effect.fn.Return<165export const createServer = Effect.fn(function* (): Effect.fn.Return<
169 http.Server,166 http.Server,
170 ServerLifecycleError,167 ServerLifecycleError,
171 Scope.Scope | ServerRuntimeServices | ServerConfig168 Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path
172> {169> {
173 const serverConfig = yield* ServerConfig;170 const serverConfig = yield* ServerConfig;
174 const {171 const {
...@@ -187,6 +184,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -187,6 +184,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
187 const terminalManager = yield* TerminalManager;184 const terminalManager = yield* TerminalManager;
188 const keybindingsManager = yield* Keybindings;185 const keybindingsManager = yield* Keybindings;
189 const git = yield* GitCore;186 const git = yield* GitCore;
187 const fileSystem = yield* FileSystem.FileSystem;
188 const path = yield* Path.Path;
190189
191 yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe(190 yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe(
192 Effect.catch((error) =>191 Effect.catch((error) =>
...@@ -233,62 +232,157 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -233,62 +232,157 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
233232
234 // HTTP server — serves static files or redirects to Vite dev server233 // HTTP server — serves static files or redirects to Vite dev server
235 const httpServer = http.createServer((req, res) => {234 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)) {244 void Effect.runPromise(
239 return;245 Effect.gen(function* () {
240 }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 server251 if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) {
243 if (devUrl) {252 const attachmentsRoot = path.resolve(path.join(serverConfig.stateDir, "attachments"));
244 res.writeHead(302, { Location: devUrl.href });253 const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);
245 res.end();254 const normalizedRelativePath = path.normalize(rawRelativePath).replace(/^[/\\]+/, "");
246 return;255
247 }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 build265 const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath));
250 if (!staticDir) {266 if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) {
251 res.writeHead(503, { "Content-Type": "text/plain" });267 respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path");
252 res.end("No static directory configured and no dev URL set.");268 return;
253 return;269 }
254 }
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.html279 const contentType = Mime.getType(filePath) ?? "application/octet-stream";
259 const ext = path.extname(filePath);280 res.writeHead(200, {
260 if (!ext) {281 "Content-Type": contentType,
261 filePath = path.join(filePath, "index.html");282 "Cache-Control": "public, max-age=31536000, immutable",
262 }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) => {303 // In dev mode, redirect to Vite dev server
265 if (err || !stats?.isFile()) {304 if (devUrl) {
266 // SPA fallback305 respond(302, { Location: devUrl.href });
267 const indexPath = path.join(staticDir, "index.html");306 return;
268 fs.readFile(indexPath, (readErr, data) => {307 }
269 if (readErr) {308
270 res.writeHead(404, { "Content-Type": "text/plain" });309 // Serve static files from the web app build
271 res.end("Not Found");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");
272 return;352 return;
273 }353 }
274 res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });354 }
275 res.end(data);
276 });
277 return;
278 }
279355
280 const fileExt = path.extname(filePath);356 const fileInfo = yield* fileSystem
281 const contentType = MIME_TYPES[fileExt] ?? "application/octet-stream";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) => {372 const contentType = Mime.getType(filePath) ?? "application/octet-stream";
284 if (readErr) {373 const data = yield* fileSystem
285 res.writeHead(500, { "Content-Type": "text/plain" });374 .readFile(filePath)
286 res.end("Internal Server Error");375 .pipe(Effect.catch(() => Effect.succeed(null)));
376 if (!data) {
377 respond(500, { "Content-Type": "text/plain" }, "Internal Server Error");
287 return;378 return;
288 }379 }
289 res.writeHead(200, { "Content-Type": contentType });380 respond(200, { "Content-Type": contentType }, data);
290 res.end(data);381 }),
291 });382 ).catch(() => {
383 if (!res.headersSent) {
384 respond(500, { "Content-Type": "text/plain" }, "Internal Server Error");
385 }
292 });386 });
293 });387 });
294388
apps/web/src/components/ChatView.tsx+460-311
...@@ -74,6 +74,8 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";...@@ -74,6 +74,8 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
74import {74import {
75 BotIcon,75 BotIcon,
76 ChevronDownIcon,76 ChevronDownIcon,
77 ChevronLeftIcon,
78 ChevronRightIcon,
77 CircleAlertIcon,79 CircleAlertIcon,
78 FileIcon,80 FileIcon,
79 FolderIcon,81 FolderIcon,
...@@ -111,6 +113,7 @@ import { SidebarTrigger } from "./ui/sidebar";...@@ -111,6 +113,7 @@ import { SidebarTrigger } from "./ui/sidebar";
111import { newCommandId, newMessageId } from "~/lib/utils";113import { newCommandId, newMessageId } from "~/lib/utils";
112import { readNativeApi } from "~/nativeApi";114import { readNativeApi } from "~/nativeApi";
113import { useAppSettings } from "../appSettings";115import { useAppSettings } from "../appSettings";
116import { clamp } from "effect/Number";
114117
115function formatMessageMeta(createdAt: string, duration: string | null): string {118function formatMessageMeta(createdAt: string, duration: string | null): string {
116 if (!duration) return formatTimestamp(createdAt);119 if (!duration) return formatTimestamp(createdAt);
...@@ -165,11 +168,36 @@ interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"...@@ -165,11 +168,36 @@ interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"
165 file: File;168 file: File;
166}169}
167170
168interface ExpandedImagePreview {171interface ExpandedImageItem {
169 src: string;172 src: string;
170 name: string;173 name: string;
171}174}
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
173type ComposerCommandItem =201type ComposerCommandItem =
174 | {202 | {
175 id: string;203 id: string;
...@@ -1070,22 +1098,53 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1070,22 +1098,53 @@ export default function ChatView({ threadId }: ChatViewProps) {
1070 };1098 };
1071 }, [revokePreviewUrls]);1099 }, [revokePreviewUrls]);
10721100
1073 /**1101 const closeExpandedImage = useCallback(() => {
1074 * Close expanded image on Escape key1102 setExpandedImage(null);
1075 */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
1076 useEffect(() => {1118 useEffect(() => {
1077 if (!expandedImage) {1119 if (!expandedImage) {
1078 return;1120 return;
1079 }1121 }
10801122
1081 const onKeyDown = (event: globalThis.KeyboardEvent) => {1123 const onKeyDown = (event: globalThis.KeyboardEvent) => {
1082 if (event.key !== "Escape") return;1124 if (event.key === "Escape") {
1083 setExpandedImage(null);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);
1084 };1143 };
10851144
1086 window.addEventListener("keydown", onKeyDown);1145 window.addEventListener("keydown", onKeyDown);
1087 return () => window.removeEventListener("keydown", onKeyDown);1146 return () => window.removeEventListener("keydown", onKeyDown);
1088 }, [expandedImage]);1147 }, [closeExpandedImage, expandedImage, navigateExpandedImage]);
10891148
1090 const activeWorktreePath = activeThread?.worktreePath;1149 const activeWorktreePath = activeThread?.worktreePath;
10911150
...@@ -1722,9 +1781,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1722,9 +1781,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
1722 [groupId]: !existing[groupId],1781 [groupId]: !existing[groupId],
1723 }));1782 }));
1724 }, []);1783 }, []);
1725 const onExpandTimelineImage = useCallback((image: ExpandedImagePreview) => {1784 const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => {
1726 setExpandedImage(image);1785 setExpandedImage(preview);
1727 }, []);1786 }, []);
1787 const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null;
1728 const onOpenTurnDiff = useCallback(1788 const onOpenTurnDiff = useCallback(
1729 (turnId: TurnId, filePath?: string) => {1789 (turnId: TurnId, filePath?: string) => {
1730 void navigate({1790 void navigate({
...@@ -1824,6 +1884,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1824,6 +1884,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
1824 <MessagesTimeline1884 <MessagesTimeline
1825 hasMessages={timelineMessages.length > 0}1885 hasMessages={timelineMessages.length > 0}
1826 isWorking={isWorking}1886 isWorking={isWorking}
1887 activeTurnInProgress={!latestTurnSettled}
1888 activeTurnStartedAt={activeLatestTurn?.startedAt ?? null}
1827 scrollContainerRef={messagesScrollRef}1889 scrollContainerRef={messagesScrollRef}
1828 timelineEntries={timelineEntries}1890 timelineEntries={timelineEntries}
1829 completionDividerBeforeEntryId={completionDividerBeforeEntryId}1891 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
...@@ -1885,9 +1947,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1885,9 +1947,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
1885 src={image.previewUrl}1947 src={image.previewUrl}
1886 alt={image.name}1948 alt={image.name}
1887 className="h-full w-full cursor-zoom-in object-cover"1949 className="h-full w-full cursor-zoom-in object-cover"
1888 onClick={() =>1950 onClick={() => {
1889 setExpandedImage({ src: image.previewUrl, name: image.name })1951 const preview = buildExpandedImagePreview(composerImages, image.id);
1890 }1952 if (!preview) return;
1953 setExpandedImage(preview);
1954 }}
1891 />1955 />
1892 ) : (1956 ) : (
1893 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">1957 <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) {...@@ -2111,14 +2175,29 @@ export default function ChatView({ threadId }: ChatViewProps) {
2111 );2175 );
2112 })()}2176 })()}
21132177
2114 {expandedImage && (2178 {expandedImage && expandedImageItem && (
2115 <div2179 <div
2116 className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]"2180 className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]"
2117 role="dialog"2181 role="dialog"
2118 aria-modal="true"2182 aria-modal="true"
2119 aria-label="Expanded image preview"2183 aria-label="Expanded image preview"
2120 onClick={() => setExpandedImage(null)}2184 onClick={closeExpandedImage}
2121 >2185 >
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 )}
2122 <div2201 <div
2123 className="relative isolate max-h-[92vh] max-w-[92vw]"2202 className="relative isolate max-h-[92vh] max-w-[92vw]"
2124 onClick={(event) => event.stopPropagation()}2203 onClick={(event) => event.stopPropagation()}
...@@ -2128,21 +2207,39 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2128,21 +2207,39 @@ export default function ChatView({ threadId }: ChatViewProps) {
2128 size="icon-xs"2207 size="icon-xs"
2129 variant="ghost"2208 variant="ghost"
2130 className="absolute right-2 top-2"2209 className="absolute right-2 top-2"
2131 onClick={() => setExpandedImage(null)}2210 onClick={closeExpandedImage}
2132 aria-label="Close image preview"2211 aria-label="Close image preview"
2133 >2212 >
2134 <XIcon />2213 <XIcon />
2135 </Button>2214 </Button>
2136 <img2215 <img
2137 src={expandedImage.src}2216 src={expandedImageItem.src}
2138 alt={expandedImage.name}2217 alt={expandedImageItem.name}
2139 className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl"2218 className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl"
2140 draggable={false}2219 draggable={false}
2141 />2220 />
2142 <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80">2221 <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 : ""}
2144 </p>2226 </p>
2145 </div>2227 </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 )}
2146 </div>2243 </div>
2147 )}2244 )}
2148 </div>2245 </div>
...@@ -2336,13 +2433,7 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri...@@ -2336,13 +2433,7 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri
2336 }, [text]);2433 }, [text]);
23372434
2338 return (2435 return (
2339 <Button2436 <Button type="button" size="xs" variant="outline" onClick={handleCopy} title="Copy message">
2340 type="button"
2341 size="xs"
2342 variant="outline"
2343 onClick={handleCopy}
2344 title="Copy message"
2345 >
2346 {copied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}2437 {copied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
2347 </Button>2438 </Button>
2348 );2439 );
...@@ -2351,6 +2442,8 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri...@@ -2351,6 +2442,8 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri
2351interface MessagesTimelineProps {2442interface MessagesTimelineProps {
2352 hasMessages: boolean;2443 hasMessages: boolean;
2353 isWorking: boolean;2444 isWorking: boolean;
2445 activeTurnInProgress: boolean;
2446 activeTurnStartedAt: string | null;
2354 scrollContainerRef: React.RefObject<HTMLDivElement | null>;2447 scrollContainerRef: React.RefObject<HTMLDivElement | null>;
2355 timelineEntries: ReturnType<typeof deriveTimelineEntries>;2448 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
2356 completionDividerBeforeEntryId: string | null;2449 completionDividerBeforeEntryId: string | null;
...@@ -2363,7 +2456,7 @@ interface MessagesTimelineProps {...@@ -2363,7 +2456,7 @@ interface MessagesTimelineProps {
2363 revertTurnCountByUserMessageId: Map<MessageId, number>;2456 revertTurnCountByUserMessageId: Map<MessageId, number>;
2364 onRevertUserMessage: (messageId: MessageId) => void;2457 onRevertUserMessage: (messageId: MessageId) => void;
2365 isRevertingCheckpoint: boolean;2458 isRevertingCheckpoint: boolean;
2366 onImageExpand: (image: ExpandedImagePreview) => void;2459 onImageExpand: (preview: ExpandedImagePreview) => void;
2367 markdownCwd: string | undefined;2460 markdownCwd: string | undefined;
2368}2461}
23692462
...@@ -2374,19 +2467,23 @@ type TimelineRow =...@@ -2374,19 +2467,23 @@ type TimelineRow =
2374 | {2467 | {
2375 kind: "work";2468 kind: "work";
2376 id: string;2469 id: string;
2470 createdAt: string;
2377 groupedEntries: TimelineWorkEntry[];2471 groupedEntries: TimelineWorkEntry[];
2378 }2472 }
2379 | {2473 | {
2380 kind: "message";2474 kind: "message";
2381 id: string;2475 id: string;
2476 createdAt: string;
2382 message: TimelineMessage;2477 message: TimelineMessage;
2383 showCompletionDivider: boolean;2478 showCompletionDivider: boolean;
2384 }2479 }
2385 | { kind: "working"; id: string };2480 | { kind: "working"; id: string; createdAt: string | null };
23862481
2387const MessagesTimeline = memo(function MessagesTimeline({2482const MessagesTimeline = memo(function MessagesTimeline({
2388 hasMessages,2483 hasMessages,
2389 isWorking,2484 isWorking,
2485 activeTurnInProgress,
2486 activeTurnStartedAt,
2390 scrollContainerRef,2487 scrollContainerRef,
2391 timelineEntries,2488 timelineEntries,
2392 completionDividerBeforeEntryId,2489 completionDividerBeforeEntryId,
...@@ -2423,6 +2520,7 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2423,6 +2520,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
2423 nextRows.push({2520 nextRows.push({
2424 kind: "work",2521 kind: "work",
2425 id: timelineEntry.id,2522 id: timelineEntry.id,
2523 createdAt: timelineEntry.createdAt,
2426 groupedEntries,2524 groupedEntries,
2427 });2525 });
2428 index = cursor - 1;2526 index = cursor - 1;
...@@ -2432,6 +2530,7 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2432,6 +2530,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
2432 nextRows.push({2530 nextRows.push({
2433 kind: "message",2531 kind: "message",
2434 id: timelineEntry.id,2532 id: timelineEntry.id,
2533 createdAt: timelineEntry.createdAt,
2435 message: timelineEntry.message,2534 message: timelineEntry.message,
2436 showCompletionDivider:2535 showCompletionDivider:
2437 timelineEntry.message.role === "assistant" &&2536 timelineEntry.message.role === "assistant" &&
...@@ -2440,14 +2539,62 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2440,14 +2539,62 @@ const MessagesTimeline = memo(function MessagesTimeline({
2440 }2539 }
24412540
2442 if (isWorking) {2541 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 });
2444 }2547 }
24452548
2446 return nextRows;2549 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
2449 const rowVirtualizer = useVirtualizer({2596 const rowVirtualizer = useVirtualizer({
2450 count: rows.length,2597 count: virtualizedRowCount,
2451 getScrollElement: () => scrollContainerRef.current,2598 getScrollElement: () => scrollContainerRef.current,
2452 estimateSize: (index: number) => {2599 estimateSize: (index: number) => {
2453 const row = rows[index];2600 const row = rows[index];
...@@ -2461,303 +2608,305 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2461,303 +2608,305 @@ const MessagesTimeline = memo(function MessagesTimeline({
2461 });2608 });
24622609
2463 const virtualRows = rowVirtualizer.getVirtualItems();2610 const virtualRows = rowVirtualizer.getVirtualItems();
24642611 const nonVirtualizedRows = rows.slice(virtualizedRowCount);
2465 if (!hasMessages && !isWorking) {2612
2466 return (2613 const renderRowContent = (row: TimelineRow) => (
2467 <div className="flex h-full items-center justify-center">2614 <div className="pb-4">
2468 <p className="text-sm text-muted-foreground/30">2615 {row.kind === "work" &&
2469 Send a message to start the conversation.2616 (() => {
2470 </p>2617 const groupId = row.id;
2471 </div>2618 const groupedEntries = row.groupedEntries;
2472 );2619 const isExpanded = expandedWorkGroups[groupId] ?? false;
2473 }2620 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
24742621 const visibleEntries =
2475 return (2622 hasOverflow && !isExpanded
2476 <div2623 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
2477 className="relative mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden"2624 : groupedEntries;
2478 style={{ height: `${rowVirtualizer.getTotalSize()}px` }}2625 const hiddenCount = groupedEntries.length - visibleEntries.length;
2479 >2626 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
2480 {virtualRows.map((virtualRow: VirtualItem) => {2627 const groupLabel = onlyToolEntries
2481 const row = rows[virtualRow.index];2628 ? groupedEntries.length === 1
2482 if (!row) return null;2629 ? "Tool call"
24832630 : `Tool calls (${groupedEntries.length})`
2484 return (2631 : groupedEntries.length === 1
2485 <div2632 ? "Work event"
2486 key={row.id}2633 : `Work log (${groupedEntries.length})`;
2487 data-index={virtualRow.index}2634
2488 ref={rowVirtualizer.measureElement}2635 return (
2489 className="absolute left-0 top-0 w-full"2636 <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">
2490 style={{ transform: `translateY(${virtualRow.start}px)` }}2637 <div className="mb-1.5 flex items-center justify-between gap-3">
2491 >2638 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
2492 <div className="pb-4">2639 {groupLabel}
2493 {row.kind === "work" &&2640 </p>
2494 (() => {2641 {hasOverflow && (
2495 const groupId = row.id;2642 <button
2496 const groupedEntries = row.groupedEntries;2643 type="button"
2497 const isExpanded = expandedWorkGroups[groupId] ?? false;2644 className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"
2498 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;2645 onClick={() => onToggleWorkGroup(groupId)}
2499 const visibleEntries =2646 >
2500 hasOverflow && !isExpanded2647 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
2501 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)2648 </button>
2502 : groupedEntries;2649 )}
2503 const hiddenCount = groupedEntries.length - visibleEntries.length;2650 </div>
2504 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");2651 <div className="space-y-1">
2505 const groupLabel = onlyToolEntries2652 {visibleEntries.map((workEntry) => (
2506 ? groupedEntries.length === 12653 <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5">
2507 ? "Tool call"2654 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
2508 : `Tool calls (${groupedEntries.length})`2655 <p
2509 : groupedEntries.length === 12656 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}
2510 ? "Work event"2657 >
2511 : `Work log (${groupedEntries.length})`;2658 {workEntry.detail ? (
25122659 <>
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);
2513 return (2784 return (
2514 <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">2785 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
2515 <div className="mb-1.5 flex items-center justify-between gap-3">2786 <div className="mb-1.5 flex items-center justify-between gap-2">
2516 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">2787 <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 )}
2518 </p>2797 </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) => (
2520 <button2811 <button
2812 key={`${turnSummary.turnId}:${file.path}`}
2521 type="button"2813 type="button"
2522 className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"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"
2523 onClick={() => onToggleWorkGroup(groupId)}2815 onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)}
2524 >2816 >
2525 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}2817 {(() => {
2526 </button>2818 const stat =
2527 )}2819 typeof file.additions === "number" &&
2528 </div>2820 typeof file.deletions === "number"
2529 <div className="space-y-1">2821 ? {
2530 {visibleEntries.map((workEntry) => (2822 additions: file.additions,
2531 <div2823 deletions: file.deletions,
2532 key={`work-row:${workEntry.id}`}2824 }
2533 className="flex items-start gap-2 py-0.5"2825 : null;
2534 >2826 if (!stat) {
2535 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />2827 return file.path;
2536 <p2828 }
2537 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}2829 return (
2538 >
2539 {workEntry.detail ? (
2540 <>2830 <>
2541 {workEntry.label}2831 <span>{file.path}</span>
2542 <span2832 <span className="ml-1 text-muted-foreground/70">(</span>
2543 className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"2833 <span className="text-success">+{stat.additions}</span>
2544 title={workEntry.detail}2834 <span className="mx-0.5 text-muted-foreground/70">/</span>
2545 >2835 <span className="text-destructive">-{stat.deletions}</span>
2546 {workEntry.detail}2836 <span className="text-muted-foreground/70">)</span>
2547 </span>
2548 </>2837 </>
2549 ) : (2838 );
2550 workEntry.label2839 })()}
2551 )}2840 </button>
2552 </p>
2553 </div>
2554 ))}2841 ))}
2555 </div>2842 </div>
2556 </div>2843 </div>
2557 );2844 );
2558 })()}2845 })()}
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" &&2874 if (!hasMessages && !isWorking) {
2561 row.message.role === "user" &&2875 return (
2562 (() => {2876 <div className="flex h-full items-center justify-center">
2563 const userImages = row.message.attachments ?? [];2877 <p className="text-sm text-muted-foreground/30">
2564 const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id);2878 Send a message to start the conversation.
2565 return (2879 </p>
2566 <div className="flex justify-end">2880 </div>
2567 <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">2881 );
2568 {userImages.length > 0 && (2882 }
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 })()}
26242883
2625 {row.kind === "message" &&2884 return (
2626 row.message.role === "assistant" &&2885 <div className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden">
2627 (() => {2886 {virtualizedRowCount > 0 && (
2628 const messageText =2887 <div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
2629 row.message.text || (row.message.streaming ? "" : "(empty response)");2888 {virtualRows.map((virtualRow: VirtualItem) => {
2630 return (2889 const row = rows[virtualRow.index];
2631 <>2890 if (!row) return null;
2632 {row.showCompletionDivider && (2891
2633 <div className="my-3 flex items-center gap-3">2892 return (
2634 <span className="h-px flex-1 bg-border" />2893 <div
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">2894 key={`virtual-row:${row.id}`}
2636 {completionSummary ? `Response • ${completionSummary}` : "Response"}2895 data-index={virtualRow.index}
2637 </span>2896 ref={rowVirtualizer.measureElement}
2638 <span className="h-px flex-1 bg-border" />2897 className="absolute left-0 top-0 w-full"
2639 </div>2898 style={{ transform: `translateY(${virtualRow.start}px)` }}
2640 )}2899 >
2641 <div className="min-w-0 px-1 py-0.5">2900 {renderRowContent(row)}
2642 <ChatMarkdown text={messageText} cwd={markdownCwd} />2901 </div>
2643 {(() => {2902 );
2644 const turnSummary = turnDiffSummaryByAssistantMessageId.get(2903 })}
2645 row.message.id,2904 </div>
2646 );2905 )}
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 })()}
27442906
2745 {row.kind === "working" && (2907 {nonVirtualizedRows.map((row) => (
2746 <div className="flex items-center gap-2 py-0.5 pl-1.5">2908 <div key={`non-virtual-row:${row.id}`}>{renderRowContent(row)}</div>
2747 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />2909 ))}
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 })}
2761 </div>2910 </div>
2762 );2911 );
2763});2912});
apps/web/src/store.ts+38-1
...@@ -192,6 +192,43 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" {...@@ -192,6 +192,43 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" {
192 return providerName === "claudeCode" ? "claudeCode" : "codex";192 return providerName === "claudeCode" ? "claudeCode" : "codex";
193}193}
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
195function normalizeTerminalIds(terminalIds: string[]): string[] {232function normalizeTerminalIds(terminalIds: string[]): string[] {
196 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);233 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);
197 const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT);234 const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT);
...@@ -456,7 +493,7 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -456,7 +493,7 @@ export function reducer(state: AppState, action: Action): AppState {
456 name: attachment.name,493 name: attachment.name,
457 mimeType: attachment.mimeType,494 mimeType: attachment.mimeType,
458 sizeBytes: attachment.sizeBytes,495 sizeBytes: attachment.sizeBytes,
459 previewUrl: attachment.dataUrl,496 previewUrl: toAttachmentPreviewUrl(attachment.dataUrl),
460 }));497 }));
461 const normalizedMessage: ChatMessage = {498 const normalizedMessage: ChatMessage = {
462 id: message.id,499 id: message.id,