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..912d34cecf8f 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 @@ -2469,6 +2511,12 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { role: "assistant", }, ]); + const threadRows = yield* sql<{ readonly latestUserMessageId: string | null }>` + SELECT latest_user_message_id AS "latestUserMessageId" + FROM projection_threads + WHERE thread_id = 'thread-revert' + `; + assert.equal(threadRows[0]?.latestUserMessageId, null); }), ); }); 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..f8d4c8ad54ff --- /dev/null +++ b/apps/server/src/orchestration/decider.annotation.test.ts @@ -0,0 +1,259 @@ +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("keeps an existing anchor when a revert removes every user message", () => + Effect.gen(function* () { + const editedEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.annotation.upsert", + commandId: CommandId.make("cmd-edit-after-empty-revert"), + threadId: ThreadId.make("thread-1"), + body: "Edited after revert", + }, + readModel: makeReadModel(existingAnnotation, null), + }); + const edited = Array.isArray(editedEvent) ? editedEvent[0] : editedEvent; + expect(edited?.type).toBe("thread.annotation-upserted"); + if (edited?.type !== "thread.annotation-upserted") return; + expect(edited.payload.annotation.anchorMessageId).toBe(existingAnnotation.anchorMessageId); + + const resolvedEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.annotation.resolve", + commandId: CommandId.make("cmd-resolve-after-empty-revert"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(edited.payload.annotation, null), + }); + const resolved = Array.isArray(resolvedEvent) ? resolvedEvent[0] : resolvedEvent; + expect(resolved?.type).toBe("thread.annotation-resolved"); + if (resolved?.type !== "thread.annotation-resolved") return; + expect(resolved.payload.annotation.anchorMessageId).toBe(existingAnnotation.anchorMessageId); + + const reopenedEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.annotation.reopen", + commandId: CommandId.make("cmd-reopen-after-empty-revert"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(resolved.payload.annotation, null), + }); + const reopened = Array.isArray(reopenedEvent) ? reopenedEvent[0] : reopenedEvent; + expect(reopened?.type).toBe("thread.annotation-reopened"); + if (reopened?.type === "thread.annotation-reopened") { + expect(reopened.payload.annotation.anchorMessageId).toBe( + existingAnnotation.anchorMessageId, + ); + } + }), + ); + + 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..d61d0bff9308 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -142,6 +142,22 @@ 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 nextAnnotationAnchorMessageId(thread: OrchestrationReadModel["threads"][number]) { + return latestUserMessageId(thread) ?? thread.annotation?.anchorMessageId; +} + function withEventBase( input: Pick & { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; @@ -805,6 +821,121 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.annotation.upsert": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + const anchorMessageId = nextAnnotationAnchorMessageId(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 = nextAnnotationAnchorMessageId(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 = nextAnnotationAnchorMessageId(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..9a2f6c55a75d 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: { @@ -701,6 +702,7 @@ describe("orchestration projector", () => { ).toEqual([{ id: "activity-1", turnId: "turn-1" }]); expect(thread?.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount)).toEqual([1]); expect(thread?.latestTurn?.turnId).toBe("turn-1"); + expect(thread?.latestUserMessageId).toBe("user-msg-1"); }); it("does not fallback-retain messages tied to removed turn IDs", async () => { @@ -854,6 +856,7 @@ describe("orchestration projector", () => { turnId: message.turnId, })), ).toEqual([{ id: "assistant-keep", role: "assistant", turnId: "turn-1" }]); + expect(thread?.latestUserMessageId).toBeNull(); }); it("caps message and checkpoint retention for long-lived threads", async () => { diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index f486dcb2bcbc..743c11138cf5 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,4 +1,9 @@ -import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; +import type { + MessageId, + OrchestrationEvent, + OrchestrationReadModel, + ThreadId, +} from "@t3tools/contracts"; import { OrchestrationCheckpointSummary, OrchestrationMessage, @@ -25,6 +30,7 @@ import { ThreadSettledPayload, ThreadPinnedPayload, ThreadPinReorderedPayload, + ThreadAnnotationChangedPayload, ThreadSnoozedPayload, ThreadUnpinnedPayload, ThreadUnarchivedPayload, @@ -150,6 +156,17 @@ function retainThreadMessagesAfterRevert( return messages.filter((message) => retainedMessageIds.has(message.id)); } +function latestUserMessageId(messages: ReadonlyArray): MessageId | null { + return ( + messages + .filter((message) => message.role === "user") + .toSorted( + (left, right) => + right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id), + )[0]?.id ?? null + ); +} + function retainThreadActivitiesAfterRevert( activities: ReadonlyArray, retainedTurnIds: ReadonlySet, @@ -305,6 +322,7 @@ export function projectEvent( settledAt: null, snoozedUntil: null, snoozedAt: null, + annotation: null, deletedAt: null, messages: [], activities: [], @@ -442,6 +460,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 +579,7 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { messages: cappedMessages, + latestUserMessageId: latestUserMessageId(cappedMessages), updatedAt: event.occurredAt, }), }; @@ -761,6 +797,7 @@ export function projectEvent( threads: updateThread(nextBase.threads, payload.threadId, { checkpoints, messages, + latestUserMessageId: latestUserMessageId(messages), proposedPlans, activities, latestTurn, 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/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 9499ee5a6915..bb5a84409719 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { orderedListGutterStyle } from "./ChatMarkdown"; +import { orderedListGutterStyle, resolveChatMarkdownEnvironmentId } from "./ChatMarkdown"; describe("orderedListGutterStyle", () => { it("leaves the default gutter alone for single-digit lists", () => { @@ -34,3 +35,25 @@ describe("orderedListGutterStyle", () => { expect(orderedListGutterStyle(0, undefined)).toBeUndefined(); }); }); + +describe("resolveChatMarkdownEnvironmentId", () => { + it("uses the supplied thread environment for cross-environment markdown actions", () => { + const activeEnvironmentId = EnvironmentId.make("environment-a"); + const threadEnvironmentId = EnvironmentId.make("environment-b"); + + expect( + resolveChatMarkdownEnvironmentId(activeEnvironmentId, { + environmentId: threadEnvironmentId, + threadId: ThreadId.make("thread-b"), + }), + ).toBe(threadEnvironmentId); + }); + + it("falls back to the active environment without a thread reference", () => { + const activeEnvironmentId = EnvironmentId.make("environment-a"); + + expect(resolveChatMarkdownEnvironmentId(activeEnvironmentId, undefined)).toBe( + activeEnvironmentId, + ); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c4548540e2ce..fd5b5b93e634 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -13,7 +13,7 @@ import { TriangleAlertIcon, WrapTextIcon, } from "lucide-react"; -import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -112,6 +112,7 @@ interface ChatMarkdownProps { cwd: string | undefined; threadRef?: ScopedThreadRef | undefined; onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; + taskListDisabled?: boolean; isStreaming?: boolean; skills?: ReadonlyArray>; className?: string; @@ -121,6 +122,13 @@ interface ChatMarkdownProps { parseRawHtml?: boolean; } +export function resolveChatMarkdownEnvironmentId( + activeEnvironmentId: EnvironmentId | null, + threadRef: ScopedThreadRef | undefined, +): EnvironmentId | null { + return threadRef?.environmentId ?? activeEnvironmentId; +} + const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; @@ -1358,6 +1366,7 @@ function ChatMarkdown({ cwd, threadRef, onTaskListChange, + taskListDisabled = false, isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, className, @@ -1375,7 +1384,8 @@ function ChatMarkdown({ reportFailure: false, }); const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); - const environmentId = useActiveEnvironmentId(); + const activeEnvironmentId = useActiveEnvironmentId(); + const environmentId = resolveChatMarkdownEnvironmentId(activeEnvironmentId, threadRef); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const openInPreferredEditor = useOpenInPreferredEditor( environmentId, @@ -1615,6 +1625,7 @@ function ChatMarkdown({ name="markdown-task" aria-label="Toggle task" checked={checked} + disabled={taskListDisabled} onChange={(event) => { const markerOffset = Number( event.currentTarget.closest("li")?.dataset.taskMarkerOffset, @@ -1778,6 +1789,7 @@ function ChatMarkdown({ openMarkdownFileInPreview, resolvedTheme, skills, + taskListDisabled, text, threadRef, ]); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cbcfee4bb623..029729478e33 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,11 @@ import { threadChangeRequestSnapshotsAtom, } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { + runThreadAnnotationBodySave, + ThreadAnnotationEditorDialog, + ThreadAnnotationPostIt, +} from "./thread-annotation/ThreadAnnotation"; import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, @@ -1224,6 +1230,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 +4187,81 @@ 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 canAnnotateThread = + isServerThread && + supportsThreadAnnotations && + (threadAnnotation !== null || + activeThread?.messages.some((message) => message.role === "user") === true); + 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 (!activeThread || !canAnnotateThread) return false; + return runThreadAnnotationBodySave( + scopeThreadRef(activeThread.environmentId, activeThread.id), + async () => { + 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, canAnnotateThread, reportAnnotationFailure, upsertThreadAnnotation], + ); + + const changeThreadAnnotationResolution = useCallback( + async (next: "resolve" | "reopen") => { + if (!activeThread || !canAnnotateThread) 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, + canAnnotateThread, + reopenThreadAnnotation, + reportAnnotationFailure, + resolveThreadAnnotation, + ], + ); const nowMinute = useNowMinute(); const snoozeNow = new Date().toISOString(); const activeThreadSnoozed = @@ -5079,7 +5169,51 @@ 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 (!canAnnotateThread) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: + isServerThread && !supportsThreadAnnotations + ? "Annotations unavailable" + : "Send a message first", + description: + isServerThread && !supportsThreadAnnotations + ? "This environment needs a newer LastCode server to annotate threads." + : "Annotations can be added after the thread has its first message.", + }), + ); + 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 +6603,11 @@ function ChatViewContent(props: ChatViewProps) { hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} loadEarlier={loadEarlierTurns} + annotation={threadAnnotation} + onAnnotationBodyChange={saveThreadAnnotation} + 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 +6667,20 @@ 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 +6745,7 @@ function ChatViewContent(props: ChatViewProps) { keybindings={keybindings} terminalOpen={Boolean(terminalUiState.terminalOpen)} gitCwd={gitCwd} + threadAnnotationsSupported={canAnnotateThread} promptRef={promptRef} composerImagesRef={composerImagesRef} composerTerminalContextsRef={composerTerminalContextsRef} @@ -6619,6 +6773,7 @@ function ChatViewContent(props: ChatViewProps) { scheduleComposerFocus={scheduleComposerFocus} setThreadError={setThreadError} onExpandImage={onExpandTimelineImage} + onOpenThreadAnnotation={() => setAnnotationEditorOpen(true)} /> @@ -6708,6 +6863,13 @@ function ChatViewContent(props: ChatViewProps) { + + {pullRequestDialogState ? ( >; orderedProjectThreadKeys: readonly string[]; isActive: boolean; openPullRequestsInRightPanel: boolean; @@ -353,6 +368,9 @@ interface SidebarThreadRowProps { prUrl: string, threadRef?: ScopedThreadRef, ) => boolean; + onEditAnnotation: (thread: SidebarThreadSummary) => void; + onSaveAnnotationBody: (thread: SidebarThreadSummary, body: string) => Promise; + onResolveAnnotation: (thread: SidebarThreadSummary) => void; } export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { @@ -380,6 +398,10 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr cancelRename, attemptArchiveThread, openPrLink, + onEditAnnotation, + onSaveAnnotationBody, + onResolveAnnotation, + providerEntriesByEnvironmentId, thread, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); @@ -474,16 +496,53 @@ 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 branchMismatch = resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", + activeWorktreePath: thread.worktreePath, + activeThreadBranch: thread.branch, + currentGitBranch: gitStatus.data?.refName ?? null, + }); + const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const environmentProviderEntries = providerEntriesByEnvironmentId.get(thread.environmentId); + const providerEntry = environmentProviderEntries?.get(modelInstanceId) ?? null; + const showInstanceBadge = + providerEntry !== null && + shouldShowInstanceBadge(providerEntry, environmentProviderEntries?.values() ?? []); + const selectedModel = providerEntry?.models.find( + (model) => model.slug === thread.modelSelection.model, + ); + const modelLabel = selectedModel + ? getTriggerDisplayModelLabel(selectedModel) + : thread.modelSelection.model; + const threadHoverDetails = ( + + ); 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 +552,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr return; } clearConfirmingArchive(); + setAnnotationRowActive(false); }); }, [clearConfirmingArchive], @@ -685,6 +745,8 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr setAnnotationRowActive(true)} + onMouseEnter={() => setAnnotationRowActive(true)} onMouseLeave={handleMouseLeave} onBlurCapture={handleBlurCapture} > @@ -737,6 +799,13 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr onClick={handleRenameInputClick} onDoubleClick={handleRenameInputClick} /> + ) : hasActiveAnnotation ? ( + + {thread.title} + ) : ( } /> - - {thread.title} + + {threadHoverDetails} )} @@ -872,19 +947,63 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr )} {jumpLabel ? ( - - onSaveAnnotationBody(thread, body)} + onEdit={() => onEditAnnotation(thread)} + onResolve={() => onResolveAnnotation(thread)} + rowActive={annotationRowActive} + threadDetails={threadHoverDetails} + threadRef={threadRef} + trigger={ + aria-label={`${jumpLabel}; annotated`} + className="inline-flex h-5 items-center rounded-full border border-dotted border-yellow-500/65 bg-accent/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-accent-foreground shadow-sm" + > + {jumpLabel} + } - > - {jumpLabel} - - {jumpLabel} - + /> + ) : ( + + + } + > + {jumpLabel} + + {jumpLabel} + + ) + ) : hasActiveAnnotation && thread.annotation ? ( + onSaveAnnotationBody(thread, body)} + onEdit={() => onEditAnnotation(thread)} + onResolve={() => onResolveAnnotation(thread)} + rowActive={annotationRowActive} + threadDetails={threadHoverDetails} + threadRef={threadRef} + trigger={ + + {formatRelativeTimeLabel( + thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + )} + + } + /> ) : ( >; projectKey: string; projectExpanded: boolean; hasOverflowingThreads: boolean; @@ -957,6 +1077,9 @@ interface SidebarProjectThreadListProps { prUrl: string, threadRef?: ScopedThreadRef, ) => boolean; + onEditAnnotation: (thread: SidebarThreadSummary) => void; + onSaveAnnotationBody: (thread: SidebarThreadSummary, body: string) => Promise; + onResolveAnnotation: (thread: SidebarThreadSummary) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; } @@ -967,6 +1090,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( const { legacySidebarScale, scaleStyle, + providerEntriesByEnvironmentId, projectKey, projectExpanded, hasOverflowingThreads, @@ -1000,6 +1124,9 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( cancelRename, attemptArchiveThread, openPrLink, + onEditAnnotation, + onSaveAnnotationBody, + onResolveAnnotation, expandThreadListForProject, collapseThreadListForProject, } = props; @@ -1031,6 +1158,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( key={threadKey} thread={thread} projectCwd={projectCwd} + providerEntriesByEnvironmentId={providerEntriesByEnvironmentId} orderedProjectThreadKeys={orderedProjectThreadKeys} isActive={activeRouteThreadKey === threadKey} openPullRequestsInRightPanel={openPullRequestsInRightPanel} @@ -1054,6 +1182,9 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( cancelRename={cancelRename} attemptArchiveThread={attemptArchiveThread} openPrLink={openPrLink} + onEditAnnotation={onEditAnnotation} + onSaveAnnotationBody={onSaveAnnotationBody} + onResolveAnnotation={onResolveAnnotation} /> ); })} @@ -1098,6 +1229,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( interface SidebarProjectItemProps { legacySidebarScale: LegacySidebarScale; scaleStyle: CSSProperties; + providerEntriesByEnvironmentId: ReadonlyMap>; project: SidebarProjectSnapshot; isThreadListExpanded: boolean; activeRouteThreadKey: string | null; @@ -1121,6 +1253,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const { legacySidebarScale, scaleStyle, + providerEntriesByEnvironmentId, project, isThreadListExpanded, activeRouteThreadKey, @@ -1158,6 +1291,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 +1385,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 +1901,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 +2175,61 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec renamingInputRef.current = null; }, []); + const saveAnnotationBody = useCallback( + async (target: SidebarThreadSummary, body: string): Promise => { + return runThreadAnnotationBodySave( + scopeThreadRef(target.environmentId, target.id), + async () => { + 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; + }, + ); + }, + [upsertThreadAnnotation], + ); + + const saveAnnotation = useCallback( + async (body: string): Promise => { + if (!annotationEditorTarget) return false; + return saveAnnotationBody(annotationEditorTarget, body); + }, + [annotationEditorTarget, saveAnnotationBody], + ); + + 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 +2370,18 @@ 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 && thread.latestUserMessageAt !== null + ? [{ 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 +2419,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; @@ -2385,6 +2603,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec void resolveAnnotation(thread)} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} /> + { + if (!open) setAnnotationEditorTarget(null); + }} + onSave={saveAnnotation} + /> + { @@ -2828,6 +3059,7 @@ interface SidebarProjectsContentProps { projectsLength: number; legacySidebarScale: LegacySidebarScale; projectTreeScaleStyle: CSSProperties; + providerEntriesByEnvironmentId: ReadonlyMap>; } // Drafts the user typed into but never sent, rendered above the projects @@ -2938,6 +3170,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( projectsLength, legacySidebarScale, projectTreeScaleStyle, + providerEntriesByEnvironmentId, } = props; const handleProjectSortOrderChange = useCallback( @@ -3066,6 +3299,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( s.sidebarThreadPreviewCount); const legacySidebarScale = useClientSettings((s) => s.legacySidebarScale); + const serverProviders = useAtomValue(primaryServerProvidersAtom); const scaleStyle = useMemo( () => legacySidebarScaleStyle(legacySidebarScale), [legacySidebarScale], @@ -3196,6 +3432,23 @@ export default function LegacySidebar() { const shortcutModifiers = useShortcutModifierState(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const providerEntriesByEnvironmentId = useMemo(() => { + const entriesByEnvironmentId = new Map>(); + for (const environment of environments) { + const environmentProviders = + environment.serverConfig?.providers ?? + (environment.environmentId === primaryEnvironmentId ? serverProviders : []); + entriesByEnvironmentId.set( + environment.environmentId, + new Map( + deriveProviderInstanceEntries(environmentProviders).map( + (entry) => [entry.instanceId as string, entry] as const, + ), + ), + ); + } + return entriesByEnvironmentId; + }, [environments, primaryEnvironmentId, serverProviders]); const environmentLabelById = useMemo( () => new Map( @@ -3817,6 +4070,7 @@ export default function LegacySidebar() { projectsLength={projects.length} legacySidebarScale={legacySidebarScale} projectTreeScaleStyle={scaleStyle} + providerEntriesByEnvironmentId={providerEntriesByEnvironmentId} /> diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 86c81b707d67..46bed987822c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -37,13 +37,11 @@ import { AlarmClockOffIcon, CheckIcon, ChevronDownIcon, - CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, ClockIcon, FolderIcon, FolderPlusIcon, - GitBranchIcon, MessageSquareIcon, PinIcon, PlusIcon, @@ -153,7 +151,6 @@ import { terminalStatusFromRunningIds, threadChangeRequestSnapshotsAtom, type ThreadChangeRequestSnapshot, - type TerminalStatusIndicator, } from "./ThreadStatusIndicators"; import { resolveSnoozePresets, @@ -163,6 +160,10 @@ import { } from "./Sidebar.snooze"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; +import { + SidebarThreadHoverContent, + type SidebarThreadHoverContentProps, +} from "./sidebar/SidebarThreadHoverContent"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { deriveProviderInstanceEntries, @@ -251,37 +252,7 @@ function terminalProcessLabel(count: number): string { return `${count} terminal ${count === 1 ? "process" : "processes"} running`; } -function SidebarThreadTooltip({ - thread, - projectTitle, - projectCwd, - projectFaviconPath, - environmentLabel, - providerEntry, - showInstanceBadge, - modelInstanceId, - modelLabel, - branchMismatch, - terminalStatus, - terminalProcessCount, -}: { - thread: SidebarThreadSummary; - projectTitle: string | null; - projectCwd: string | null; - projectFaviconPath: string | null; - environmentLabel: string | null; - providerEntry: ProviderInstanceEntry | null; - showInstanceBadge: boolean; - modelInstanceId: string; - modelLabel: string; - branchMismatch: { - threadBranch: string; - currentBranch: string; - } | null; - terminalStatus: TerminalStatusIndicator | null; - terminalProcessCount: number; -}) { - const driverKind = providerEntry?.driverKind ?? null; +function SidebarThreadTooltip(props: SidebarThreadHoverContentProps) { return ( -
-
- {thread.title} -
-
- {projectTitle ? ( -
- -
{projectTitle}
-
- ) : null} - {environmentLabel ? ( -
- -
{environmentLabel}
-
- ) : null} - {thread.branch ? ( -
- -
{thread.branch}
-
- ) : null} - {branchMismatch ? ( -
- -
- You're currently checked out on another branch. -
-
- ) : null} - {driverKind ? ( -
- -
- {showInstanceBadge && providerEntry - ? `${modelLabel} · ${providerEntry.displayName}` - : modelLabel} -
-
- ) : null} - {terminalStatus ? ( -
- -
- {terminalProcessLabel(terminalProcessCount)} -
-
- ) : null} - {thread.session?.lastError ? ( -
- -
Error occurred
-
- ) : null} -
-
+
); } 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..14f044473edd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -237,6 +237,50 @@ 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).toContain("[@media(pointer:coarse)]:block"); + expect(markup).toContain("[@media(pointer:coarse)]:opacity-100"); + 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).toContain('class="pointer-events-auto absolute left-3"'); + 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..197daf5584a9 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,11 @@ import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; +import { + ThreadAnnotationActions, + ThreadAnnotationBody, + useThreadAnnotationBodyPending, +} from "../thread-annotation/ThreadAnnotation"; import { buildInlineTerminalContextText, @@ -243,6 +249,11 @@ 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; + onAnnotationBodyChange?: ((body: string) => Promise) | undefined; + onAnnotationResolve?: () => void; + onAnnotationReopen?: () => void; } // --------------------------------------------------------------------------- @@ -283,6 +294,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ hideEmptyPlaceholder = false, topFadeEnabled = false, loadEarlier = null, + annotation = null, + onAnnotationEdit = NOOP_OPEN_AGENTS, + onAnnotationBodyChange, + onAnnotationResolve = NOOP_OPEN_AGENTS, + onAnnotationReopen = NOOP_OPEN_AGENTS, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -614,10 +630,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ListFooterComponent={TIMELINE_LIST_FOOTER} /> { onManualNavigation(); void listRef.current?.scrollToIndex({ @@ -643,6 +666,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 +692,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 +741,45 @@ function timelineMinimapEventTargetsPreview(target: EventTarget): boolean { } function TimelineMinimap({ + annotation, hasPersistentGutter, hitStripWidth, items, + markdownCwd, stripMap, + threadRef, + onAnnotationEdit, + onAnnotationBodyChange, + onAnnotationResolve, + onAnnotationReopen, onSelect, }: { + annotation: ThreadAnnotation | null; hasPersistentGutter: boolean; hitStripWidth: number; items: ReadonlyArray; + markdownCwd: string | undefined; stripMap: Map; + threadRef: ScopedThreadRef | null; + onAnnotationEdit: () => void; + onAnnotationBodyChange: ((body: string) => Promise) | undefined; + onAnnotationResolve: () => void; + onAnnotationReopen: () => void; onSelect: (item: TimelineMinimapItem) => void; }) { + const annotationBodyPending = useThreadAnnotationBodyPending(threadRef); 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 +824,7 @@ function TimelineMinimap({ [items.length], ); - if (items.length < TIMELINE_MINIMAP_MIN_ITEMS) { + if (items.length < TIMELINE_MINIMAP_MIN_ITEMS && annotation === null) { return null; } @@ -785,6 +832,8 @@ function TimelineMinimap({ ); diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts index 3b5295f180eb..a5265e48cab1 100644 --- a/apps/web/src/components/files/FilePreviewPanel.test.ts +++ b/apps/web/src/components/files/FilePreviewPanel.test.ts @@ -5,7 +5,8 @@ import { normalizeFileCommentRange, remapFileCommentAnnotations, } from "./fileCommentAnnotations"; -import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; +import { setMarkdownTaskChecked } from "../../markdownTaskList"; +import { isMarkdownPreviewFile } from "./filePreviewMode"; describe("file comment annotations", () => { it("normalizes and formats selected line ranges", () => { diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 1bdebc5a4551..b8de5f6d8741 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -26,6 +26,7 @@ import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; +import { setMarkdownTaskChecked } from "~/markdownTaskList"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; import { resolvePathLinkTarget } from "~/terminal-links"; import { ScrollArea } from "~/components/ui/scroll-area"; @@ -56,7 +57,7 @@ import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { fileBreadcrumbs } from "./filePath"; -import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; +import { isMarkdownPreviewFile } from "./filePreviewMode"; import { FileSaveCoordinator } from "./fileSaveCoordinator"; import { confirmProjectFileQueryData, diff --git a/apps/web/src/components/files/filePreviewMode.ts b/apps/web/src/components/files/filePreviewMode.ts index 63249dbc72f0..4bab333a816b 100644 --- a/apps/web/src/components/files/filePreviewMode.ts +++ b/apps/web/src/components/files/filePreviewMode.ts @@ -1,18 +1 @@ export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path); - -export function setMarkdownTaskChecked( - markdown: string, - markerOffset: number, - checked: boolean, -): string { - if ( - markerOffset < 0 || - markdown[markerOffset] !== "[" || - !/[ xX]/.test(markdown[markerOffset + 1] ?? "") || - markdown[markerOffset + 2] !== "]" - ) { - return markdown; - } - - return `${markdown.slice(0, markerOffset + 1)}${checked ? "x" : " "}${markdown.slice(markerOffset + 2)}`; -} diff --git a/apps/web/src/components/sidebar/SidebarThreadHoverContent.tsx b/apps/web/src/components/sidebar/SidebarThreadHoverContent.tsx new file mode 100644 index 000000000000..4826863b5ac5 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarThreadHoverContent.tsx @@ -0,0 +1,114 @@ +import { CircleAlertIcon, GitBranchIcon, ServerIcon, TerminalIcon } from "lucide-react"; + +import type { ProviderInstanceEntry } from "../../providerInstances"; +import type { SidebarThreadSummary } from "../../types"; +import { cn } from "~/lib/utils"; +import { ProjectFavicon } from "../ProjectFavicon"; +import type { TerminalStatusIndicator } from "../ThreadStatusIndicators"; +import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; + +export interface SidebarThreadHoverContentProps { + thread: SidebarThreadSummary; + projectTitle: string | null; + projectCwd: string | null; + projectFaviconPath: string | null; + environmentLabel: string | null; + providerEntry: ProviderInstanceEntry | null; + showInstanceBadge: boolean; + modelInstanceId: string; + modelLabel: string; + branchMismatch: { + threadBranch: string; + currentBranch: string; + } | null; + terminalStatus: TerminalStatusIndicator | null; + terminalProcessCount: number; +} + +function terminalProcessLabel(count: number): string { + return `${count} terminal ${count === 1 ? "process" : "processes"} running`; +} + +export function SidebarThreadHoverContent(props: SidebarThreadHoverContentProps) { + const driverKind = props.providerEntry?.driverKind ?? null; + + return ( +
+
+ {props.thread.title} +
+
+ {props.projectTitle ? ( +
+ +
{props.projectTitle}
+
+ ) : null} + {props.environmentLabel ? ( +
+ +
{props.environmentLabel}
+
+ ) : null} + {props.thread.branch ? ( +
+ +
{props.thread.branch}
+
+ ) : null} + {props.branchMismatch ? ( +
+ +
+ You're currently checked out on another branch. +
+
+ ) : null} + {driverKind ? ( +
+ +
+ {props.showInstanceBadge && props.providerEntry + ? `${props.modelLabel} · ${props.providerEntry.displayName}` + : props.modelLabel} +
+
+ ) : null} + {props.terminalStatus ? ( +
+ +
+ {terminalProcessLabel(props.terminalProcessCount)} +
+
+ ) : null} + {props.thread.session?.lastError ? ( +
+ +
Error occurred
+
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/thread-annotation/ThreadAnnotation.test.ts b/apps/web/src/components/thread-annotation/ThreadAnnotation.test.ts new file mode 100644 index 000000000000..8c8894a070ff --- /dev/null +++ b/apps/web/src/components/thread-annotation/ThreadAnnotation.test.ts @@ -0,0 +1,42 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { runThreadAnnotationBodySave } from "./ThreadAnnotation"; + +const THREAD_REF = scopeThreadRef( + EnvironmentId.make("annotation-test-environment"), + ThreadId.make("annotation-test-thread"), +); + +describe("runThreadAnnotationBodySave", () => { + it("serializes complete-body saves for the same thread", async () => { + let releaseFirst: () => void = () => {}; + const firstSave = runThreadAnnotationBodySave( + THREAD_REF, + () => + new Promise((resolve) => { + releaseFirst = () => resolve(true); + }), + ); + const overlappingSave = vi.fn(async () => true); + + await expect(runThreadAnnotationBodySave(THREAD_REF, overlappingSave)).resolves.toBe(false); + expect(overlappingSave).not.toHaveBeenCalled(); + + releaseFirst(); + await expect(firstSave).resolves.toBe(true); + await expect(runThreadAnnotationBodySave(THREAD_REF, overlappingSave)).resolves.toBe(true); + expect(overlappingSave).toHaveBeenCalledOnce(); + }); + + it("releases the thread after a failed save", async () => { + await expect( + runThreadAnnotationBodySave(THREAD_REF, async () => { + throw new Error("save failed"); + }), + ).rejects.toThrow("save failed"); + + await expect(runThreadAnnotationBodySave(THREAD_REF, async () => true)).resolves.toBe(true); + }); +}); 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..73642d624eab --- /dev/null +++ b/apps/web/src/components/thread-annotation/ThreadAnnotation.tsx @@ -0,0 +1,384 @@ +import { + THREAD_ANNOTATION_MAX_BODY_CHARS, + type ScopedThreadRef, + type ThreadAnnotation as ThreadAnnotationModel, +} from "@t3tools/contracts"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, + type FormEvent, + type ReactNode, +} from "react"; + +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { setMarkdownTaskChecked } from "../../markdownTaskList"; +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"; + +const pendingBodyChanges = new Set(); +const pendingBodyChangeListeners = new Map void>>(); + +function setBodyChangePending(threadKey: string, pending: boolean) { + if (pending) pendingBodyChanges.add(threadKey); + else pendingBodyChanges.delete(threadKey); + pendingBodyChangeListeners.get(threadKey)?.forEach((listener) => listener()); +} + +function subscribeToBodyChange(threadKey: string | null, listener: () => void) { + if (!threadKey) return () => undefined; + const listeners = pendingBodyChangeListeners.get(threadKey) ?? new Set(); + listeners.add(listener); + pendingBodyChangeListeners.set(threadKey, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) pendingBodyChangeListeners.delete(threadKey); + }; +} + +export function useThreadAnnotationBodyPending(threadRef: ScopedThreadRef | null): boolean { + const threadKey = threadRef ? scopedThreadKey(threadRef) : null; + return useSyncExternalStore( + (listener) => subscribeToBodyChange(threadKey, listener), + () => (threadKey ? pendingBodyChanges.has(threadKey) : false), + () => false, + ); +} + +export async function runThreadAnnotationBodySave( + threadRef: ScopedThreadRef, + save: () => Promise, +): Promise { + const threadKey = scopedThreadKey(threadRef); + if (pendingBodyChanges.has(threadKey)) return false; + setBodyChangePending(threadKey, true); + try { + return await save(); + } finally { + setBodyChangePending(threadKey, false); + } +} + +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); + const wasOpenRef = useRef(false); + + useEffect(() => { + const justOpened = props.open && !wasOpenRef.current; + wasOpenRef.current = props.open; + if (!justOpened) 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. + + + +