From b9382cbad4018da9ad94cb8b4339a5146679f079 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Fri, 21 Aug 2026 16:30:59 -0700 Subject: [PATCH 01/12] feat(lastcode): add thread annotations - Support creating, editing, resolving, and reopening thread annotations - Persist annotation state and expose it across server and web clients --- .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../Layers/ProjectionPipeline.test.ts | 46 ++- .../Layers/ProjectionPipeline.ts | 27 +- .../Layers/ProjectionSnapshotQuery.test.ts | 52 +++- .../Layers/ProjectionSnapshotQuery.ts | 22 +- apps/server/src/orchestration/Schemas.ts | 2 + .../orchestration/decider.annotation.test.ts | 212 ++++++++++++++ apps/server/src/orchestration/decider.ts | 127 +++++++++ .../projector.annotation.test.ts | 86 ++++++ .../src/orchestration/projector.test.ts | 1 + apps/server/src/orchestration/projector.ts | 20 ++ .../Layers/ProjectionRepositories.test.ts | 41 ++- .../persistence/Layers/ProjectionThreads.ts | 13 +- apps/server/src/persistence/Migrations.ts | 2 + .../041_ProjectionThreadAnnotation.test.ts | 93 ++++++ .../041_ProjectionThreadAnnotation.ts | 35 +++ .../persistence/Services/ProjectionThreads.ts | 4 + apps/server/src/relay/AgentAwarenessRelay.ts | 3 + apps/server/src/ws.ts | 10 +- apps/web/src/components/ChatView.tsx | 154 +++++++++- apps/web/src/components/LegacySidebar.tsx | 125 ++++++++ apps/web/src/components/chat/ChatComposer.tsx | 34 ++- .../components/chat/MessagesTimeline.test.tsx | 41 +++ .../src/components/chat/MessagesTimeline.tsx | 162 +++++++++-- .../thread-annotation/ThreadAnnotation.tsx | 269 ++++++++++++++++++ apps/web/src/composer-logic.test.ts | 22 ++ apps/web/src/composer-logic.ts | 17 +- apps/web/src/state/entities.ts | 9 + docs/user/composer.md | 4 + docs/user/thread-sidebar.md | 14 + .../client-runtime/src/operations/commands.ts | 30 ++ .../src/state/threadCommands.ts | 27 ++ .../client-runtime/src/state/threadDetail.ts | 1 + .../src/state/threadReducer.test.ts | 30 ++ .../client-runtime/src/state/threadReducer.ts | 12 + packages/contracts/src/environment.test.ts | 10 + packages/contracts/src/environment.ts | 3 + .../src/orchestration.annotation.test.ts | 74 +++++ packages/contracts/src/orchestration.ts | 75 +++++ 40 files changed, 1875 insertions(+), 36 deletions(-) create mode 100644 apps/server/src/orchestration/decider.annotation.test.ts create mode 100644 apps/server/src/orchestration/projector.annotation.test.ts create mode 100644 apps/server/src/persistence/Migrations/041_ProjectionThreadAnnotation.test.ts create mode 100644 apps/server/src/persistence/Migrations/041_ProjectionThreadAnnotation.ts create mode 100644 apps/web/src/components/thread-annotation/ThreadAnnotation.tsx create mode 100644 packages/contracts/src/orchestration.annotation.test.ts diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index ee30d987591d..0b6c2a527995 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -92,6 +92,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); + expect(second.capabilities.threadAnnotations).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 45dc0ee9cfd5..11dd4319beb2 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -152,6 +152,7 @@ export const make = Effect.gen(function* () { threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, + threadAnnotations: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index e3b18d74a9a7..bf91fe296912 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -6,6 +6,7 @@ import { MessageId, ProjectId, ThreadId, + ThreadAnnotation, TurnId, ProviderInstanceId, } from "@t3tools/contracts"; @@ -15,6 +16,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; @@ -37,6 +39,10 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ServerConfig } from "../../config.ts"; +const decodeThreadAnnotationJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(ThreadAnnotation), +); + const makeProjectionPipelinePrefixedTestLayer = (prefix: string) => OrchestrationProjectionPipelineLive.pipe( Layer.provideMerge(OrchestrationEventStoreLive), @@ -122,7 +128,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { payload: { threadId: ThreadId.make("thread-1"), messageId: MessageId.make("message-1"), - role: "assistant", + role: "user", text: "hello", turnId: null, streaming: false, @@ -131,6 +137,28 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }, }); + yield* eventStore.append({ + type: "thread.annotation-upserted", + eventId: EventId.make("evt-annotation"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:01:00.000Z", + commandId: CommandId.make("cmd-annotation"), + causationEventId: null, + correlationId: CommandId.make("cmd-annotation"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + annotation: { + body: "# Follow up", + anchorMessageId: MessageId.make("message-1"), + createdAt: "2026-01-01T00:01:00.000Z", + updatedAt: "2026-01-01T00:01:00.000Z", + resolvedAt: null, + }, + }, + }); + yield* projectionPipeline.bootstrap; const projectRows = yield* sql<{ @@ -159,6 +187,20 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { `; assert.deepEqual(messageRows, [{ messageId: "message-1", text: "hello" }]); + const annotationRows = yield* sql<{ + readonly annotation: string | null; + readonly latestUserMessageId: string | null; + }>` + SELECT + annotation_json AS "annotation", + latest_user_message_id AS "latestUserMessageId" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + const annotation = yield* decodeThreadAnnotationJson(annotationRows[0]?.annotation); + assert.equal(annotation.body, "# Follow up"); + assert.equal(annotationRows[0]?.latestUserMessageId, "message-1"); + const stateRows = yield* sql<{ readonly projector: string; readonly lastAppliedSequence: number; @@ -171,7 +213,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { `; assert.equal(stateRows.length, Object.keys(ORCHESTRATION_PROJECTOR_NAMES).length); for (const row of stateRows) { - assert.equal(row.lastAppliedSequence, 3); + assert.equal(row.lastAppliedSequence, 4); } // Settled lifecycle through the DB pipeline: thread.settled writes the diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e9a625dd91cf..cfc36b15cfc2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -3,6 +3,7 @@ import { type ChatAttachment, type OrchestrationEvent, type OrchestrationSessionStatus, + type MessageId, ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -570,12 +571,17 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ]); let latestUserMessageAt: string | null = null; + let latestUserMessageId: MessageId | null = null; for (const message of messages) { if ( message.role === "user" && - (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) + (latestUserMessageAt === null || + message.createdAt > latestUserMessageAt || + (message.createdAt === latestUserMessageAt && + (latestUserMessageId === null || message.messageId > latestUserMessageId))) ) { latestUserMessageAt = message.createdAt; + latestUserMessageId = message.messageId; } } @@ -590,6 +596,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, + latestUserMessageId, latestUserMessageAt, pendingApprovalCount, pendingUserInputCount, @@ -623,6 +630,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti pinOrderKey: null, titleRegenerationRequestId: null, titleRegenerationStartedAt: null, + annotation: null, + latestUserMessageId: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -776,6 +785,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.annotation-upserted": + case "thread.annotation-resolved": + case "thread.annotation-reopened": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + annotation: event.payload.annotation, + }); + return; + } + case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index fcc48669353e..1fd65c2969cd 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -85,12 +85,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { branch, worktree_path, latest_turn_id, + latest_user_message_id, latest_user_message_at, pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, pinned_at, pin_order_key, + annotation_json, created_at, updated_at, deleted_at @@ -105,12 +107,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { NULL, NULL, 'turn-1', + 'message-1', '2026-02-24T00:00:04.000Z', 1, 0, 0, '2026-02-24T00:00:01.000Z', 'gm', + '{"body":"# Follow up","anchorMessageId":"message-1","createdAt":"2026-02-24T00:00:02.500Z","updatedAt":"2026-02-24T00:00:02.500Z","resolvedAt":null}', '2026-02-24T00:00:02.000Z', '2026-02-24T00:00:03.000Z', NULL @@ -328,6 +332,13 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", titleRegeneration: null, + annotation: { + body: "# Follow up", + anchorMessageId: asMessageId("message-1"), + createdAt: "2026-02-24T00:00:02.500Z", + updatedAt: "2026-02-24T00:00:02.500Z", + resolvedAt: null, + }, deletedAt: null, messages: [ { @@ -447,6 +458,13 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", titleRegeneration: null, + annotation: { + body: "# Follow up", + anchorMessageId: asMessageId("message-1"), + createdAt: "2026-02-24T00:00:02.500Z", + updatedAt: "2026-02-24T00:00:02.500Z", + resolvedAt: null, + }, session: { threadId: ThreadId.make("thread-1"), status: "running", @@ -516,6 +534,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { branch, worktree_path, latest_turn_id, + latest_user_message_id, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -537,6 +556,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { NULL, NULL, NULL, + NULL, 0, 0, 0, @@ -556,6 +576,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { NULL, NULL, NULL, + NULL, 0, 0, 0, @@ -636,6 +657,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { branch, worktree_path, latest_turn_id, + latest_user_message_id, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -658,6 +680,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { NULL, NULL, NULL, + NULL, 0, 0, 0, @@ -1293,7 +1316,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); - it.effect("uses projection_threads.latest_turn_id for bulk command and shell snapshots", () => + it.effect("uses projection_threads latest markers for bulk command and shell snapshots", () => Effect.gen(function* () { const snapshotQuery = yield* ProjectionSnapshotQuery; const sql = yield* SqlClient.SqlClient; @@ -1337,6 +1360,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { branch, worktree_path, latest_turn_id, + latest_user_message_id, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -1356,6 +1380,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { NULL, NULL, 'turn-running', + 'message-user-2', '2026-04-03T00:00:04.000Z', 0, 0, @@ -1367,6 +1392,29 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ) `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + is_streaming, + created_at, + updated_at + ) + VALUES ( + 'message-user-2', + 'thread-1', + NULL, + 'user', + 'Latest prompt', + 0, + '2026-04-03T00:00:30.000Z', + '2026-04-03T00:00:30.000Z' + ) + `; + yield* sql` INSERT INTO projection_turns ( thread_id, @@ -1434,6 +1482,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { const commandReadModel = yield* snapshotQuery.getCommandReadModel(); assert.equal(commandReadModel.threads[0]?.latestTurn?.turnId, asTurnId("turn-running")); assert.equal(commandReadModel.threads[0]?.latestTurn?.state, "running"); + assert.equal(commandReadModel.threads[0]?.messages.length, 0); + assert.equal(commandReadModel.threads[0]?.latestUserMessageId, asMessageId("message-user-2")); const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); assert.equal(shellSnapshot.threads[0]?.latestTurn?.turnId, asTurnId("turn-running")); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3d04381b3346..e0313a3d9691 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -25,6 +25,7 @@ import { ModelSelection, ProjectId, ThreadId, + ThreadAnnotation, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Effect from "effect/Effect"; @@ -90,6 +91,7 @@ const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + annotation: Schema.NullOr(Schema.fromJsonString(ThreadAnnotation)), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -440,6 +442,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + annotation_json AS "annotation", + latest_user_message_id AS "latestUserMessageId", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -476,6 +480,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + annotation_json AS "annotation", + latest_user_message_id AS "latestUserMessageId", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -514,6 +520,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + annotation_json AS "annotation", + latest_user_message_id AS "latestUserMessageId", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -956,6 +964,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + annotation_json AS "annotation", + latest_user_message_id AS "latestUserMessageId", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -1156,7 +1166,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { 'thread.activity-appended', 'thread.turn-diff-completed', 'thread.reverted', - 'thread.session-set' + 'thread.session-set', + 'thread.annotation-upserted', + 'thread.annotation-resolved', + 'thread.annotation-reopened' ) `, }); @@ -1711,6 +1724,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), + ...(row.annotation !== null ? { annotation: row.annotation } : {}), deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1918,6 +1932,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), + ...(row.annotation !== null ? { annotation: row.annotation } : {}), + latestUserMessageId: row.latestUserMessageId, deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -2054,6 +2070,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), + ...(row.annotation !== null ? { annotation: row.annotation } : {}), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -2200,6 +2217,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), + ...(row.annotation !== null ? { annotation: row.annotation } : {}), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -2480,6 +2498,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), + ...(threadRow.value.annotation !== null ? { annotation: threadRow.value.annotation } : {}), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -2622,6 +2641,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), + ...(threadRow.value.annotation !== null ? { annotation: threadRow.value.annotation } : {}), deletedAt: null, messages: messageRows.map((row) => { const message = { diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 7e866cf89592..33c5d02d3274 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -16,6 +16,7 @@ import { ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema, + ThreadAnnotationChangedPayload as ContractsThreadAnnotationChangedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -48,6 +49,7 @@ export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; +export const ThreadAnnotationChangedPayload = ContractsThreadAnnotationChangedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.annotation.test.ts b/apps/server/src/orchestration/decider.annotation.test.ts new file mode 100644 index 000000000000..b3e148cb9b96 --- /dev/null +++ b/apps/server/src/orchestration/decider.annotation.test.ts @@ -0,0 +1,212 @@ +import { + CommandId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type ThreadAnnotation, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; + +function makeReadModel( + annotation: ThreadAnnotation | null = null, + latestMessageId: string | null = "message-new", +): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + annotation, + deletedAt: null, + messages: + latestMessageId === null + ? [] + : [ + { + id: MessageId.make(latestMessageId), + role: "user", + text: "Prompt", + turnId: null, + streaming: false, + createdAt: "2026-01-01T00:00:01.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + }, + ], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +const existingAnnotation: ThreadAnnotation = { + body: "- [ ] Follow up", + anchorMessageId: MessageId.make("message-old"), + createdAt: "2025-12-30T00:00:00.000Z", + updatedAt: "2025-12-31T00:00:00.000Z", + resolvedAt: null, +}; + +it.layer(NodeServices.layer)("thread annotation decider", (it) => { + it.effect("creates an annotation with server timestamps", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.annotation.upsert", + commandId: CommandId.make("cmd-create"), + threadId: ThreadId.make("thread-1"), + body: "# Note", + }, + readModel: makeReadModel(), + }); + const first = Array.isArray(event) ? event[0] : event; + expect(first?.type).toBe("thread.annotation-upserted"); + if (first?.type === "thread.annotation-upserted") { + expect(first.payload.annotation.body).toBe("# Note"); + expect(first.payload.annotation.createdAt).toBe(first.payload.annotation.updatedAt); + expect(first.payload.annotation.resolvedAt).toBeNull(); + } + }), + ); + + it.effect("anchors from the projected marker without hydrated messages", () => + Effect.gen(function* () { + const readModel = makeReadModel(null, null); + const thread = readModel.threads[0]; + if (!thread) return; + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.annotation.upsert", + commandId: CommandId.make("cmd-projected-anchor"), + threadId: ThreadId.make("thread-1"), + body: "# Note", + }, + readModel: { + ...readModel, + threads: [ + { + ...thread, + latestUserMessageId: MessageId.make("message-projected"), + }, + ], + }, + }); + const first = Array.isArray(event) ? event[0] : event; + if (first?.type === "thread.annotation-upserted") { + expect(first.payload.annotation.anchorMessageId).toBe("message-projected"); + } + }), + ); + + it.effect("edits without changing created or resolved state", () => + Effect.gen(function* () { + const resolved = { ...existingAnnotation, resolvedAt: existingAnnotation.updatedAt }; + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.annotation.upsert", + commandId: CommandId.make("cmd-edit"), + threadId: ThreadId.make("thread-1"), + body: "Edited", + }, + readModel: makeReadModel(resolved), + }); + const first = Array.isArray(event) ? event[0] : event; + if (first?.type === "thread.annotation-upserted") { + expect(first.payload.annotation.createdAt).toBe(resolved.createdAt); + expect(first.payload.annotation.resolvedAt).toBe(resolved.resolvedAt); + expect(first.payload.annotation.anchorMessageId).toBe("message-new"); + } + }), + ); + + it.effect("resolve and reopen move the anchor and timestamp without changing the body", () => + Effect.gen(function* () { + const resolvedEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.annotation.resolve", + commandId: CommandId.make("cmd-resolve"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(existingAnnotation, "message-resolved"), + }); + const resolved = Array.isArray(resolvedEvent) ? resolvedEvent[0] : resolvedEvent; + if (resolved?.type !== "thread.annotation-resolved") return; + expect(resolved.payload.annotation.body).toBe(existingAnnotation.body); + expect(resolved.payload.annotation.anchorMessageId).toBe("message-resolved"); + expect(resolved.payload.annotation.resolvedAt).toBe(resolved.payload.annotation.updatedAt); + + const reopenedEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.annotation.reopen", + commandId: CommandId.make("cmd-reopen"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(resolved.payload.annotation, "message-reopened"), + }); + const reopened = Array.isArray(reopenedEvent) ? reopenedEvent[0] : reopenedEvent; + if (reopened?.type === "thread.annotation-reopened") { + expect(reopened.payload.annotation.body).toBe(existingAnnotation.body); + expect(reopened.payload.annotation.anchorMessageId).toBe("message-reopened"); + expect(reopened.payload.annotation.resolvedAt).toBeNull(); + } + }), + ); + + it.effect("rejects resolving a missing annotation", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decideOrchestrationCommand({ + command: { + type: "thread.annotation.resolve", + commandId: CommandId.make("cmd-missing"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(), + }), + ); + expect(result._tag).toBe("Failure"); + }), + ); + + it.effect("rejects annotations on a thread without a user message", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decideOrchestrationCommand({ + command: { + type: "thread.annotation.upsert", + commandId: CommandId.make("cmd-no-anchor"), + threadId: ThreadId.make("thread-1"), + body: "Note", + }, + readModel: makeReadModel(null, null), + }), + ); + expect(result._tag).toBe("Failure"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index a45267188b2c..2fddf395cf32 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -142,6 +142,18 @@ function threadHasQueuedTurnStart( ); } +function latestUserMessageId(thread: OrchestrationReadModel["threads"][number]) { + return ( + thread.latestUserMessageId ?? + thread.messages + .filter((message) => message.role === "user") + .toSorted( + (left, right) => + right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id), + )[0]?.id + ); +} + function withEventBase( input: Pick & { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; @@ -805,6 +817,121 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.annotation.upsert": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + const anchorMessageId = latestUserMessageId(thread); + if (anchorMessageId === undefined) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has no user message to anchor an annotation`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.annotation-upserted", + payload: { + threadId: command.threadId, + annotation: { + body: command.body, + anchorMessageId, + createdAt: thread.annotation?.createdAt ?? occurredAt, + updatedAt: occurredAt, + resolvedAt: thread.annotation?.resolvedAt ?? null, + }, + }, + }; + } + + case "thread.annotation.resolve": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + if (thread.annotation == null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has no annotation to resolve`, + }); + } + const anchorMessageId = latestUserMessageId(thread); + if (anchorMessageId === undefined) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has no user message to anchor an annotation`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.annotation-resolved", + payload: { + threadId: command.threadId, + annotation: { + ...thread.annotation, + anchorMessageId, + updatedAt: occurredAt, + resolvedAt: occurredAt, + }, + }, + }; + } + + case "thread.annotation.reopen": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + if (thread.annotation == null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has no annotation to reopen`, + }); + } + const anchorMessageId = latestUserMessageId(thread); + if (anchorMessageId === undefined) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has no user message to anchor an annotation`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.annotation-reopened", + payload: { + threadId: command.threadId, + annotation: { + ...thread.annotation, + anchorMessageId, + updatedAt: occurredAt, + resolvedAt: null, + }, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.annotation.test.ts b/apps/server/src/orchestration/projector.annotation.test.ts new file mode 100644 index 000000000000..85ee5f1c6465 --- /dev/null +++ b/apps/server/src/orchestration/projector.annotation.test.ts @@ -0,0 +1,86 @@ +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const THREAD_UPDATED_AT = "2026-01-01T00:00:00.000Z"; +const ANNOTATION_UPDATED_AT = "2026-01-01T00:05:00.000Z"; + +function event(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; + readonly occurredAt?: string; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: input.occurredAt ?? THREAD_UPDATED_AT, + commandId: CommandId.make(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +it.effect("projects annotation changes without changing thread recency", () => + Effect.gen(function* () { + const created = yield* projectEvent( + createEmptyReadModel(THREAD_UPDATED_AT), + event({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: THREAD_UPDATED_AT, + updatedAt: THREAD_UPDATED_AT, + }, + }), + ); + + const annotated = yield* projectEvent( + created, + event({ + sequence: 2, + type: "thread.annotation-upserted", + occurredAt: ANNOTATION_UPDATED_AT, + payload: { + threadId: ThreadId.make("thread-1"), + annotation: { + body: "# Note", + anchorMessageId: MessageId.make("message-1"), + createdAt: ANNOTATION_UPDATED_AT, + updatedAt: ANNOTATION_UPDATED_AT, + resolvedAt: null, + }, + }, + }), + ); + + expect(annotated.threads[0]?.annotation?.body).toBe("# Note"); + expect(annotated.threads[0]?.updatedAt).toBe(THREAD_UPDATED_AT); + }), +); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 9c07a312023c..5a7cfad799b9 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -75,6 +75,7 @@ describe("orchestration projector", () => { expect(next.threads).toEqual([ { id: "thread-1", + annotation: null, projectId: "project-1", title: "demo", modelSelection: { diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index f486dcb2bcbc..c0140d57cabc 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -25,6 +25,7 @@ import { ThreadSettledPayload, ThreadPinnedPayload, ThreadPinReorderedPayload, + ThreadAnnotationChangedPayload, ThreadSnoozedPayload, ThreadUnpinnedPayload, ThreadUnarchivedPayload, @@ -305,6 +306,7 @@ export function projectEvent( settledAt: null, snoozedUntil: null, snoozedAt: null, + annotation: null, deletedAt: null, messages: [], activities: [], @@ -442,6 +444,23 @@ export function projectEvent( })), ); + case "thread.annotation-upserted": + case "thread.annotation-resolved": + case "thread.annotation-reopened": + return decodeForEvent( + ThreadAnnotationChangedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + annotation: payload.annotation, + }), + })), + ); + case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ @@ -544,6 +563,7 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { messages: cappedMessages, + ...(payload.role === "user" ? { latestUserMessageId: payload.messageId } : {}), updatedAt: event.occurredAt, }), }; diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index bebd8fbb4a7d..128430c13eb0 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -1,8 +1,15 @@ -import { ProjectId, ThreadId, ProviderInstanceId } from "@t3tools/contracts"; +import { + MessageId, + ProjectId, + ThreadAnnotation, + ThreadId, + ProviderInstanceId, +} from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "./Sqlite.ts"; @@ -11,6 +18,10 @@ import { ProjectionThreadRepositoryLive } from "./ProjectionThreads.ts"; import { ProjectionProjectRepository } from "../Services/ProjectionProjects.ts"; import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts"; +const decodeThreadAnnotationJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(ThreadAnnotation), +); + const projectionRepositoriesLayer = it.layer( Layer.mergeAll( ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), @@ -97,6 +108,14 @@ projectionRepositoriesLayer("Projection repositories", (it) => { snoozedUntil: null, snoozedAt: null, pinnedAt: null, + annotation: { + body: "# Follow up", + anchorMessageId: MessageId.make("message-1"), + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-24T00:01:00.000Z", + resolvedAt: null, + }, + latestUserMessageId: MessageId.make("message-1"), latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -106,8 +125,11 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const rows = yield* sql<{ readonly modelSelection: string | null; + readonly annotation: string | null; }>` - SELECT model_selection_json AS "modelSelection" + SELECT + model_selection_json AS "modelSelection", + annotation_json AS "annotation" FROM projection_threads WHERE thread_id = 'thread-null-options' `; @@ -124,6 +146,14 @@ projectionRepositoriesLayer("Projection repositories", (it) => { model: "claude-opus-4-6", }), ); + const annotation = yield* decodeThreadAnnotationJson(row.annotation); + assert.deepStrictEqual(annotation, { + body: "# Follow up", + anchorMessageId: MessageId.make("message-1"), + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-24T00:01:00.000Z", + resolvedAt: null, + }); const persisted = yield* threads.getById({ threadId: ThreadId.make("thread-null-options"), @@ -132,6 +162,11 @@ projectionRepositoriesLayer("Projection repositories", (it) => { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }); + assert.strictEqual(Option.getOrNull(persisted)?.annotation?.body, "# Follow up"); + assert.strictEqual( + Option.getOrNull(persisted)?.latestUserMessageId, + MessageId.make("message-1"), + ); }), ); @@ -160,6 +195,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { snoozedUntil: "2026-03-26T09:00:00.000Z", snoozedAt: "2026-03-25T00:00:00.000Z", pinnedAt: "2026-03-25T00:00:00.000Z", + annotation: null, + latestUserMessageId: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index b7d8ae137473..ca72c8537337 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -14,11 +14,12 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection } from "@t3tools/contracts"; +import { ModelSelection, ThreadAnnotation } from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + annotation: Schema.NullOr(Schema.fromJsonString(ThreadAnnotation)), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; @@ -51,6 +52,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pin_order_key, title_regeneration_request_id, title_regeneration_started_at, + annotation_json, + latest_user_message_id, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -78,6 +81,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.pinOrderKey ?? null}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, + ${row.annotation === null ? null : JSON.stringify(row.annotation)}, + ${row.latestUserMessageId}, ${row.latestUserMessageAt}, ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, @@ -105,6 +110,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pin_order_key = excluded.pin_order_key, title_regeneration_request_id = excluded.title_regeneration_request_id, title_regeneration_started_at = excluded.title_regeneration_started_at, + annotation_json = excluded.annotation_json, + latest_user_message_id = excluded.latest_user_message_id, latest_user_message_at = excluded.latest_user_message_at, pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, @@ -139,6 +146,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + annotation_json AS "annotation", + latest_user_message_id AS "latestUserMessageId", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -175,6 +184,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + annotation_json AS "annotation", + latest_user_message_id AS "latestUserMessageId", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index b137cedfbedd..29d18293edd1 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -53,6 +53,7 @@ import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; +import Migration0041 from "./Migrations/041_ProjectionThreadAnnotation.ts"; /** * Migration loader with all migrations defined inline. @@ -105,6 +106,7 @@ export const migrationEntries = [ [38, "ProjectionThreadsPinOrderKey", Migration0038], [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], + [41, "ProjectionThreadAnnotation", Migration0041], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/041_ProjectionThreadAnnotation.test.ts b/apps/server/src/persistence/Migrations/041_ProjectionThreadAnnotation.test.ts new file mode 100644 index 000000000000..37dd7bef187c --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionThreadAnnotation.test.ts @@ -0,0 +1,93 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("041_ProjectionThreadAnnotation", (it) => { + it.effect("adds annotation and latest user marker fields to thread projections", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 40 }); + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + created_at, + updated_at + ) + VALUES ( + 'thread-1', + 'project-1', + 'Thread 1', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + '2026-02-24T00:00:00.000Z', + '2026-02-24T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + role, + text, + is_streaming, + created_at, + updated_at + ) + VALUES + ( + 'message-user-1', + 'thread-1', + 'user', + 'First', + 0, + '2026-02-24T00:01:00.000Z', + '2026-02-24T00:01:00.000Z' + ), + ( + 'message-user-2', + 'thread-1', + 'user', + 'Second', + 0, + '2026-02-24T00:01:00.000Z', + '2026-02-24T00:01:00.000Z' + ) + `; + yield* runMigrations({ toMigrationInclusive: 41 }); + + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(projection_threads) + `; + const annotationJson = columns.find((column) => column.name === "annotation_json"); + const latestUserMessageId = columns.find( + (column) => column.name === "latest_user_message_id", + ); + + assert.equal(annotationJson?.name, "annotation_json"); + assert.equal(annotationJson?.notnull, 0); + assert.equal(latestUserMessageId?.name, "latest_user_message_id"); + assert.equal(latestUserMessageId?.notnull, 0); + + const rows = yield* sql<{ readonly latestUserMessageId: string | null }>` + SELECT latest_user_message_id AS "latestUserMessageId" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + assert.equal(rows[0]?.latestUserMessageId, "message-user-2"); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/041_ProjectionThreadAnnotation.ts b/apps/server/src/persistence/Migrations/041_ProjectionThreadAnnotation.ts new file mode 100644 index 000000000000..6cbd339335a4 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionThreadAnnotation.ts @@ -0,0 +1,35 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "annotation_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN annotation_json TEXT + `; + } + + if (!columns.some((column) => column.name === "latest_user_message_id")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN latest_user_message_id TEXT + `; + } + + yield* sql` + UPDATE projection_threads + SET latest_user_message_id = ( + SELECT messages.message_id + FROM projection_thread_messages AS messages + WHERE messages.thread_id = projection_threads.thread_id + AND messages.role = 'user' + ORDER BY messages.created_at DESC, messages.message_id DESC + LIMIT 1 + ) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index c572e1d11ccd..b5f450c1841a 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -9,11 +9,13 @@ import { CommandId, IsoDateTime, + MessageId, ModelSelection, NonNegativeInt, ProjectId, ProviderInteractionMode, RuntimeMode, + ThreadAnnotation, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -45,6 +47,8 @@ export const ProjectionThread = Schema.Struct({ pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + annotation: Schema.NullOr(ThreadAnnotation), + latestUserMessageId: Schema.NullOr(MessageId), latestUserMessageAt: Schema.NullOr(IsoDateTime), pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 5127ecf7d359..66e53e01989f 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -79,6 +79,9 @@ export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boo case "thread.proposed-plan-upserted": case "thread.runtime-mode-set": case "thread.interaction-mode-set": + case "thread.annotation-upserted": + case "thread.annotation-resolved": + case "thread.annotation-reopened": return false; case "thread.activity-appended": return ( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0277f3a0262e..a8b157d5d366 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -282,7 +282,10 @@ export function isThreadDetailEvent(event: OrchestrationEvent): event is Extract | "thread.activity-appended" | "thread.turn-diff-completed" | "thread.reverted" - | "thread.session-set"; + | "thread.session-set" + | "thread.annotation-upserted" + | "thread.annotation-resolved" + | "thread.annotation-reopened"; } > { return ( @@ -291,7 +294,10 @@ export function isThreadDetailEvent(event: OrchestrationEvent): event is Extract event.type === "thread.activity-appended" || event.type === "thread.turn-diff-completed" || event.type === "thread.reverted" || - event.type === "thread.session-set" + event.type === "thread.session-set" || + event.type === "thread.annotation-upserted" || + event.type === "thread.annotation-resolved" || + event.type === "thread.annotation-reopened" ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cbcfee4bb623..883e0969764d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -78,6 +78,7 @@ import { useDiffPanelStore } from "../diffPanelStore"; import { collapseExpandedComposerCursor, parseStandaloneComposerSlashCommand, + parseThreadAnnotationSlashCommand, } from "../composer-logic"; import { derivePendingApprovals, @@ -283,6 +284,10 @@ import { threadChangeRequestSnapshotsAtom, } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { + ThreadAnnotationEditorDialog, + ThreadAnnotationPostIt, +} from "./thread-annotation/ThreadAnnotation"; import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, @@ -1224,6 +1229,15 @@ function ChatViewContent(props: ChatViewProps) { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const upsertThreadAnnotation = useAtomCommand(threadEnvironment.upsertAnnotation, { + reportFailure: false, + }); + const resolveThreadAnnotation = useAtomCommand(threadEnvironment.resolveAnnotation, { + reportFailure: false, + }); + const reopenThreadAnnotation = useAtomCommand(threadEnvironment.reopenAnnotation, { + reportFailure: false, + }); const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { reportFailure: false, @@ -4172,6 +4186,78 @@ function ChatViewContent(props: ChatViewProps) { ); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; + const supportsThreadAnnotations = + serverConfig?.environment.capabilities.threadAnnotations === true; + const threadAnnotation = activeThread?.annotation ?? null; + const [annotationEditorOpen, setAnnotationEditorOpen] = useState(false); + const [annotationMutationPending, setAnnotationMutationPending] = useState(false); + const [dismissedAnnotationKey, setDismissedAnnotationKey] = useState(null); + const annotationVersionKey = threadAnnotation + ? `${routeThreadKey}:${threadAnnotation.updatedAt}` + : null; + + useEffect(() => { + setDismissedAnnotationKey(null); + setAnnotationEditorOpen(false); + }, [routeThreadKey]); + + const reportAnnotationFailure = useCallback((action: string, error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to ${action} annotation`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, []); + + const saveThreadAnnotation = useCallback( + async (body: string): Promise => { + if (!isServerThread || !activeThread || !supportsThreadAnnotations) return false; + setAnnotationMutationPending(true); + const result = await upsertThreadAnnotation({ + environmentId: activeThread.environmentId, + input: { threadId: activeThread.id, body }, + }); + setAnnotationMutationPending(false); + if (result._tag === "Success") return true; + if (!isAtomCommandInterrupted(result)) { + reportAnnotationFailure("save", squashAtomCommandFailure(result)); + } + return false; + }, + [ + activeThread, + isServerThread, + reportAnnotationFailure, + supportsThreadAnnotations, + upsertThreadAnnotation, + ], + ); + + const changeThreadAnnotationResolution = useCallback( + async (next: "resolve" | "reopen") => { + if (!isServerThread || !activeThread || !supportsThreadAnnotations) return; + setAnnotationMutationPending(true); + const command = next === "resolve" ? resolveThreadAnnotation : reopenThreadAnnotation; + const result = await command({ + environmentId: activeThread.environmentId, + input: { threadId: activeThread.id }, + }); + setAnnotationMutationPending(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportAnnotationFailure(next, squashAtomCommandFailure(result)); + } + }, + [ + activeThread, + isServerThread, + reopenThreadAnnotation, + reportAnnotationFailure, + resolveThreadAnnotation, + supportsThreadAnnotations, + ], + ); const nowMinute = useNowMinute(); const snoozeNow = new Date().toISOString(); const activeThreadSnoozed = @@ -5079,7 +5165,47 @@ function ChatViewContent(props: ChatViewProps) { return; } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx?.providerAvailable) { + if (!sendCtx) { + notifyDirectAnnotationAttached(); + return; + } + const annotationSlashCommand = + !directAnnotation && + sendCtx.images.length === 0 && + sendCtx.terminalContexts.length === 0 && + sendCtx.elementContexts.length === 0 && + sendCtx.previewAnnotations.length === 0 && + sendCtx.reviewComments.length === 0 + ? parseThreadAnnotationSlashCommand(promptRef.current) + : null; + if (annotationSlashCommand) { + if (!isServerThread || !supportsThreadAnnotations) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: isServerThread ? "Annotations unavailable" : "Send a message first", + description: isServerThread + ? "This environment needs a newer LastCode server to annotate threads." + : "Annotations can be added after this draft becomes a thread.", + }), + ); + return; + } + if (annotationSlashCommand.kind === "open-editor") { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + setAnnotationEditorOpen(true); + return; + } + if (await saveThreadAnnotation(annotationSlashCommand.body)) { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + } + return; + } + if (!sendCtx.providerAvailable) { notifyDirectAnnotationAttached(); return; } @@ -6469,6 +6595,10 @@ function ChatViewContent(props: ChatViewProps) { hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} loadEarlier={loadEarlierTurns} + annotation={threadAnnotation} + onAnnotationEdit={() => setAnnotationEditorOpen(true)} + onAnnotationResolve={() => void changeThreadAnnotationResolution("resolve")} + onAnnotationReopen={() => void changeThreadAnnotationResolution("reopen")} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -6528,6 +6658,19 @@ function ChatViewContent(props: ChatViewProps) { ) : ( )} + {threadAnnotation && + threadAnnotation.resolvedAt === null && + annotationVersionKey !== dismissedAnnotationKey ? ( + setDismissedAnnotationKey(annotationVersionKey)} + onEdit={() => setAnnotationEditorOpen(true)} + onResolve={() => void changeThreadAnnotationResolution("resolve")} + pending={annotationMutationPending} + threadRef={routeThreadRef} + /> + ) : null} {threadSyncPhase && !activeEnvironmentUnavailable ? ( ) : null} @@ -6592,6 +6735,7 @@ function ChatViewContent(props: ChatViewProps) { keybindings={keybindings} terminalOpen={Boolean(terminalUiState.terminalOpen)} gitCwd={gitCwd} + threadAnnotationsSupported={isServerThread && supportsThreadAnnotations} promptRef={promptRef} composerImagesRef={composerImagesRef} composerTerminalContextsRef={composerTerminalContextsRef} @@ -6619,6 +6763,7 @@ function ChatViewContent(props: ChatViewProps) { scheduleComposerFocus={scheduleComposerFocus} setThreadError={setThreadError} onExpandImage={onExpandTimelineImage} + onOpenThreadAnnotation={() => setAnnotationEditorOpen(true)} /> @@ -6708,6 +6853,13 @@ function ChatViewContent(props: ChatViewProps) { + + {pullRequestDialogState ? ( boolean; + onEditAnnotation: (thread: SidebarThreadSummary) => void; + onResolveAnnotation: (thread: SidebarThreadSummary) => void; } export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { @@ -380,6 +387,8 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr cancelRename, attemptArchiveThread, openPrLink, + onEditAnnotation, + onResolveAnnotation, thread, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); @@ -474,16 +483,19 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; + const hasActiveAnnotation = thread.annotation?.resolvedAt === null; const threadMetaClassName = isConfirmingArchive ? "pointer-events-none opacity-0" : !isThreadRunning ? "pointer-events-none transition-opacity duration-150 max-sm:pr-6 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" : "pointer-events-none"; + const [annotationRowActive, setAnnotationRowActive] = useState(false); const clearConfirmingArchive = useCallback(() => { setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); }, [setConfirmingArchiveThreadKey, threadKey]); const handleMouseLeave = useCallback(() => { clearConfirmingArchive(); + setAnnotationRowActive(false); }, [clearConfirmingArchive]); const handleBlurCapture = useCallback( (event: React.FocusEvent) => { @@ -493,6 +505,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr return; } clearConfirmingArchive(); + setAnnotationRowActive(false); }); }, [clearConfirmingArchive], @@ -685,6 +698,8 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr setAnnotationRowActive(true)} + onMouseEnter={() => setAnnotationRowActive(true)} onMouseLeave={handleMouseLeave} onBlurCapture={handleBlurCapture} > @@ -885,6 +900,27 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr {jumpLabel} + ) : hasActiveAnnotation && thread.annotation ? ( + onEditAnnotation(thread)} + onResolve={() => onResolveAnnotation(thread)} + rowActive={annotationRowActive} + threadRef={threadRef} + trigger={ + + {formatRelativeTimeLabel( + thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + )} + + } + /> ) : ( boolean; + onEditAnnotation: (thread: SidebarThreadSummary) => void; + onResolveAnnotation: (thread: SidebarThreadSummary) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; } @@ -1000,6 +1038,8 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( cancelRename, attemptArchiveThread, openPrLink, + onEditAnnotation, + onResolveAnnotation, expandThreadListForProject, collapseThreadListForProject, } = props; @@ -1054,6 +1094,8 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( cancelRename={cancelRename} attemptArchiveThread={attemptArchiveThread} openPrLink={openPrLink} + onEditAnnotation={onEditAnnotation} + onResolveAnnotation={onResolveAnnotation} /> ); })} @@ -1158,6 +1200,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const upsertThreadAnnotation = useAtomCommand(threadEnvironment.upsertAnnotation, { + reportFailure: false, + }); + const resolveThreadAnnotation = useAtomCommand(threadEnvironment.resolveAnnotation, { + reportFailure: false, + }); const updateSettings = useUpdateClientSettings(); const sidebarThreadPreviewCount = useClientSettings( (settings) => settings.sidebarThreadPreviewCount, @@ -1246,6 +1294,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState(null); + const [annotationEditorTarget, setAnnotationEditorTarget] = useState( + null, + ); const [projectRenameTarget, setProjectRenameTarget] = useState( null, ); @@ -1759,6 +1810,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ) => { if (isSidebarNestedLinkClick(event.target)) return; const isMac = isMacPlatform(navigator.platform); + if ( + isContextMenuPointerDown({ + button: event.button, + ctrlKey: event.ctrlKey, + isMac, + }) + ) { + event.preventDefault(); + return; + } const isModClick = isMac ? event.metaKey : event.ctrlKey; const isShiftClick = event.shiftKey; const threadKey = scopedThreadKey(threadRef); @@ -2023,6 +2084,50 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec renamingInputRef.current = null; }, []); + const saveAnnotation = useCallback( + async (body: string): Promise => { + const target = annotationEditorTarget; + if (!target) return false; + const result = await upsertThreadAnnotation({ + environmentId: target.environmentId, + input: { threadId: target.id, body }, + }); + if (result._tag === "Success") return true; + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to save annotation", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return false; + }, + [annotationEditorTarget, upsertThreadAnnotation], + ); + + const resolveAnnotation = useCallback( + async (thread: SidebarThreadSummary) => { + const result = await resolveThreadAnnotation({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to resolve annotation", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [resolveThreadAnnotation], + ); + const startThreadRename = useCallback((threadKey: string, title: string) => { setRenamingThreadKey(threadKey); setRenamingTitle(title); @@ -2163,12 +2268,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); const threadWorkspacePath = thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null; + const supportsThreadAnnotations = readEnvironmentSupportsThreadAnnotations( + thread.environmentId, + ); const clicked = await api.contextMenu.show( [ ...(thread.branch ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }] : []), { id: "rename", label: "Rename thread" }, + ...(supportsThreadAnnotations ? [{ id: "annotate", label: "Annotate thread…" }] : []), { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, @@ -2206,6 +2315,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } + if (clicked === "annotate") { + setAnnotationEditorTarget(thread); + return; + } + if (clicked === "mark-unread") { markThreadUnread(threadKey, thread.latestTurn?.completedAt); return; @@ -2418,10 +2532,21 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec cancelRename={cancelRename} attemptArchiveThread={attemptArchiveThread} openPrLink={openPrLink} + onEditAnnotation={setAnnotationEditorTarget} + onResolveAnnotation={(thread) => void resolveAnnotation(thread)} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} /> + { + if (!open) setAnnotationEditorTarget(null); + }} + onSave={saveAnnotation} + /> + { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c697851cd2eb..15e5a4e5f7eb 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -568,6 +568,7 @@ export interface ChatComposerProps { keybindings: ResolvedKeybindingsConfig; terminalOpen: boolean; gitCwd: string | null; + threadAnnotationsSupported: boolean; // Refs the parent needs kept in sync promptRef: React.RefObject; @@ -605,6 +606,7 @@ export interface ChatComposerProps { scheduleComposerFocus: () => void; setThreadError: (threadId: ThreadId | null, error: string | null) => void; onExpandImage: (preview: ExpandedImagePreview) => void; + onOpenThreadAnnotation: () => void; } // -------------------------------------------------------------------------- @@ -654,6 +656,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) keybindings, terminalOpen, gitCwd, + threadAnnotationsSupported, promptRef, composerRef, composerImagesRef, @@ -676,6 +679,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) scheduleComposerFocus, setThreadError, onExpandImage, + onOpenThreadAnnotation, } = props; const isSendDisabled = sendDisabledReason !== null; @@ -1054,6 +1058,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) label: "/model", description: "Switch response model for this thread", }, + ...(threadAnnotationsSupported + ? ([ + { + id: "slash:annotate", + type: "slash-command", + command: "annotate", + label: "/annotate", + description: "Add or edit this thread's annotation", + }, + ] as const) + : []), ...(planModeUiEnabled ? ([ { @@ -1111,6 +1126,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) planModeUiEnabled, selectedProvider, selectedProviderStatus, + threadAnnotationsSupported, workspaceEntries.entries, ]); @@ -1719,6 +1735,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } + if (item.command === "annotate") { + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + focusEditorAfterReplace: false, + }); + if (applied) { + setComposerHighlightedItemId(null); + onOpenThreadAnnotation(); + } + return; + } void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), @@ -1765,7 +1792,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } }, - [applyPromptReplacement, handleInteractionModeChange, resolveActiveComposerTrigger], + [ + applyPromptReplacement, + handleInteractionModeChange, + onOpenThreadAnnotation, + resolveActiveComposerTrigger, + ], ); const onComposerMenuItemHighlighted = useCallback( diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 617ee0b80d1c..cefc4c0ed671 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -237,6 +237,47 @@ function buildAssistantTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it("keeps an annotation visible in the minimap with only one loaded marker", () => { + const entry = buildUserTimelineEntry("Annotated prompt"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-testid="timeline-minimap"'); + expect(markup).toContain("data-thread-annotation-marker"); + expect(markup).not.toContain("data-thread-annotation-overflow"); + }); + + it("uses an honest earlier-message marker when the anchor is outside loaded history", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("data-thread-annotation-overflow"); + expect(markup).toContain('aria-label="Annotation attached to an earlier message"'); + expect(markup).not.toContain("data-thread-annotation-marker"); + }); + it("uses the larger leading inset only when the top fade is enabled", () => { const timelineEntries = [buildUserTimelineEntry("Hello")]; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b6d93968f257..86eab739a392 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3,6 +3,7 @@ import { type MessageId, type ScopedThreadRef, type ServerProviderSkill, + type ThreadAnnotation, type TurnId, } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; @@ -107,6 +108,10 @@ import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; +import { + ThreadAnnotationActions, + ThreadAnnotationBody, +} from "../thread-annotation/ThreadAnnotation"; import { buildInlineTerminalContextText, @@ -243,6 +248,10 @@ interface MessagesTimelineProps { topFadeEnabled?: boolean; /** Non-null when older turns exist beyond the loaded window. */ loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null; + annotation?: ThreadAnnotation | null; + onAnnotationEdit?: () => void; + onAnnotationResolve?: () => void; + onAnnotationReopen?: () => void; } // --------------------------------------------------------------------------- @@ -283,6 +292,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ hideEmptyPlaceholder = false, topFadeEnabled = false, loadEarlier = null, + annotation = null, + onAnnotationEdit = NOOP_OPEN_AGENTS, + onAnnotationResolve = NOOP_OPEN_AGENTS, + onAnnotationReopen = NOOP_OPEN_AGENTS, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -614,10 +627,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ListFooterComponent={TIMELINE_LIST_FOOTER} /> { onManualNavigation(); void listRef.current?.scrollToIndex({ @@ -643,6 +662,7 @@ function getItemType(item: MessagesTimelineRow) { interface TimelineMinimapItem { readonly id: string; + readonly messageId: MessageId; readonly rowIndex: number; readonly userText: string | null; readonly assistantText: string | null; @@ -668,6 +688,7 @@ function deriveTimelineMinimapItems( items.push({ id: row.id, + messageId: row.message.id, rowIndex: index, userText: compactMinimapPreview(row.message.text), assistantText: compactMinimapPreview(resolveFinalAssistantTextForTurn(rows, index)), @@ -716,23 +737,42 @@ function timelineMinimapEventTargetsPreview(target: EventTarget): boolean { } function TimelineMinimap({ + annotation, hasPersistentGutter, hitStripWidth, items, + markdownCwd, stripMap, + threadRef, + onAnnotationEdit, + onAnnotationResolve, + onAnnotationReopen, onSelect, }: { + annotation: ThreadAnnotation | null; hasPersistentGutter: boolean; hitStripWidth: number; items: ReadonlyArray; + markdownCwd: string | undefined; stripMap: Map; + threadRef: ScopedThreadRef | null; + onAnnotationEdit: () => void; + onAnnotationResolve: () => void; + onAnnotationReopen: () => void; onSelect: (item: TimelineMinimapItem) => void; }) { const [activeIndex, setActiveIndex] = useState(null); + const [overflowAnnotationOpen, setOverflowAnnotationOpen] = useState(false); const resolvedActiveIndex = activeIndex !== null && activeIndex < items.length ? activeIndex : null; const activeItem = resolvedActiveIndex === null ? null : (items[resolvedActiveIndex] ?? null); + const annotationItemIndex = annotation + ? items.findIndex((item) => item.messageId === annotation.anchorMessageId) + : -1; + const annotationIsEarlier = annotation !== null && annotationItemIndex === -1; + const activeItemHasAnnotation = + annotation !== null && activeItem?.messageId === annotation.anchorMessageId; const activeTopPercent = resolvedActiveIndex === null ? 0 @@ -777,7 +817,7 @@ function TimelineMinimap({ [items.length], ); - if (items.length < TIMELINE_MINIMAP_MIN_ITEMS) { + if (items.length < TIMELINE_MINIMAP_MIN_ITEMS && annotation === null) { return null; } @@ -793,15 +833,19 @@ function TimelineMinimap({ data-persistent-gutter={hasPersistentGutter ? "true" : "false"} >
- +
+ {annotationIsEarlier && annotation && threadRef ? ( +
setOverflowAnnotationOpen(true)} + onMouseLeave={() => setOverflowAnnotationOpen(false)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + setOverflowAnnotationOpen(false); + } + }} + style={{ + top: "50%", + transform: `translateY(calc(-50% - ${resolveTimelineMinimapHeightStyle(items.length)} / 2))`, + }} + > +
+ ) : null} ); diff --git a/apps/web/src/components/thread-annotation/ThreadAnnotation.tsx b/apps/web/src/components/thread-annotation/ThreadAnnotation.tsx new file mode 100644 index 000000000000..064f3e0862a8 --- /dev/null +++ b/apps/web/src/components/thread-annotation/ThreadAnnotation.tsx @@ -0,0 +1,269 @@ +import { + THREAD_ANNOTATION_MAX_BODY_CHARS, + type ScopedThreadRef, + type ThreadAnnotation as ThreadAnnotationModel, +} from "@t3tools/contracts"; +import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; + +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import ChatMarkdown from "../ChatMarkdown"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Textarea } from "../ui/textarea"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; + +export function ThreadAnnotationEditorDialog(props: { + annotation: ThreadAnnotationModel | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (body: string) => Promise; +}) { + const [body, setBody] = useState(""); + const [saving, setSaving] = useState(false); + const textareaRef = useRef(null); + + useEffect(() => { + if (!props.open) return; + setBody(props.annotation?.body ?? ""); + setSaving(false); + }, [props.annotation?.body, props.open]); + + const submit = async (event?: FormEvent) => { + event?.preventDefault(); + const trimmed = body.trim(); + if (!trimmed || saving) return; + setSaving(true); + const saved = await props.onSave(trimmed); + setSaving(false); + if (saved) props.onOpenChange(false); + }; + + return ( + + +
void submit(event)}> + + {props.annotation ? "Edit annotation" : "Annotate thread"} + + Markdown supports headings, lists, task lists, links, and tags. + + + +