diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index e6c53dd06b02..b81e6a53377f 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -91,6 +91,8 @@ describe("CheckpointDiffQueryLive", () => { getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + listAllTags: () => Effect.succeed([]), + getTagById: () => Effect.succeed(Option.none()), }), ), ); @@ -150,6 +152,8 @@ describe("CheckpointDiffQueryLive", () => { getThreadCheckpointContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + listAllTags: () => Effect.succeed([]), + getTagById: () => Effect.succeed(Option.none()), }), ), ); diff --git a/apps/server/src/git/Prompts.ts b/apps/server/src/git/Prompts.ts index a187d1a686e0..b99de11a99ea 100644 --- a/apps/server/src/git/Prompts.ts +++ b/apps/server/src/git/Prompts.ts @@ -34,9 +34,7 @@ export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { "- subject must be imperative, <= 72 chars, and no trailing period", "- body can be empty string or short bullet points", ...(wantsBranch - ? [ - "- branch must be a short semantic git branch fragment for this change", - ] + ? ["- branch must be a short semantic git branch fragment for this change"] : []), "- capture the primary user-visible or developer-visible change", "", @@ -136,8 +134,7 @@ interface PromptFromMessageInput { function buildPromptFromMessage(input: PromptFromMessageInput): string { const attachmentLines = (input.attachments ?? []).map( - (attachment) => - `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, + (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, ); const promptSections = [ diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index ab635628958f..ce637cda53ab 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -229,7 +229,9 @@ export const launchDetached = (launch: EditorLaunch) => const handleError = (cause: Error) => { if (!settled) { settled = true; - resume(Effect.fail(new OpenError({ message: "failed to spawn detached process", cause }))); + resume( + Effect.fail(new OpenError({ message: "failed to spawn detached process", cause })), + ); } }; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index b61664f16196..27a43283110e 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -98,6 +98,7 @@ describe("OrchestrationEngine", () => { const projectionSnapshot = { snapshotSequence: 7, updatedAt: "2026-03-03T00:00:04.000Z", + tags: [], projects: [ { id: asProjectId("project-bootstrap"), @@ -108,6 +109,7 @@ describe("OrchestrationEngine", () => { model: "gpt-5-codex", }, scripts: [], + tags: [], createdAt: "2026-03-03T00:00:00.000Z", updatedAt: "2026-03-03T00:00:01.000Z", deletedAt: null, @@ -149,6 +151,7 @@ describe("OrchestrationEngine", () => { snapshotSequence: projectionSnapshot.snapshotSequence, projects: [], threads: [], + tags: [], updatedAt: projectionSnapshot.updatedAt, }), getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), @@ -158,6 +161,8 @@ describe("OrchestrationEngine", () => { getThreadCheckpointContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + listAllTags: () => Effect.succeed([]), + getTagById: () => Effect.succeed(Option.none()), }), ), Layer.provide( diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index ddd1718faf02..5f19bc9ad108 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -2,6 +2,7 @@ import type { OrchestrationEvent, OrchestrationReadModel, ProjectId, + TagId, ThreadId, } from "@t3tools/contracts"; import { OrchestrationCommand } from "@t3tools/contracts"; @@ -51,8 +52,8 @@ interface CommandEnvelope { } function commandToAggregateRef(command: OrchestrationCommand): { - readonly aggregateKind: "project" | "thread"; - readonly aggregateId: ProjectId | ThreadId; + readonly aggregateKind: "project" | "thread" | "tag"; + readonly aggregateId: ProjectId | ThreadId | TagId; } { switch (command.type) { case "project.create": @@ -62,6 +63,13 @@ function commandToAggregateRef(command: OrchestrationCommand): { aggregateKind: "project", aggregateId: command.projectId, }; + case "tag.create": + case "tag.rename": + case "tag.delete": + return { + aggregateKind: "tag", + aggregateId: command.tagId, + }; default: return { aggregateKind: "thread", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 7a08765647fe..5efb683ab945 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -72,6 +72,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { workspaceRoot: "/tmp/project-1", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -344,6 +345,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { workspaceRoot: "/tmp/project-clear-attachments", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -472,6 +474,7 @@ it.layer( workspaceRoot: "/tmp/project-overwrite", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -620,6 +623,7 @@ it.layer( workspaceRoot: "/tmp/project-rollback", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -749,6 +753,7 @@ it.layer( workspaceRoot: "/tmp/project-revert-files", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -957,6 +962,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta workspaceRoot: "/tmp/project-delete-files", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -1120,6 +1126,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { workspaceRoot: "/tmp/project-a", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -1247,6 +1254,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { workspaceRoot: "/tmp/project-empty", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index d981ae0da627..b5aa4a33da5a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -12,6 +12,7 @@ import { OrchestrationEventStore } from "../../persistence/Services/Orchestratio import { ProjectionPendingApprovalRepository } from "../../persistence/Services/ProjectionPendingApprovals.ts"; import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts"; import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts"; +import { ProjectionTagRepository } from "../../persistence/Services/ProjectionTags.ts"; import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts"; import { type ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; import { @@ -31,6 +32,7 @@ import { ProjectionThreadRepository } from "../../persistence/Services/Projectio import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts"; import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts"; import { ProjectionStateRepositoryLive } from "../../persistence/Layers/ProjectionState.ts"; +import { ProjectionTagRepositoryLive } from "../../persistence/Layers/ProjectionTags.ts"; import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts"; import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts"; @@ -38,6 +40,7 @@ import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; import { ServerConfig } from "../../config.ts"; +import { normalizeTagNameForDedup } from "../commandInvariants.ts"; import { OrchestrationProjectionPipeline, type OrchestrationProjectionPipelineShape, @@ -59,6 +62,7 @@ export const ORCHESTRATION_PROJECTOR_NAMES = { threadTurns: "projection.thread-turns", checkpoints: "projection.checkpoints", pendingApprovals: "projection.pending-approvals", + tags: "projection.tags", } as const; type ProjectorName = @@ -444,6 +448,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const eventStore = yield* OrchestrationEventStore; const projectionStateRepository = yield* ProjectionStateRepository; const projectionProjectRepository = yield* ProjectionProjectRepository; + const projectionTagRepository = yield* ProjectionTagRepository; const projectionThreadRepository = yield* ProjectionThreadRepository; const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository; @@ -467,6 +472,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti workspaceRoot: event.payload.workspaceRoot, defaultModelSelection: event.payload.defaultModelSelection, scripts: event.payload.scripts, + tags: event.payload.tags, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, deletedAt: null, @@ -490,6 +496,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ? { defaultModelSelection: event.payload.defaultModelSelection } : {}), ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), + ...(event.payload.tags !== undefined ? { tags: event.payload.tags } : {}), updatedAt: event.payload.updatedAt, }); return; @@ -515,6 +522,47 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); + const applyTagsProjection: ProjectorDefinition["apply"] = Effect.fn("applyTagsProjection")( + function* (event, _attachmentSideEffects) { + switch (event.type) { + case "tag.created": + yield* projectionTagRepository.upsert({ + tagId: event.payload.tagId, + name: event.payload.name, + nameNormalized: normalizeTagNameForDedup(event.payload.name), + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + }); + return; + + case "tag.renamed": { + const existingRow = yield* projectionTagRepository.getById({ + tagId: event.payload.tagId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionTagRepository.upsert({ + ...existingRow.value, + name: event.payload.name, + nameNormalized: normalizeTagNameForDedup(event.payload.name), + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "tag.deleted": + yield* projectionTagRepository.deleteById({ + tagId: event.payload.tagId, + }); + return; + + default: + return; + } + }, + ); + const refreshThreadShellSummary = Effect.fn("refreshThreadShellSummary")(function* ( threadId: ThreadId, ) { @@ -1371,6 +1419,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti name: ORCHESTRATION_PROJECTOR_NAMES.threads, apply: applyThreadsProjection, }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.tags, + apply: applyTagsProjection, + }, ]; const runProjectorForEvent = Effect.fn("runProjectorForEvent")(function* ( @@ -1474,4 +1526,5 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(ProjectionTurnRepositoryLive), Layer.provideMerge(ProjectionPendingApprovalRepositoryLive), Layer.provideMerge(ProjectionStateRepositoryLive), + Layer.provideMerge(ProjectionTagRepositoryLive), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 9f0d63545fc6..e20d297fa253 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -264,6 +264,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runOnWorktreeCreate: false, }, ], + tags: [], createdAt: "2026-02-24T00:00:00.000Z", updatedAt: "2026-02-24T00:00:01.000Z", deletedAt: null, @@ -375,6 +376,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runOnWorktreeCreate: false, }, ], + tags: [], createdAt: "2026-02-24T00:00:00.000Z", updatedAt: "2026-02-24T00:00:01.000Z", }, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 07645571ba7d..dd231a4ef0a8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -9,6 +9,7 @@ import { OrchestrationShellSnapshot, OrchestrationThread, ProjectScript, + TagId, TurnId, type OrchestrationCheckpointSummary, type OrchestrationLatestTurn, @@ -17,6 +18,7 @@ import { type OrchestrationProposedPlan, type OrchestrationProject, type OrchestrationSession, + type OrchestrationTagCatalogEntry, type OrchestrationThreadActivity, type OrchestrationThreadShell, ModelSelection, @@ -36,6 +38,11 @@ import { import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts"; import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts"; import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; +import { + ProjectionTag, + ProjectionTagRepository, +} from "../../persistence/Services/ProjectionTags.ts"; +import { ProjectionTagRepositoryLive } from "../../persistence/Layers/ProjectionTags.ts"; import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts"; import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; @@ -57,6 +64,7 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + tags: Schema.fromJsonString(Schema.Array(TagId)), }), ); const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( @@ -127,6 +135,7 @@ const REQUIRED_SNAPSHOT_PROJECTORS = [ ORCHESTRATION_PROJECTOR_NAMES.threadActivities, ORCHESTRATION_PROJECTOR_NAMES.threadSessions, ORCHESTRATION_PROJECTOR_NAMES.checkpoints, + ORCHESTRATION_PROJECTOR_NAMES.tags, ] as const; function maxIso(left: string | null, right: string): string { @@ -213,6 +222,16 @@ function mapProjectShellRow( repositoryIdentity, defaultModelSelection: row.defaultModelSelection, scripts: row.scripts, + tags: row.tags, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function mapTagRow(row: ProjectionTag): OrchestrationTagCatalogEntry { + return { + id: row.tagId, + name: row.name, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -228,6 +247,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st const makeProjectionSnapshotQuery = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const repositoryIdentityResolver = yield* RepositoryIdentityResolver; + const projectionTagRepository = yield* ProjectionTagRepository; const repositoryIdentityResolutionConcurrency = 4; const listProjectRows = SqlSchema.findAll({ @@ -241,6 +261,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", + tags_json AS "tags", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" @@ -437,6 +458,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", + tags_json AS "tags", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" @@ -459,6 +481,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", + tags_json AS "tags", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" @@ -740,6 +763,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + projectionTagRepository.listAll(), ]), ) .pipe( @@ -754,6 +778,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { checkpointRows, latestTurnRows, stateRows, + tagRows, ]) => Effect.gen(function* () { const messagesByThread = new Map>(); @@ -904,6 +929,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, defaultModelSelection: row.defaultModelSelection, scripts: row.scripts, + tags: row.tags, createdAt: row.createdAt, updatedAt: row.updatedAt, deletedAt: row.deletedAt, @@ -934,6 +960,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snapshotSequence: computeSnapshotSequence(stateRows), projects, threads, + tags: tagRows.map(mapTagRow), updatedAt: updatedAt ?? new Date(0).toISOString(), }; @@ -996,92 +1023,95 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + projectionTagRepository.listAll(), ]), ) .pipe( - Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) => - Effect.gen(function* () { - let updatedAt: string | null = null; - for (const row of projectRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of threadRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of sessionRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of latestTurnRows) { - updatedAt = maxIso(updatedAt, row.requestedAt); - if (row.startedAt !== null) { - updatedAt = maxIso(updatedAt, row.startedAt); + Effect.flatMap( + ([projectRows, threadRows, sessionRows, latestTurnRows, stateRows, tagRows]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); } - if (row.completedAt !== null) { - updatedAt = maxIso(updatedAt, row.completedAt); + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); } - } - for (const row of stateRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - - const repositoryIdentities = new Map( - yield* Effect.forEach( - projectRows, - (row) => - repositoryIdentityResolver - .resolve(row.workspaceRoot) - .pipe(Effect.map((identity) => [row.projectId, identity] as const)), - { concurrency: repositoryIdentityResolutionConcurrency }, - ), - ); - const latestTurnByThread = new Map( - latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), - ); - const sessionByThread = new Map( - sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), - ); - - const snapshot = { - snapshotSequence: computeSnapshotSequence(stateRows), - projects: projectRows - .filter((row) => row.deletedAt === null) - .map((row) => - mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), - ), - threads: threadRows - .filter((row) => row.deletedAt === null) - .map( - (row): OrchestrationThreadShell => ({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: row.modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - archivedAt: row.archivedAt, - session: sessionByThread.get(row.threadId) ?? null, - latestUserMessageAt: row.latestUserMessageAt, - hasPendingApprovals: row.pendingApprovalCount > 0, - hasPendingUserInput: row.pendingUserInputCount > 0, - hasActionableProposedPlan: row.hasActionableProposedPlan > 0, - }), + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + + const repositoryIdentities = new Map( + yield* Effect.forEach( + projectRows, + (row) => + repositoryIdentityResolver + .resolve(row.workspaceRoot) + .pipe(Effect.map((identity) => [row.projectId, identity] as const)), + { concurrency: repositoryIdentityResolutionConcurrency }, ), - updatedAt: updatedAt ?? new Date(0).toISOString(), - }; + ); + const latestTurnByThread = new Map( + latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), + ); + const sessionByThread = new Map( + sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), + ); - return yield* decodeShellSnapshot(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: projectRows + .filter((row) => row.deletedAt === null) + .map((row) => + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ), + threads: threadRows + .filter((row) => row.deletedAt === null) + .map( + (row): OrchestrationThreadShell => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + }), + ), + tags: tagRows.map(mapTagRow), + updatedAt: updatedAt ?? new Date(0).toISOString(), + }; + + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", + ), ), - ), - ); - }), + ); + }), ), Effect.mapError((error) => { if (isPersistenceError(error)) { @@ -1128,6 +1158,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { repositoryIdentity, defaultModelSelection: option.value.defaultModelSelection, scripts: option.value.scripts, + tags: option.value.tags, createdAt: option.value.createdAt, updatedAt: option.value.updatedAt, deletedAt: option.value.deletedAt, @@ -1415,6 +1446,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ); }); + const listAllTags: ProjectionSnapshotQueryShape["listAllTags"] = () => + projectionTagRepository.listAll().pipe(Effect.map((rows) => rows.map(mapTagRow))); + + const getTagById: ProjectionSnapshotQueryShape["getTagById"] = (tagId) => + projectionTagRepository + .getById({ tagId }) + .pipe(Effect.map((option) => Option.map(option, mapTagRow))); + return { getSnapshot, getShellSnapshot, @@ -1425,10 +1464,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getThreadCheckpointContext, getThreadShellById, getThreadDetailById, + listAllTags, + getTagById, } satisfies ProjectionSnapshotQueryShape; }); export const OrchestrationProjectionSnapshotQueryLive = Layer.effect( ProjectionSnapshotQuery, makeProjectionSnapshotQuery, -); +).pipe(Layer.provideMerge(ProjectionTagRepositoryLive)); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 3c616cab6d44..c8a17ac66ef1 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -469,7 +469,9 @@ const make = Effect.gen(function* () { const { textGenerationModelSelection: modelSelection } = yield* serverSettingsService.getSettings; - const fullName = yield* git.readConfigValue(cwd, "user.name").pipe(Effect.orElseSucceed(() => null)); + const fullName = yield* git + .readConfigValue(cwd, "user.name") + .pipe(Effect.orElseSucceed(() => null)); const firstName = fullName ? fullName.trim().split(/\s+/)[0]!.toLowerCase() : undefined; const generated = yield* textGeneration.generateBranchName({ diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index f7ebf693440f..bd01d5f861d1 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -20,6 +20,9 @@ import { ThreadApprovalResponseRequestedPayload as ContractsThreadApprovalResponseRequestedPayloadSchema, ThreadCheckpointRevertRequestedPayload as ContractsThreadCheckpointRevertRequestedPayloadSchema, ThreadSessionStopRequestedPayload as ContractsThreadSessionStopRequestedPayloadSchema, + TagCreatedPayload as ContractsTagCreatedPayloadSchema, + TagRenamedPayload as ContractsTagRenamedPayloadSchema, + TagDeletedPayload as ContractsTagDeletedPayloadSchema, } from "@t3tools/contracts"; // Server-internal alias surface, backed by contract schemas as the source of truth. @@ -50,3 +53,7 @@ export const ThreadApprovalResponseRequestedPayload = export const ThreadCheckpointRevertRequestedPayload = ContractsThreadCheckpointRevertRequestedPayloadSchema; export const ThreadSessionStopRequestedPayload = ContractsThreadSessionStopRequestedPayloadSchema; + +export const TagCreatedPayload = ContractsTagCreatedPayloadSchema; +export const TagRenamedPayload = ContractsTagRenamedPayloadSchema; +export const TagDeletedPayload = ContractsTagDeletedPayloadSchema; diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index be81dcbb3745..7d205ae71e9a 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -12,9 +12,11 @@ import type { OrchestrationProjectShell, OrchestrationReadModel, OrchestrationShellSnapshot, + OrchestrationTagCatalogEntry, OrchestrationThread, OrchestrationThreadShell, ProjectId, + TagId, ThreadId, } from "@t3tools/contracts"; import { Context } from "effect"; @@ -105,6 +107,21 @@ export interface ProjectionSnapshotQueryShape { readonly getThreadDetailById: ( threadId: ThreadId, ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * List all tag catalog entries. + */ + readonly listAllTags: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + + /** + * Read a single tag catalog entry by id. + */ + readonly getTagById: ( + tagId: TagId, + ) => Effect.Effect, ProjectionRepositoryError>; } /** diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index a678bcea1666..ac88ef3685c4 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -23,6 +23,7 @@ const now = new Date().toISOString(); const readModel: OrchestrationReadModel = { snapshotSequence: 2, updatedAt: now, + tags: [], projects: [ { id: ProjectId.make("project-a"), @@ -33,6 +34,7 @@ const readModel: OrchestrationReadModel = { model: "gpt-5-codex", }, scripts: [], + tags: [], createdAt: now, updatedAt: now, deletedAt: null, @@ -46,6 +48,7 @@ const readModel: OrchestrationReadModel = { model: "gpt-5-codex", }, scripts: [], + tags: [], createdAt: now, updatedAt: now, deletedAt: null, diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index 009fdb190e54..c6dbc6a70041 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -2,8 +2,10 @@ import type { OrchestrationCommand, OrchestrationProject, OrchestrationReadModel, + OrchestrationTagCatalogEntry, OrchestrationThread, ProjectId, + TagId, ThreadId, } from "@t3tools/contracts"; import { Effect } from "effect"; @@ -31,6 +33,21 @@ export function findProjectById( return readModel.projects.find((project) => project.id === projectId); } +export function findTagById( + readModel: OrchestrationReadModel, + tagId: TagId, +): OrchestrationTagCatalogEntry | undefined { + return readModel.tags.find((tag) => tag.id === tagId); +} + +export function normalizeTagName(name: string): string { + return name.trim(); +} + +export function normalizeTagNameForDedup(name: string): string { + return normalizeTagName(name).toLocaleLowerCase(); +} + export function listThreadsByProjectId( readModel: OrchestrationReadModel, projectId: ProjectId, @@ -157,3 +174,59 @@ export function requireNonNegativeInteger(input: { ), ); } + +export function requireTag(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly tagId: TagId; +}): Effect.Effect { + const tag = findTagById(input.readModel, input.tagId); + if (tag) { + return Effect.succeed(tag); + } + return Effect.fail( + invariantError( + input.command.type, + `Tag '${input.tagId}' does not exist for command '${input.command.type}'.`, + ), + ); +} + +export function requireTagNameAvailable(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly name: string; + readonly ignoreTagId?: TagId; +}): Effect.Effect { + const dedupKey = normalizeTagNameForDedup(input.name); + for (const tag of input.readModel.tags) { + if (input.ignoreTagId !== undefined && tag.id === input.ignoreTagId) { + continue; + } + if (normalizeTagNameForDedup(tag.name) === dedupKey) { + return Effect.fail( + invariantError( + input.command.type, + `Tag name '${normalizeTagName(input.name)}' is already in use by tag '${tag.id}'.`, + ), + ); + } + } + return Effect.void; +} + +export function requireTagsExist(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly tagIds: ReadonlyArray; +}): Effect.Effect { + const knownIds = new Set(input.readModel.tags.map((tag) => tag.id)); + for (const tagId of input.tagIds) { + if (!knownIds.has(tagId)) { + return Effect.fail( + invariantError(input.command.type, `Tag '${tagId}' does not exist in the catalog.`), + ); + } + } + return Effect.void; +} diff --git a/apps/server/src/orchestration/decider.delete.test.ts b/apps/server/src/orchestration/decider.delete.test.ts index 2b323714932e..33c40a5541ce 100644 --- a/apps/server/src/orchestration/decider.delete.test.ts +++ b/apps/server/src/orchestration/decider.delete.test.ts @@ -40,6 +40,7 @@ async function seedReadModel(): Promise { workspaceRoot: "/tmp/project-delete", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 1feabb0ed852..13d7684b7ad1 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -62,6 +62,7 @@ describe("decider project scripts", () => { workspaceRoot: "/tmp/scripts", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -116,6 +117,7 @@ describe("decider project scripts", () => { workspaceRoot: "/tmp/project", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -217,6 +219,7 @@ describe("decider project scripts", () => { workspaceRoot: "/tmp/project", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -299,6 +302,7 @@ describe("decider project scripts", () => { workspaceRoot: "/tmp/project", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 9b6b1eb154d2..24f6bab91aa5 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -2,14 +2,20 @@ import type { OrchestrationCommand, OrchestrationEvent, OrchestrationReadModel, + TagId, } from "@t3tools/contracts"; +import { TAG_NAME_MAX_CHARS, TAG_NAME_PATTERN } from "@t3tools/contracts"; import { Effect } from "effect"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; import { listThreadsByProjectId, + normalizeTagName, requireProject, requireProjectAbsent, + requireTag, + requireTagNameAvailable, + requireTagsExist, requireThread, requireThreadArchived, requireThreadAbsent, @@ -58,9 +64,11 @@ type DecideOrchestrationCommandResult = const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ commands, readModel, + trailingEvent, }: { readonly commands: ReadonlyArray; readonly readModel: OrchestrationReadModel; + readonly trailingEvent?: (readModel: OrchestrationReadModel) => PlannedOrchestrationEvent; }): Effect.fn.Return, OrchestrationCommandInvariantError> { let nextReadModel = readModel; let nextSequence = readModel.snapshotSequence; @@ -82,6 +90,11 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ } } + if (trailingEvent !== undefined) { + const trailing = trailingEvent(nextReadModel); + plannedEvents.push(trailing); + } + return plannedEvents; }); @@ -114,6 +127,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" workspaceRoot: command.workspaceRoot, defaultModelSelection: command.defaultModelSelection ?? null, scripts: [], + tags: [], createdAt: command.createdAt, updatedAt: command.createdAt, }, @@ -126,6 +140,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); + if (command.tags !== undefined) { + yield* requireTagsExist({ + readModel, + command, + tagIds: command.tags, + }); + } const occurredAt = nowIso(); return { ...withEventBase({ @@ -143,6 +164,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? { defaultModelSelection: command.defaultModelSelection } : {}), ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), + ...(command.tags !== undefined ? { tags: command.tags } : {}), updatedAt: occurredAt, }, }; @@ -741,6 +763,98 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "tag.create": { + yield* validateTagName({ command, name: command.name }); + const trimmed = normalizeTagName(command.name); + yield* requireTagNameAvailable({ + readModel, + command, + name: trimmed, + }); + return { + ...withEventBase({ + aggregateKind: "tag", + aggregateId: command.tagId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "tag.created", + payload: { + tagId: command.tagId, + name: trimmed, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + } + + case "tag.rename": { + yield* requireTag({ readModel, command, tagId: command.tagId }); + yield* validateTagName({ command, name: command.name }); + const trimmed = normalizeTagName(command.name); + yield* requireTagNameAvailable({ + readModel, + command, + name: trimmed, + ignoreTagId: command.tagId, + }); + const occurredAt = nowIso(); + return { + ...withEventBase({ + aggregateKind: "tag", + aggregateId: command.tagId, + occurredAt, + commandId: command.commandId, + }), + type: "tag.renamed", + payload: { + tagId: command.tagId, + name: trimmed, + updatedAt: occurredAt, + }, + }; + } + + case "tag.delete": { + yield* requireTag({ readModel, command, tagId: command.tagId }); + const referencingProjects = readModel.projects.filter((project) => + project.tags.includes(command.tagId), + ); + const occurredAt = nowIso(); + const tagDeletedEvent: PlannedOrchestrationEvent = { + ...withEventBase({ + aggregateKind: "tag", + aggregateId: command.tagId, + occurredAt, + commandId: command.commandId, + }), + type: "tag.deleted", + payload: { + tagId: command.tagId, + deletedAt: occurredAt, + }, + }; + + if (referencingProjects.length === 0) { + return tagDeletedEvent; + } + + const cascadeCommands = referencingProjects.map( + (project): Extract => ({ + type: "project.meta.update", + commandId: command.commandId, + projectId: project.id, + tags: project.tags.filter((id: TagId) => id !== command.tagId), + }), + ); + + return yield* decideCommandSequence({ + readModel, + commands: cascadeCommands, + trailingEvent: () => tagDeletedEvent, + }); + } + default: { command satisfies never; const fallback = command as never as { type: string }; @@ -751,3 +865,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } } }); + +function validateTagName(input: { + readonly command: OrchestrationCommand; + readonly name: string; +}): Effect.Effect { + const trimmed = normalizeTagName(input.name); + if (trimmed.length === 0) { + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: input.command.type, + detail: "Tag name must be non-empty after trimming.", + }), + ); + } + if (trimmed.length > TAG_NAME_MAX_CHARS) { + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: input.command.type, + detail: `Tag name must be at most ${TAG_NAME_MAX_CHARS} characters.`, + }), + ); + } + if (!TAG_NAME_PATTERN.test(trimmed)) { + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: input.command.type, + detail: "Tag name may only contain letters, digits, spaces, '-', and '_'.", + }), + ); + } + return Effect.void; +} diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index deb8a6d44d7b..6fb83e27f458 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -13,6 +13,9 @@ import { ProjectCreatedPayload, ProjectDeletedPayload, ProjectMetaUpdatedPayload, + TagCreatedPayload, + TagDeletedPayload, + TagRenamedPayload, ThreadActivityAppendedPayload, ThreadArchivedPayload, ThreadCreatedPayload, @@ -160,6 +163,7 @@ export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { snapshotSequence: 0, projects: [], threads: [], + tags: [], updatedAt: nowIso, }; } @@ -185,6 +189,7 @@ export function projectEvent( workspaceRoot: payload.workspaceRoot, defaultModelSelection: payload.defaultModelSelection, scripts: payload.scripts, + tags: payload.tags, createdAt: payload.createdAt, updatedAt: payload.updatedAt, deletedAt: null, @@ -217,6 +222,7 @@ export function projectEvent( ? { defaultModelSelection: payload.defaultModelSelection } : {}), ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), + ...(payload.tags !== undefined ? { tags: payload.tags } : {}), updatedAt: payload.updatedAt, } : project, @@ -648,6 +654,59 @@ export function projectEvent( }), ); + case "tag.created": + return decodeForEvent(TagCreatedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => { + const existing = nextBase.tags.find((tag) => tag.id === payload.tagId); + const nextTag = { + id: payload.tagId, + name: payload.name, + createdAt: payload.createdAt, + updatedAt: payload.updatedAt, + }; + return { + ...nextBase, + tags: existing + ? nextBase.tags.map((tag) => (tag.id === payload.tagId ? nextTag : tag)) + : [...nextBase.tags, nextTag], + }; + }), + ); + + case "tag.renamed": + return decodeForEvent(TagRenamedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => { + const existing = nextBase.tags.find((tag) => tag.id === payload.tagId); + if (!existing) { + return nextBase; + } + return { + ...nextBase, + tags: nextBase.tags.map((tag) => + tag.id === payload.tagId + ? { + ...tag, + name: payload.name, + updatedAt: payload.updatedAt, + } + : tag, + ), + }; + }), + ); + + case "tag.deleted": + return decodeForEvent(TagDeletedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + tags: nextBase.tags.filter((tag) => tag.id !== payload.tagId), + projects: nextBase.projects.map((project) => ({ + ...project, + tags: project.tags.filter((tagId) => tagId !== payload.tagId), + })), + })), + ); + default: return Effect.succeed(nextBase); } diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts index 80526986a70c..9b9280b26d1b 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts @@ -37,6 +37,7 @@ layer("OrchestrationEventStore", (it) => { workspaceRoot: "/tmp/project-roundtrip", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index 4d81cf5e8d73..9c7fabcb089e 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -9,6 +9,7 @@ import { OrchestrationEventMetadata, OrchestrationEventType, ProjectId, + TagId, ThreadId, } from "@t3tools/contracts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -32,7 +33,7 @@ const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMeta const AppendEventRequestSchema = Schema.Struct({ eventId: EventId, aggregateKind: OrchestrationAggregateKind, - streamId: Schema.Union([ProjectId, ThreadId]), + streamId: Schema.Union([ProjectId, ThreadId, TagId]), type: OrchestrationEventType, causationEventId: Schema.NullOr(EventId), correlationId: Schema.NullOr(CommandId), @@ -48,7 +49,7 @@ const OrchestrationEventPersistedRowSchema = Schema.Struct({ eventId: EventId, type: OrchestrationEventType, aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), + aggregateId: Schema.Union([ProjectId, ThreadId, TagId]), occurredAt: IsoDateTime, commandId: Schema.NullOr(CommandId), causationEventId: Schema.NullOr(EventId), diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 7ff19f55aeac..0da85db7ec42 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -2,7 +2,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { Effect, Layer, Schema, Struct } from "effect"; -import { ModelSelection, ProjectScript } from "@t3tools/contracts"; +import { ModelSelection, ProjectScript, TagId } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionProjectInput, @@ -16,6 +16,7 @@ const ProjectionProjectDbRow = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + tags: Schema.fromJsonString(Schema.Array(TagId)), }), ); type ProjectionProjectDbRow = typeof ProjectionProjectDbRow.Type; @@ -33,6 +34,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root, default_model_selection_json, scripts_json, + tags_json, created_at, updated_at, deleted_at @@ -43,6 +45,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.workspaceRoot}, ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, ${JSON.stringify(row.scripts)}, + ${JSON.stringify(row.tags)}, ${row.createdAt}, ${row.updatedAt}, ${row.deletedAt} @@ -53,6 +56,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root = excluded.workspace_root, default_model_selection_json = excluded.default_model_selection_json, scripts_json = excluded.scripts_json, + tags_json = excluded.tags_json, created_at = excluded.created_at, updated_at = excluded.updated_at, deleted_at = excluded.deleted_at @@ -70,6 +74,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", + tags_json AS "tags", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" @@ -89,6 +94,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", + tags_json AS "tags", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index d42be6994587..b3b1df77f4a7 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -32,6 +32,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { model: "gpt-5.4", }, scripts: [], + tags: [], createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", deletedAt: null, diff --git a/apps/server/src/persistence/Layers/ProjectionTags.ts b/apps/server/src/persistence/Layers/ProjectionTags.ts new file mode 100644 index 000000000000..274ab298fb99 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionTags.ts @@ -0,0 +1,139 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Schema } from "effect"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + DeleteProjectionTagInput, + GetProjectionTagByNormalizedNameInput, + GetProjectionTagInput, + ProjectionTag, + ProjectionTagRepository, + type ProjectionTagRepositoryShape, +} from "../Services/ProjectionTags.ts"; + +const makeProjectionTagRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionTagRow = SqlSchema.void({ + Request: ProjectionTag, + execute: (row) => + sql` + INSERT INTO projection_tags ( + tag_id, + name, + name_normalized, + created_at, + updated_at + ) + VALUES ( + ${row.tagId}, + ${row.name}, + ${row.nameNormalized}, + ${row.createdAt}, + ${row.updatedAt} + ) + ON CONFLICT (tag_id) + DO UPDATE SET + name = excluded.name, + name_normalized = excluded.name_normalized, + updated_at = excluded.updated_at + `, + }); + + const getProjectionTagRow = SqlSchema.findOneOption({ + Request: GetProjectionTagInput, + Result: ProjectionTag, + execute: ({ tagId }) => + sql` + SELECT + tag_id AS "tagId", + name, + name_normalized AS "nameNormalized", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_tags + WHERE tag_id = ${tagId} + `, + }); + + const getProjectionTagByNormalizedNameRow = SqlSchema.findOneOption({ + Request: GetProjectionTagByNormalizedNameInput, + Result: ProjectionTag, + execute: ({ nameNormalized }) => + sql` + SELECT + tag_id AS "tagId", + name, + name_normalized AS "nameNormalized", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_tags + WHERE name_normalized = ${nameNormalized} + LIMIT 1 + `, + }); + + const listProjectionTagRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionTag, + execute: () => + sql` + SELECT + tag_id AS "tagId", + name, + name_normalized AS "nameNormalized", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_tags + ORDER BY created_at ASC, tag_id ASC + `, + }); + + const deleteProjectionTagRow = SqlSchema.void({ + Request: DeleteProjectionTagInput, + execute: ({ tagId }) => + sql` + DELETE FROM projection_tags + WHERE tag_id = ${tagId} + `, + }); + + const upsert: ProjectionTagRepositoryShape["upsert"] = (row) => + upsertProjectionTagRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionTagRepository.upsert:query")), + ); + + const getById: ProjectionTagRepositoryShape["getById"] = (input) => + getProjectionTagRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionTagRepository.getById:query")), + ); + + const getByNormalizedName: ProjectionTagRepositoryShape["getByNormalizedName"] = (input) => + getProjectionTagByNormalizedNameRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionTagRepository.getByNormalizedName:query")), + ); + + const listAll: ProjectionTagRepositoryShape["listAll"] = () => + listProjectionTagRows().pipe( + Effect.mapError(toPersistenceSqlError("ProjectionTagRepository.listAll:query")), + ); + + const deleteById: ProjectionTagRepositoryShape["deleteById"] = (input) => + deleteProjectionTagRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionTagRepository.deleteById:query")), + ); + + return { + upsert, + getById, + getByNormalizedName, + listAll, + deleteById, + } satisfies ProjectionTagRepositoryShape; +}); + +export const ProjectionTagRepositoryLive = Layer.effect( + ProjectionTagRepository, + makeProjectionTagRepository, +); diff --git a/apps/server/src/persistence/MigrationsRunner.test.ts b/apps/server/src/persistence/MigrationsRunner.test.ts index 0fa51641ef6f..162a631d9ab4 100644 --- a/apps/server/src/persistence/MigrationsRunner.test.ts +++ b/apps/server/src/persistence/MigrationsRunner.test.ts @@ -25,7 +25,8 @@ const resetMigrationsState = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const tables = yield* sql<{ readonly name: string; - }>`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`.withoutTransform; + }>`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'` + .withoutTransform; for (const { name } of tables) { yield* sql`DROP TABLE IF EXISTS ${sql(name)}`; } diff --git a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts index da8755ae71cf..43415c7b8683 100644 --- a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts +++ b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts @@ -13,6 +13,7 @@ import { OrchestrationAggregateKind, OrchestrationCommandReceiptStatus, ProjectId, + TagId, ThreadId, } from "@t3tools/contracts"; import { Option, Schema, Context } from "effect"; @@ -23,7 +24,7 @@ import type { OrchestrationCommandReceiptRepositoryError } from "../Errors.ts"; export const OrchestrationCommandReceipt = Schema.Struct({ commandId: CommandId, aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), + aggregateId: Schema.Union([ProjectId, ThreadId, TagId]), acceptedAt: IsoDateTime, resultSequence: NonNegativeInt, status: OrchestrationCommandReceiptStatus, diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 0970bb2ead4c..1b8bc2f06f6a 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -6,7 +6,7 @@ * * @module ProjectionProjectRepository */ -import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts"; +import { IsoDateTime, ModelSelection, ProjectId, ProjectScript, TagId } from "@t3tools/contracts"; import { Option, Schema, Context } from "effect"; import type { Effect } from "effect"; @@ -18,6 +18,7 @@ export const ProjectionProject = Schema.Struct({ workspaceRoot: Schema.String, defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), + tags: Schema.Array(TagId), createdAt: IsoDateTime, updatedAt: IsoDateTime, deletedAt: Schema.NullOr(IsoDateTime), diff --git a/apps/server/src/persistence/Services/ProjectionTags.ts b/apps/server/src/persistence/Services/ProjectionTags.ts new file mode 100644 index 000000000000..ca2ee6c21ff7 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionTags.ts @@ -0,0 +1,82 @@ +/** + * ProjectionTagRepository - Projection repository interface for tags. + * + * Owns persistence operations for tag rows in the orchestration projection + * read model. + * + * @module ProjectionTagRepository + */ +import { IsoDateTime, TagId } from "@t3tools/contracts"; +import { Option, Schema, Context } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionTag = Schema.Struct({ + tagId: TagId, + name: Schema.String, + nameNormalized: Schema.String, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type ProjectionTag = typeof ProjectionTag.Type; + +export const GetProjectionTagInput = Schema.Struct({ + tagId: TagId, +}); +export type GetProjectionTagInput = typeof GetProjectionTagInput.Type; + +export const GetProjectionTagByNormalizedNameInput = Schema.Struct({ + nameNormalized: Schema.String, +}); +export type GetProjectionTagByNormalizedNameInput = + typeof GetProjectionTagByNormalizedNameInput.Type; + +export const DeleteProjectionTagInput = Schema.Struct({ + tagId: TagId, +}); +export type DeleteProjectionTagInput = typeof DeleteProjectionTagInput.Type; + +/** + * ProjectionTagRepositoryShape - Service API for projected tag records. + */ +export interface ProjectionTagRepositoryShape { + /** + * Insert or replace a projected tag row. + */ + readonly upsert: (row: ProjectionTag) => Effect.Effect; + + /** + * Read a projected tag row by id. + */ + readonly getById: ( + input: GetProjectionTagInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read a projected tag row by its normalized (case-folded) name. + */ + readonly getByNormalizedName: ( + input: GetProjectionTagByNormalizedNameInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * List all projected tag rows in deterministic creation order. + */ + readonly listAll: () => Effect.Effect, ProjectionRepositoryError>; + + /** + * Delete a projected tag row by id. + */ + readonly deleteById: ( + input: DeleteProjectionTagInput, + ) => Effect.Effect; +} + +/** + * ProjectionTagRepository - Service tag for tag projection persistence. + */ +export class ProjectionTagRepository extends Context.Service< + ProjectionTagRepository, + ProjectionTagRepositoryShape +>()("t3/persistence/Services/ProjectionTags/ProjectionTagRepository") {} diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 45199a02b2af..35f4719896c5 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -62,6 +62,7 @@ function makeReadModel( return { snapshotSequence: 0, updatedAt: now, + tags: [], projects: [ { id: projectId, @@ -69,6 +70,7 @@ function makeReadModel( workspaceRoot: "/tmp/provider-reaper-project", defaultModelSelection, scripts: [], + tags: [], createdAt: now, updatedAt: now, deletedAt: null, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 47e159d3036e..854d39e46f25 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -130,6 +130,7 @@ const makeDefaultOrchestrationReadModel = () => { return { snapshotSequence: 0, updatedAt: now, + tags: [], projects: [ { id: defaultProjectId, @@ -137,6 +138,7 @@ const makeDefaultOrchestrationReadModel = () => { workspaceRoot: "/tmp/default-project", defaultModelSelection, scripts: [], + tags: [], createdAt: now, updatedAt: now, deletedAt: null, @@ -470,6 +472,7 @@ const buildAppUnderTest = (options?: { snapshotSequence: 0, projects: [], threads: [], + tags: [], updatedAt: new Date(0).toISOString(), }), getProjectShellById: () => Effect.succeed(Option.none()), @@ -479,6 +482,8 @@ const buildAppUnderTest = (options?: { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), + listAllTags: () => Effect.succeed([]), + getTagById: () => Effect.succeed(Option.none()), ...options?.layers?.projectionSnapshotQuery, }), ), @@ -2874,6 +2879,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const snapshot = { snapshotSequence: 1, updatedAt: now, + tags: [], projects: [ { id: ProjectId.make("project-a"), @@ -2881,6 +2887,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { workspaceRoot: "/tmp/project-a", defaultModelSelection, scripts: [], + tags: [], createdAt: now, updatedAt: now, deletedAt: null, @@ -3019,6 +3026,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { workspaceRoot: "/tmp/default-project", defaultModelSelection, scripts: [], + tags: [], createdAt: "2026-04-05T00:00:00.000Z", updatedAt: "2026-04-05T00:00:00.000Z", }, diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 836b71c7eb4f..92fa362c9695 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -92,6 +92,8 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getThreadCheckpointContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + listAllTags: () => Effect.succeed([]), + getTagById: () => Effect.succeed(Option.none()), }), Effect.provideService(AnalyticsService, { record: () => Effect.void, @@ -140,6 +142,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa workspaceRoot: "/tmp/startup-project", defaultModelSelection: getAutoBootstrapDefaultModelSelection(), scripts: [], + tags: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", deletedAt: null, @@ -150,6 +153,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getThreadCheckpointContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), + listAllTags: () => Effect.succeed([]), + getTagById: () => Effect.succeed(Option.none()), }), Effect.provideService(OrchestrationEngineService, { getReadModel: () => Effect.die("unused"), @@ -189,6 +194,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getThreadCheckpointContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), + listAllTags: () => Effect.succeed([]), + getTagById: () => Effect.succeed(Option.none()), }), Effect.provideService(OrchestrationEngineService, { getReadModel: () => Effect.die("unused"), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 73f7597cb795..b7e7973b401f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -280,6 +280,26 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => threadId: event.payload.threadId, }), ); + case "tag.created": + case "tag.renamed": + return projectionSnapshotQuery.getTagById(event.payload.tagId).pipe( + Effect.map((tag) => + Option.map(tag, (nextTag) => ({ + kind: "tag-upserted" as const, + sequence: event.sequence, + tag: nextTag, + })), + ), + Effect.catch(() => Effect.succeed(Option.none())), + ); + case "tag.deleted": + return Effect.succeed( + Option.some({ + kind: "tag-removed" as const, + sequence: event.sequence, + tagId: event.payload.tagId, + }), + ); default: if (event.aggregateKind !== "thread") { return Effect.succeed(Option.none()); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index f5cc79b88a8c..12993b1e77bf 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -327,11 +327,13 @@ function createSnapshotForTargetUser(options: { model: "gpt-5", }, scripts: [], + tags: [], createdAt: NOW_ISO, updatedAt: NOW_ISO, deletedAt: null, }, ], + tags: [], threads: [ { id: THREAD_ID, @@ -809,6 +811,7 @@ function createSnapshotWithSecondaryProject(options?: { workspaceRoot: "/repo/clients/docs-portal", defaultModelSelection: { provider: "codex", model: "gpt-5" }, scripts: [], + tags: [], createdAt: NOW_ISO, updatedAt: NOW_ISO, deletedAt: null, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index ba0a0eddf30a..1f1805a094f1 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -259,8 +259,11 @@ function setStoreThreads(threads: ReadonlyArray>) createdAt: "2026-03-29T00:00:00.000Z", updatedAt: "2026-03-29T00:00:00.000Z", scripts: [], + tags: [], }, }, + tagIds: [], + tagById: {}, threadIds: threads.map((thread) => thread.id), threadIdsByProjectId: { [projectId]: threads.map((thread) => thread.id), diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 7eadd4e41be6..8bc38c6ea793 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -125,11 +125,13 @@ function createMinimalSnapshot(): OrchestrationReadModel { model: "gpt-5", }, scripts: [], + tags: [], createdAt: NOW_ISO, updatedAt: NOW_ISO, deletedAt: null, }, ], + tags: [], threads: [ { id: THREAD_ID, diff --git a/apps/web/src/components/ProjectTagsEditor.tsx b/apps/web/src/components/ProjectTagsEditor.tsx new file mode 100644 index 000000000000..06f82e1f54c3 --- /dev/null +++ b/apps/web/src/components/ProjectTagsEditor.tsx @@ -0,0 +1,105 @@ +import { useMemo } from "react"; +import { PlusIcon } from "lucide-react"; +import { type TagId } from "@t3tools/contracts"; +import type { Tag } from "../types"; +import type { SidebarProjectGroupMember } from "../sidebarProjectGrouping"; +import { Menu, MenuCheckboxItem, MenuGroup, MenuItem, MenuPopup, MenuSeparator } from "./ui/menu"; + +export interface ProjectTagsEditorProps { + projectMember: SidebarProjectGroupMember; + tags: readonly Tag[]; + anchor: { x: number; y: number }; + onClose: () => void; + onToggleAssignment: (tagId: TagId, nextChecked: boolean) => void; + onCreateTag: () => void; +} + +export function ProjectTagsEditor({ + projectMember, + tags, + anchor, + onClose, + onToggleAssignment, + onCreateTag, +}: ProjectTagsEditorProps) { + const assignedTagIds = useMemo(() => new Set(projectMember.tags), [projectMember.tags]); + // The portaled positioner reads --anchor-{x,y} via the `anchor` prop. We use a + // `getBoundingClientRect` virtual element that returns a zero-size rect at the + // click coordinates, which makes Base UI place the popup at that point. + const virtualAnchor = useMemo( + () => ({ + getBoundingClientRect: (): DOMRect => ({ + x: anchor.x, + y: anchor.y, + top: anchor.y, + left: anchor.x, + right: anchor.x, + bottom: anchor.y, + width: 0, + height: 0, + toJSON: () => ({}), + }), + }), + [anchor.x, anchor.y], + ); + + return ( + { + if (!open) { + onClose(); + } + }} + > + + { + event.preventDefault(); + onCreateTag(); + }} + > + + New tag… + + {tags.length > 0 ? ( + <> + + + {tags.map((tag) => { + const isAssigned = assignedTagIds.has(tag.id); + return ( + { + onToggleAssignment(tag.id, nextChecked); + }} + > + {tag.name} + + ); + })} + + + ) : ( + <> + +
+ No tags yet — create one above. +
+ + )} +
+
+ ); +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index f92f2f628c36..99d1f07145ae 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createThreadJumpHintVisibilityController, + filterProjectSnapshotsByTags, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, @@ -19,8 +20,17 @@ import { shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, + toggleProjectTagAssignment, + toggleTagFilterSelection, } from "./Sidebar.logic"; -import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + OrchestrationLatestTurn, + ProjectId, + TagId, + ThreadId, +} from "@t3tools/contracts"; +import type { SidebarProjectSnapshot } from "../sidebarProjectGrouping"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -705,6 +715,7 @@ function makeProject(overrides: Partial = {}): Project { createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:00:00.000Z", scripts: [], + tags: [], ...rest, }; } @@ -962,3 +973,104 @@ describe("sortProjectsForSidebar", () => { expect(timestamp).toBe(Date.parse("2026-03-09T10:10:00.000Z")); }); }); + +describe("toggleTagFilterSelection", () => { + const tagA = TagId.make("tag-a"); + const tagB = TagId.make("tag-b"); + + it("adds an unselected tag to the end of the selection", () => { + expect(toggleTagFilterSelection([tagA], tagB)).toEqual([tagA, tagB]); + }); + + it("removes an already-selected tag without disturbing the order of the rest", () => { + expect(toggleTagFilterSelection([tagA, tagB], tagA)).toEqual([tagB]); + }); + + it("returns an empty array when toggling the only selected tag", () => { + expect(toggleTagFilterSelection([tagA], tagA)).toEqual([]); + }); +}); + +describe("filterProjectSnapshotsByTags", () => { + const tagA = TagId.make("tag-a"); + const tagB = TagId.make("tag-b"); + const tagC = TagId.make("tag-c"); + + function makeSnapshot({ + projectKey, + displayTagIds, + }: { + projectKey: string; + displayTagIds: readonly TagId[]; + }): SidebarProjectSnapshot { + // Cast through unknown — the helper only inspects `displayTagIds` so we + // intentionally narrow the surface used by the test. + return { + projectKey, + displayTagIds, + } as unknown as SidebarProjectSnapshot; + } + + const snapshots = [ + makeSnapshot({ projectKey: "p-1", displayTagIds: [tagA, tagB] }), + makeSnapshot({ projectKey: "p-2", displayTagIds: [tagA] }), + makeSnapshot({ projectKey: "p-3", displayTagIds: [tagC] }), + makeSnapshot({ projectKey: "p-4", displayTagIds: [] }), + ]; + + it("returns all snapshots when no tag is selected", () => { + expect(filterProjectSnapshotsByTags(snapshots, []).map((s) => s.projectKey)).toEqual([ + "p-1", + "p-2", + "p-3", + "p-4", + ]); + }); + + it("filters to snapshots that include the selected tag (single)", () => { + expect(filterProjectSnapshotsByTags(snapshots, [tagA]).map((s) => s.projectKey)).toEqual([ + "p-1", + "p-2", + ]); + }); + + it("filters using AND semantics when multiple tags are selected", () => { + expect(filterProjectSnapshotsByTags(snapshots, [tagA, tagB]).map((s) => s.projectKey)).toEqual([ + "p-1", + ]); + }); + + it("excludes snapshots with no tags when any tag is selected", () => { + expect(filterProjectSnapshotsByTags(snapshots, [tagC]).map((s) => s.projectKey)).toEqual([ + "p-3", + ]); + }); +}); + +describe("toggleProjectTagAssignment", () => { + const tagA = TagId.make("tag-a"); + const tagB = TagId.make("tag-b"); + const tagC = TagId.make("tag-c"); + + it("appends a not-yet-assigned tag to the end of the array", () => { + expect(toggleProjectTagAssignment([tagA], tagB)).toEqual([tagA, tagB]); + }); + + it("removes an already-assigned tag and preserves the order of the remaining tags", () => { + expect(toggleProjectTagAssignment([tagA, tagB, tagC], tagB)).toEqual([tagA, tagC]); + }); + + it("returns an empty array when toggling the only assigned tag", () => { + expect(toggleProjectTagAssignment([tagA], tagA)).toEqual([]); + }); + + it("dedupes existing duplicates while toggling", () => { + // Adding a new tag in the presence of duplicates should produce a clean, + // dedup-then-append result. + expect(toggleProjectTagAssignment([tagA, tagA, tagB], tagC)).toEqual([tagA, tagB, tagC]); + }); + + it("dedupes existing duplicates when removing a tag that appears multiple times", () => { + expect(toggleProjectTagAssignment([tagA, tagA, tagB], tagA)).toEqual([tagB]); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 39b759ac3132..80ae9dc98372 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import type { TagId } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { getThreadSortTimestamp, @@ -6,6 +7,7 @@ import { toSortableTimestamp, type ThreadSortInput, } from "../lib/threadSort"; +import type { SidebarProjectSnapshot } from "../sidebarProjectGrouping"; import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; @@ -539,3 +541,40 @@ export function sortProjectsForSidebar< return left.name.localeCompare(right.name) || left.id.localeCompare(right.id); }); } + +export function filterProjectSnapshotsByTags( + snapshots: readonly SidebarProjectSnapshot[], + selectedTagIds: readonly TagId[], +): SidebarProjectSnapshot[] { + if (selectedTagIds.length === 0) { + return [...snapshots]; + } + return snapshots.filter((snapshot) => { + const memberTagIds = new Set(snapshot.displayTagIds); + return selectedTagIds.every((tagId) => memberTagIds.has(tagId)); + }); +} + +export function toggleTagFilterSelection(selected: readonly TagId[], tagId: TagId): TagId[] { + return selected.includes(tagId) ? selected.filter((id) => id !== tagId) : [...selected, tagId]; +} + +/** + * Returns the new tag-id array for a `project.meta.update` payload after toggling + * `tagId` on the given project. Removing an already-assigned tag preserves the + * relative order of the remaining tags. Adding a new tag appends it to the end. + * The result is always deduplicated. + */ +export function toggleProjectTagAssignment(currentTagIds: readonly TagId[], tagId: TagId): TagId[] { + const seen = new Set(); + const deduped: TagId[] = []; + for (const existing of currentTagIds) { + if (seen.has(existing)) continue; + seen.add(existing); + deduped.push(existing); + } + if (seen.has(tagId)) { + return deduped.filter((id) => id !== tagId); + } + return [...deduped, tagId]; +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3dd0e56a0c5a..a005a0202c65 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -42,6 +42,7 @@ import { ProjectId, type ScopedThreadRef, type SidebarProjectGroupingMode, + TagId, type ThreadEnvMode, ThreadId, } from "@t3tools/contracts"; @@ -67,6 +68,7 @@ import { selectProjectsAcrossEnvironments, selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, + selectTagsAcrossEnvironments, selectThreadByRef, useStore, } from "../store"; @@ -150,6 +152,7 @@ import { getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, + filterProjectSnapshotsByTags, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, @@ -158,11 +161,16 @@ import { orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, + toggleProjectTagAssignment, + toggleTagFilterSelection, useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; import { sortThreads } from "../lib/threadSort"; -import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; +import { ProjectTagsEditor } from "./ProjectTagsEditor"; +import { SidebarSectionHeader } from "./SidebarSectionHeader"; +import { SidebarTagFilter } from "./SidebarTagFilter"; +import { SidebarUpdatePill } from "./SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; @@ -177,7 +185,7 @@ import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; -import type { SidebarThreadSummary } from "../types"; +import type { SidebarThreadSummary, Tag } from "../types"; import { buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectSnapshots, @@ -892,6 +900,10 @@ interface SidebarProjectItemProps { suppressProjectClickForContextMenuRef: React.RefObject; isManualProjectSorting: boolean; dragHandleProps: SortableProjectHandleProps | null; + onOpenEditTagsForMember: ( + member: SidebarProjectGroupMember, + position: { x: number; y: number }, + ) => void; } const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { @@ -912,6 +924,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec suppressProjectClickForContextMenuRef, isManualProjectSorting, dragHandleProps, + onOpenEditTagsForMember, } = props; const threadSortOrder = useSettings( (settings) => settings.sidebarThreadSortOrder, @@ -1480,19 +1493,48 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }; }; + const clickPosition = { + x: event.clientX, + y: event.clientY, + }; + + // Build an "Edit tags…" entry alongside the existing actions. When the + // project group has multiple physical members, expose one submenu leaf + // per member so the user can pick which physical project to retag. + const editTagsActionId = "edit-tags"; + const editTagsLeaf = (member: SidebarProjectGroupMember): ContextMenuItem => { + const id = `${editTagsActionId}:${member.physicalProjectKey}`; + actionHandlers.set(id, () => { + onOpenEditTagsForMember(member, clickPosition); + }); + return { + id, + label: formatProjectMemberActionLabel(member, project.groupedProjectCount), + }; + }; + const editTagsItem: ContextMenuItem = + project.memberProjects.length === 1 + ? { + ...editTagsLeaf(project.memberProjects[0]!), + label: "Edit tags…", + } + : { + id: `${editTagsActionId}:submenu`, + label: "Edit tags…", + children: project.memberProjects.map(editTagsLeaf), + }; + const clicked = await api.contextMenu.show( [ buildTargetedItem("rename", "Rename project"), + editTagsItem, buildTargetedItem("grouping", "Project grouping…"), buildTargetedItem("copy-path", "Copy Project Path"), buildTargetedItem("delete", "Remove project", { destructive: true, }), ], - { - x: event.clientX, - y: event.clientY, - }, + clickPosition, ); if (!clicked) { @@ -1505,6 +1547,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [ copyPathToClipboard, handleRemoveProject, + onOpenEditTagsForMember, openProjectGroupingDialog, openProjectRenameDialog, project.groupedProjectCount, @@ -2437,6 +2480,17 @@ interface SidebarProjectsContentProps { projectGroupingMode: SidebarProjectGroupingMode; updateSettings: ReturnType["updateSettings"]; openAddProject: () => void; + tagsForSidebar: readonly Tag[]; + selectedTagIds: readonly TagId[]; + onTagFilterToggle: (tagId: TagId) => void; + onClearTagFilter: () => void; + onOpenTagCreateDialog: () => void; + onRenameTag: (tag: Tag) => void; + onDeleteTag: (tag: Tag) => void; + onOpenEditTagsForMember: ( + member: SidebarProjectGroupMember, + position: { x: number; y: number }, + ) => void; isManualProjectSorting: boolean; projectDnDSensors: ReturnType; projectCollisionDetection: CollisionDetection; @@ -2477,6 +2531,14 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( projectGroupingMode, updateSettings, openAddProject, + tagsForSidebar, + selectedTagIds, + onTagFilterToggle, + onClearTagFilter, + onOpenTagCreateDialog, + onRenameTag, + onDeleteTag, + onOpenEditTagsForMember, isManualProjectSorting, projectDnDSensors, projectCollisionDetection, @@ -2571,37 +2633,43 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( ) : null} -
- - Projects - -
- - - - } - > - - - Add project - -
-
+ +
+ + + + + + } + > + + + Add project + + {isManualProjectSorting ? ( )} @@ -2671,6 +2740,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef} isManualProjectSorting={isManualProjectSorting} dragHandleProps={null} + onOpenEditTagsForMember={onOpenEditTagsForMember} /> ))} @@ -2688,10 +2758,232 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( export default function Sidebar() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const tagsForSidebar = useStore(useShallow(selectTagsAcrossEnvironments)); const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); + const selectedTagIds = useUiStateStore((store) => store.projectTagFilter.selectedTagIds); + const setProjectTagFilterSelection = useUiStateStore( + (store) => store.setProjectTagFilterSelection, + ); + const clearProjectTagFilterTagId = useUiStateStore((store) => store.clearProjectTagFilterTagId); + const [tagCreateDialogOpen, setTagCreateDialogOpen] = useState(false); + const [tagCreateName, setTagCreateName] = useState(""); + const [tagRenameTarget, setTagRenameTarget] = useState(null); + const [tagRenameName, setTagRenameName] = useState(""); + const [editTagsAnchor, setEditTagsAnchor] = useState<{ + member: SidebarProjectGroupMember; + x: number; + y: number; + } | null>(null); + const handleTagFilterToggle = useCallback( + (tagId: TagId) => { + setProjectTagFilterSelection(toggleTagFilterSelection(selectedTagIds, tagId)); + }, + [selectedTagIds, setProjectTagFilterSelection], + ); + const handleClearTagFilter = useCallback(() => { + setProjectTagFilterSelection([]); + }, [setProjectTagFilterSelection]); + const handleRenameTag = useCallback((tag: Tag) => { + setTagRenameTarget(tag); + setTagRenameName(tag.name); + }, []); + const handleOpenEditTagsForMember = useCallback( + (member: SidebarProjectGroupMember, position: { x: number; y: number }) => { + setEditTagsAnchor({ member, x: position.x, y: position.y }); + }, + [], + ); + const handleCloseEditTags = useCallback(() => { + setEditTagsAnchor(null); + }, []); + const openTagCreateDialog = useCallback(() => { + setTagCreateName(""); + setTagCreateDialogOpen(true); + }, []); + const closeTagCreateDialog = useCallback(() => { + setTagCreateDialogOpen(false); + setTagCreateName(""); + }, []); + const closeTagRenameDialog = useCallback(() => { + setTagRenameTarget(null); + setTagRenameName(""); + }, []); + const submitTagCreate = useCallback(async () => { + const trimmed = tagCreateName.trim(); + if (trimmed.length === 0) { + toastManager.add({ type: "warning", title: "Tag name cannot be empty" }); + return; + } + const environmentIds = Array.from(new Set(projects.map((project) => project.environmentId))); + if (environmentIds.length === 0) { + toastManager.add({ + type: "warning", + title: "No environments available", + description: "Connect to at least one environment before creating tags.", + }); + return; + } + const sharedTagId = TagId.make(crypto.randomUUID()); + const createdAt = new Date().toISOString(); + const failures: string[] = []; + for (const environmentId of environmentIds) { + const api = readEnvironmentApi(environmentId); + if (!api) { + failures.push(environmentId); + continue; + } + try { + await api.orchestration.dispatchCommand({ + type: "tag.create", + commandId: newCommandId(), + tagId: sharedTagId, + name: trimmed, + createdAt, + }); + } catch (error) { + failures.push( + `${environmentId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + if (failures.length === environmentIds.length) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to create tag", + description: failures.join("\n"), + }), + ); + return; + } + closeTagCreateDialog(); + }, [closeTagCreateDialog, projects, tagCreateName]); + const submitTagRename = useCallback(async () => { + if (!tagRenameTarget) { + return; + } + const trimmed = tagRenameName.trim(); + if (trimmed.length === 0) { + toastManager.add({ type: "warning", title: "Tag name cannot be empty" }); + return; + } + if (trimmed === tagRenameTarget.name) { + closeTagRenameDialog(); + return; + } + const environmentIds = Array.from(new Set(projects.map((project) => project.environmentId))); + const failures: string[] = []; + for (const environmentId of environmentIds) { + const api = readEnvironmentApi(environmentId); + if (!api) { + failures.push(environmentId); + continue; + } + try { + await api.orchestration.dispatchCommand({ + type: "tag.rename", + commandId: newCommandId(), + tagId: tagRenameTarget.id, + name: trimmed, + }); + } catch (error) { + failures.push( + `${environmentId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + if (failures.length === environmentIds.length) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to rename tag", + description: failures.join("\n"), + }), + ); + return; + } + closeTagRenameDialog(); + }, [closeTagRenameDialog, projects, tagRenameName, tagRenameTarget]); + const handleDeleteTag = useCallback( + async (tag: Tag) => { + // Optimistically clear the tag from the local filter selection so that the + // sidebar trigger label updates immediately. The server-side + // `tag-removed` shell-stream handler also clears it; this is a + // belt-and-braces fast path so the UI doesn't briefly show a count + // referencing a deleted tag. + clearProjectTagFilterTagId(tag.id); + const environmentIds = Array.from(new Set(projects.map((project) => project.environmentId))); + const failures: string[] = []; + for (const environmentId of environmentIds) { + const api = readEnvironmentApi(environmentId); + if (!api) { + failures.push(environmentId); + continue; + } + try { + await api.orchestration.dispatchCommand({ + type: "tag.delete", + commandId: newCommandId(), + tagId: tag.id, + }); + } catch (error) { + failures.push( + `${environmentId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + if (failures.length === environmentIds.length && environmentIds.length > 0) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to delete "${tag.name}"`, + description: failures.join("\n"), + }), + ); + } + }, + [clearProjectTagFilterTagId, projects], + ); + const handleToggleProjectTagAssignment = useCallback( + async (tagId: TagId, _nextChecked: boolean) => { + const anchor = editTagsAnchor; + if (!anchor) { + return; + } + const nextTagIds = toggleProjectTagAssignment(anchor.member.tags, tagId); + const api = readEnvironmentApi(anchor.member.environmentId); + if (!api) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to update tags for "${anchor.member.name}"`, + description: "Project API unavailable.", + }), + ); + return; + } + try { + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId: anchor.member.id, + tags: nextTagIds, + }); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to update tags for "${anchor.member.name}"`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [editTagsAnchor], + ); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const isOnSettings = pathname.startsWith("/settings"); @@ -2938,10 +3230,10 @@ export default function Sidebar() { [sidebarThreads], ); const sortedProjects = useMemo(() => { - const sortableProjects = sidebarProjects.map((project) => ({ - ...project, - id: project.projectKey, - })); + const filteredSidebarProjects = filterProjectSnapshotsByTags(sidebarProjects, selectedTagIds); + const sortableProjects = filteredSidebarProjects.map((project) => + Object.assign({}, project, { id: project.projectKey }), + ); const sortableThreads = visibleThreads.map((thread) => { const physicalKey = projectPhysicalKeyByScopedRef.get( @@ -2967,6 +3259,7 @@ export default function Sidebar() { sidebarProjectByKey, sidebarProjects, visibleThreads, + selectedTagIds, ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; const visibleSidebarThreadKeys = useMemo( @@ -3329,6 +3622,14 @@ export default function Sidebar() { projectGroupingMode={sidebarProjectGroupingMode} updateSettings={updateSettings} openAddProject={openAddProjectCommandPalette} + tagsForSidebar={tagsForSidebar} + selectedTagIds={selectedTagIds} + onTagFilterToggle={handleTagFilterToggle} + onClearTagFilter={handleClearTagFilter} + onOpenTagCreateDialog={openTagCreateDialog} + onRenameTag={handleRenameTag} + onDeleteTag={handleDeleteTag} + onOpenEditTagsForMember={handleOpenEditTagsForMember} isManualProjectSorting={isManualProjectSorting} projectDnDSensors={projectDnDSensors} projectCollisionDetection={projectCollisionDetection} @@ -3359,6 +3660,100 @@ export default function Sidebar() { )} + { + if (!open) { + closeTagCreateDialog(); + } + }} + > + + + New tag + + Tags are shared across projects in the catalog and used to filter the project list. + + + +
+ Tag name + setTagCreateName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void submitTagCreate(); + } + }} + data-testid="sidebar-tag-create-input" + /> +
+
+ + + + +
+
+ { + if (!open) { + closeTagRenameDialog(); + } + }} + > + + + Rename tag + + {tagRenameTarget + ? `Update the name of "${tagRenameTarget.name}".` + : "Update the tag name."} + + + +
+ Tag name + setTagRenameName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void submitTagRename(); + } + }} + data-testid="sidebar-tag-rename-input" + /> +
+
+ + + + +
+
+ {editTagsAnchor !== null ? ( + { + void handleToggleProjectTagAssignment(tagId, nextChecked); + }} + onCreateTag={openTagCreateDialog} + /> + ) : null} ); } diff --git a/apps/web/src/components/SidebarSectionHeader.tsx b/apps/web/src/components/SidebarSectionHeader.tsx new file mode 100644 index 000000000000..c84e48cc7033 --- /dev/null +++ b/apps/web/src/components/SidebarSectionHeader.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; + +export interface SidebarSectionHeaderProps { + label: string; + children?: React.ReactNode; +} + +/** + * Shared header used for top-level sidebar sections (Tags, Projects, …). + * Keeps label typography, padding, and right-aligned action slot consistent + * across sections so they have the exact same formatting and size. + */ +export function SidebarSectionHeader({ label, children }: SidebarSectionHeaderProps) { + return ( +
+ + {label} + + {children ?
{children}
: null} +
+ ); +} diff --git a/apps/web/src/components/SidebarTagFilter.tsx b/apps/web/src/components/SidebarTagFilter.tsx new file mode 100644 index 000000000000..095de52cdaa2 --- /dev/null +++ b/apps/web/src/components/SidebarTagFilter.tsx @@ -0,0 +1,204 @@ +import { useMemo, useState } from "react"; +import { ChevronRightIcon, PlusIcon, XIcon } from "lucide-react"; +import { type TagId } from "@t3tools/contracts"; +import type { Tag } from "../types"; +import { cn } from "../lib/utils"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; +import { Menu, MenuItem, MenuPopup } from "./ui/menu"; +import { Toggle } from "./ui/toggle"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { SidebarSectionHeader } from "./SidebarSectionHeader"; + +interface TagFilterPillContextMenuProps { + anchor: { x: number; y: number }; + onClose: () => void; + onRename: () => void; + onDelete: () => void; +} + +function TagFilterPillContextMenu({ + anchor, + onClose, + onRename, + onDelete, +}: TagFilterPillContextMenuProps) { + // The portaled positioner reads anchor coordinates from a virtual element + // whose `getBoundingClientRect` returns a zero-size rect at the click point. + // Mirrors the approach used by ProjectTagsEditor. + const virtualAnchor = useMemo( + () => ({ + getBoundingClientRect: (): DOMRect => ({ + x: anchor.x, + y: anchor.y, + top: anchor.y, + left: anchor.x, + right: anchor.x, + bottom: anchor.y, + width: 0, + height: 0, + toJSON: () => ({}), + }), + }), + [anchor.x, anchor.y], + ); + return ( + { + if (!open) { + onClose(); + } + }} + > + + { + onRename(); + onClose(); + }} + > + Rename… + + { + onDelete(); + onClose(); + }} + > + Delete + + + + ); +} + +interface TagFilterPillProps { + tag: Tag; + pressed: boolean; + onPressedChange: () => void; + onRename: () => void; + onDelete: () => void; +} + +function TagFilterPill({ tag, pressed, onPressedChange, onRename, onDelete }: TagFilterPillProps) { + const [contextAnchor, setContextAnchor] = useState<{ x: number; y: number } | null>(null); + return ( + <> + { + event.preventDefault(); + setContextAnchor({ x: event.clientX, y: event.clientY }); + }} + data-testid={`sidebar-tag-filter-item-${tag.id}`} + aria-pressed={pressed} + className={cn( + "h-6 rounded-full px-2 text-xs text-foreground/90 sm:h-5 sm:text-xs", + "data-pressed:border-foreground data-pressed:bg-foreground data-pressed:text-background", + "data-pressed:hover:bg-foreground/90", + )} + > + + {tag.name} + + + {contextAnchor ? ( + setContextAnchor(null)} + onRename={onRename} + onDelete={onDelete} + /> + ) : null} + + ); +} + +export interface SidebarTagFilterProps { + tags: readonly Tag[]; + selectedTagIds: readonly TagId[]; + onCreate: () => void; + onToggleTag: (tagId: TagId) => void; + onClear: () => void; + onRenameTag: (tag: Tag) => void; + onDeleteTag: (tag: Tag) => void; +} + +export function SidebarTagFilter({ + tags, + selectedTagIds, + onCreate, + onToggleTag, + onClear, + onRenameTag, + onDeleteTag, +}: SidebarTagFilterProps) { + const [open, setOpen] = useState(true); + const hasSelection = selectedTagIds.length > 0; + const headerLabel = hasSelection && !open ? `Tags (${selectedTagIds.length})` : "Tags"; + return ( + + + {hasSelection ? ( + + + } + > + + + Clear filter + + ) : null} + + + + + +
+ {tags.map((tag) => ( + onToggleTag(tag.id)} + onRename={() => onRenameTag(tag)} + onDelete={() => onDeleteTag(tag)} + /> + ))} + + + } + > + + + New tag + +
+
+
+ ); +} diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/SidebarUpdatePill.tsx similarity index 95% rename from apps/web/src/components/sidebar/SidebarUpdatePill.tsx rename to apps/web/src/components/SidebarUpdatePill.tsx index d7e5b74d42de..9fcf1ad66ddc 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/SidebarUpdatePill.tsx @@ -1,12 +1,12 @@ import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useState } from "react"; -import { isElectron } from "../../env"; +import { isElectron } from "../env"; import { setDesktopUpdateStateQueryData, useDesktopUpdateState, -} from "../../lib/desktopUpdateReactQuery"; -import { stackedThreadToast, toastManager } from "../ui/toast"; +} from "../lib/desktopUpdateReactQuery"; +import { stackedThreadToast, toastManager } from "./ui/toast"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, @@ -17,9 +17,9 @@ import { shouldShowArm64IntelBuildWarning, shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, -} from "../desktopUpdate.logic"; -import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +} from "./desktopUpdate.logic"; +import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; export function SidebarUpdatePill() { const queryClient = useQueryClient(); diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 9ae26fe8b461..312e5ad510de 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -52,6 +52,7 @@ function makeProject( createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", scripts: [], + tags: [], ...overrides, }; } @@ -81,6 +82,8 @@ function makeEmptyEnvironmentState(): EnvironmentState { return { projectIds: [], projectById: {}, + tagIds: [], + tagById: {}, threadIds: [], threadIdsByProjectId: {}, threadShellById: {}, diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 5f169adc9b4d..579503ca0ae4 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -91,6 +91,7 @@ function makeThreadShellSnapshot(params: { return { snapshotSequence: 1, projects: [], + tags: [], updatedAt: "2026-04-13T00:00:00.000Z", threads: [ { diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index c361cbd78706..c7ae97e62c0a 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -332,6 +332,7 @@ describe("wsApi", () => { model: "gpt-5-codex", }, scripts: [], + tags: [], createdAt: "2026-02-24T00:00:00.000Z", updatedAt: "2026-02-24T00:00:00.000Z", }, diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 8909c1bf7552..87689b6c30e3 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -1,5 +1,5 @@ import { scopeProjectRef } from "@t3tools/client-runtime"; -import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedProjectRef, TagId } from "@t3tools/contracts"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKey, @@ -23,6 +23,7 @@ export interface SidebarProjectSnapshot extends Project { memberProjects: readonly SidebarProjectGroupMember[]; memberProjectRefs: readonly ScopedProjectRef[]; remoteEnvironmentLabels: readonly string[]; + displayTagIds: readonly TagId[]; } export function buildPhysicalToLogicalProjectKeyMap(input: { @@ -95,6 +96,8 @@ export function buildSidebarProjectSnapshots(input: { .flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : [])) .filter((label, index, labels) => labels.indexOf(label) === index); + const displayTagIds = Array.from(new Set(members.flatMap((member) => member.tags))) as TagId[]; + result.push({ ...representative, projectKey: logicalKey, @@ -111,6 +114,7 @@ export function buildSidebarProjectSnapshots(input: { memberProjects: members, memberProjectRefs: members.map((member) => scopeProjectRef(member.environmentId, member.id)), remoteEnvironmentLabels, + displayTagIds, }); } diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 9bb01ba0be3c..600ca0b2f7dd 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -99,6 +99,7 @@ function makeState(thread: Thread): AppState { createdAt: "2026-02-13T00:00:00.000Z", updatedAt: "2026-02-13T00:00:00.000Z", scripts: [], + tags: [], }; const threadIdsByProjectId: EnvironmentState["threadIdsByProjectId"] = { [thread.projectId]: [thread.id], @@ -108,6 +109,8 @@ function makeState(thread: Thread): AppState { projectById: { [projectId]: project, }, + tagIds: [], + tagById: {}, threadIds: [thread.id], threadIdsByProjectId, threadShellById: { @@ -183,6 +186,8 @@ function makeEmptyState(overrides: Partial = {}): A const environmentState: EnvironmentState = { projectIds: [], projectById: {}, + tagIds: [], + tagById: {}, threadIds: [], threadIdsByProjectId: {}, threadShellById: {}, @@ -469,6 +474,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:00.000Z", updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], + tags: [], }, }, }); @@ -484,6 +490,7 @@ describe("incremental orchestration updates", () => { model: DEFAULT_MODEL_BY_PROVIDER.codex, }, scripts: [], + tags: [], createdAt: "2026-02-27T00:00:01.000Z", updatedAt: "2026-02-27T00:00:01.000Z", }), @@ -524,6 +531,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:00.000Z", updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], + tags: [], }, [recreatedProjectId]: { id: recreatedProjectId, @@ -537,6 +545,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:00.000Z", updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], + tags: [], }, }, }); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 3b1976bf9aba..16a4548ce4f6 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -11,12 +11,14 @@ import type { OrchestrationShellStreamEvent, OrchestrationSession, OrchestrationSessionStatus, + OrchestrationTagCatalogEntry, OrchestrationThread, OrchestrationThreadShell, OrchestrationThreadActivity, ProjectId, ScopedProjectRef, ScopedThreadRef, + TagId, } from "@t3tools/contracts"; import { ProviderKind } from "@t3tools/contracts"; import type { ThreadId, TurnId } from "@t3tools/contracts"; @@ -28,6 +30,7 @@ import { type Project, type ProposedPlan, type SidebarThreadSummary, + type Tag, type Thread, type ThreadSession, type ThreadShell, @@ -37,11 +40,15 @@ import { import { resolveEnvironmentHttpUrl } from "./environments/runtime"; import { sanitizeThreadErrorMessage } from "./rpc/transportError"; import { getThreadFromEnvironmentState } from "./threadDerivation"; +import { useUiStateStore } from "./uiStateStore"; export interface EnvironmentState { projectIds: ProjectId[]; projectById: Record; + tagIds: TagId[]; + tagById: Record; + // --------------------------------------------------------------------------- // Thread bookkeeping — written by BOTH shell stream and detail stream. // Both streams ensure the thread is registered here; the bookkeeping is @@ -97,6 +104,8 @@ export interface AppState { const initialEnvironmentState: EnvironmentState = { projectIds: [], projectById: {}, + tagIds: [], + tagById: {}, threadIds: [], threadIdsByProjectId: {}, threadShellById: {}, @@ -221,6 +230,17 @@ function mapProject( createdAt: project.createdAt, updatedAt: project.updatedAt, scripts: mapProjectScripts(project.scripts), + tags: [...project.tags], + }; +} + +function mapTag(tag: OrchestrationTagCatalogEntry, environmentId: EnvironmentId): Tag { + return { + id: tag.id, + environmentId, + name: tag.name, + createdAt: tag.createdAt, + updatedAt: tag.updatedAt, }; } @@ -1038,6 +1058,13 @@ function buildProjectState( }; } +function buildTagState(tags: ReadonlyArray): Pick { + return { + tagIds: tags.map((tag) => tag.id), + tagById: Object.fromEntries(tags.map((tag) => [tag.id, tag] as const)) as Record, + }; +} + function getStoredEnvironmentState( state: AppState, environmentId: EnvironmentId, @@ -1075,10 +1102,12 @@ function syncEnvironmentShellSnapshot( environmentId: EnvironmentId, ): EnvironmentState { const nextProjects = snapshot.projects.map((project) => mapProject(project, environmentId)); + const nextTags = snapshot.tags.map((tag) => mapTag(tag, environmentId)); const nextThreadIds = new Set(snapshot.threads.map((thread) => thread.id)); let nextState: EnvironmentState = { ...state, ...buildProjectState(nextProjects), + ...buildTagState(nextTags), threadIds: [], threadIdsByProjectId: {}, threadShellById: {}, @@ -1154,6 +1183,7 @@ function applyEnvironmentOrchestrationEvent( repositoryIdentity: event.payload.repositoryIdentity ?? null, defaultModelSelection: event.payload.defaultModelSelection, scripts: event.payload.scripts, + tags: event.payload.tags, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, deletedAt: null, @@ -1218,6 +1248,7 @@ function applyEnvironmentOrchestrationEvent( ...(event.payload.scripts !== undefined ? { scripts: mapProjectScripts(event.payload.scripts) } : {}), + ...(event.payload.tags !== undefined ? { tags: [...event.payload.tags] } : {}), updatedAt: event.payload.updatedAt, }; return { @@ -1687,6 +1718,38 @@ function applyEnvironmentShellEvent( return writeThreadShellState(state, mapThreadShell(event.thread, environmentId)); case "thread-removed": return removeThreadState(state, event.threadId); + case "tag-upserted": { + const nextTag = mapTag(event.tag, environmentId); + const tagById = { + ...state.tagById, + [nextTag.id]: nextTag, + }; + const tagIds = state.tagIds.includes(nextTag.id) + ? state.tagIds + : [...state.tagIds, nextTag.id]; + return { + ...state, + tagById, + tagIds, + }; + } + case "tag-removed": { + if (!state.tagById[event.tagId]) { + return state; + } + const { [event.tagId]: _removedTag, ...tagById } = state.tagById; + // Schedule a microtask to clear the device-only filter selection without + // recursing into another store update during this reducer's commit phase. + const removedTagId = event.tagId; + queueMicrotask(() => { + useUiStateStore.getState().clearProjectTagFilterTagId(removedTagId); + }); + return { + ...state, + tagById, + tagIds: removeId(state.tagIds, event.tagId), + }; + } } } @@ -1741,6 +1804,21 @@ export function selectProjectsAcrossEnvironments(state: AppState): Project[] { ); } +export function selectTagsAcrossEnvironments(state: AppState): Tag[] { + const seen = new Map(); + for (const [, environmentState] of getEnvironmentEntries(state)) { + for (const tagId of environmentState.tagIds) { + const tag = environmentState.tagById[tagId]; + if (!tag) continue; + const existing = seen.get(tagId); + if (!existing || existing.updatedAt < tag.updatedAt) { + seen.set(tagId, tag); + } + } + } + return [...seen.values()]; +} + export function selectThreadsAcrossEnvironments(state: AppState): Thread[] { return getEnvironmentEntries(state).flatMap(([, environmentState]) => getThreads(environmentState), diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index a5449757311e..9d7b4fc3ec15 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -7,6 +7,7 @@ import type { OrchestrationSessionStatus, OrchestrationThreadActivity, ProjectScript as ContractProjectScript, + TagId, ThreadId, ProjectId, TurnId, @@ -90,6 +91,15 @@ export interface Project { createdAt?: string | undefined; updatedAt?: string | undefined; scripts: ProjectScript[]; + tags: TagId[]; +} + +export interface Tag { + id: TagId; + environmentId: EnvironmentId; + name: string; + createdAt: string; + updatedAt: string; } export interface Thread { diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index f63208d9ba73..627d50182142 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -22,6 +22,7 @@ function makeUiState(overrides: Partial = {}): UiState { projectOrder: [], threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, + projectTagFilter: { selectedTagIds: [] }, ...overrides, }; } diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 8bd65ffc56af..61787e91d5c0 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,3 +1,4 @@ +import type { TagId } from "@t3tools/contracts"; import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; @@ -20,6 +21,7 @@ export interface PersistedUiState { expandedProjectCwds?: string[]; projectOrderCwds?: string[]; threadChangedFilesExpandedById?: Record>; + projectTagFilterSelectedTagIds?: string[]; } export interface UiProjectState { @@ -32,7 +34,9 @@ export interface UiThreadState { threadChangedFilesExpandedById: Record>; } -export interface UiState extends UiProjectState, UiThreadState {} +export interface UiState extends UiProjectState, UiThreadState { + projectTagFilter: { selectedTagIds: TagId[] }; +} export interface SyncProjectInput { /** Physical project key (env + cwd). Used for manual sort order. */ @@ -52,6 +56,7 @@ const initialState: UiState = { projectOrder: [], threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, + projectTagFilter: { selectedTagIds: [] }, }; const persistedCollapsedProjectCwds = new Set(); @@ -91,6 +96,11 @@ function readPersistedState(): UiState { threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( parsed.threadChangedFilesExpandedById, ), + projectTagFilter: { + selectedTagIds: (parsed.projectTagFilterSelectedTagIds ?? []) + .filter((entry): entry is string => typeof entry === "string" && entry.length > 0) + .map((entry) => entry as TagId), + }, }; } catch { return initialState; @@ -180,6 +190,7 @@ export function persistState(state: UiState): void { expandedProjectCwds, projectOrderCwds, threadChangedFilesExpandedById, + projectTagFilterSelectedTagIds: state.projectTagFilter.selectedTagIds.map((tagId) => tagId), } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -599,6 +610,34 @@ export function reorderProjects( }; } +export function setProjectTagFilterSelection( + state: UiState, + selectedTagIds: readonly TagId[], +): UiState { + if ( + state.projectTagFilter.selectedTagIds.length === selectedTagIds.length && + state.projectTagFilter.selectedTagIds.every((id, index) => id === selectedTagIds[index]) + ) { + return state; + } + return { + ...state, + projectTagFilter: { selectedTagIds: [...selectedTagIds] }, + }; +} + +export function clearProjectTagFilterTagId(state: UiState, tagId: TagId): UiState { + if (!state.projectTagFilter.selectedTagIds.includes(tagId)) { + return state; + } + return { + ...state, + projectTagFilter: { + selectedTagIds: state.projectTagFilter.selectedTagIds.filter((id) => id !== tagId), + }, + }; +} + interface UiStateStore extends UiState { syncProjects: (projects: readonly SyncProjectInput[]) => void; syncThreads: (threads: readonly SyncThreadInput[]) => void; @@ -612,6 +651,8 @@ interface UiStateStore extends UiState { draggedProjectIds: readonly string[], targetProjectIds: readonly string[], ) => void; + setProjectTagFilterSelection: (selectedTagIds: readonly TagId[]) => void; + clearProjectTagFilterTagId: (tagId: TagId) => void; } export const useUiStateStore = create((set) => ({ @@ -630,6 +671,9 @@ export const useUiStateStore = create((set) => ({ set((state) => setProjectExpanded(state, projectId, expanded)), reorderProjects: (draggedProjectIds, targetProjectIds) => set((state) => reorderProjects(state, draggedProjectIds, targetProjectIds)), + setProjectTagFilterSelection: (selectedTagIds) => + set((state) => setProjectTagFilterSelection(state, selectedTagIds)), + clearProjectTagFilterTagId: (tagId) => set((state) => clearProjectTagFilterTagId(state, tagId)), })); useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 10ee6851be25..6f12313cad51 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -47,3 +47,5 @@ export const ApprovalRequestId = makeEntityId("ApprovalRequestId"); export type ApprovalRequestId = typeof ApprovalRequestId.Type; export const CheckpointRef = makeEntityId("CheckpointRef"); export type CheckpointRef = typeof CheckpointRef.Type; +export const TagId = makeEntityId("TagId"); +export type TagId = typeof TagId.Type; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index c201d6c82a3c..18fd9dc922af 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -11,6 +11,7 @@ import { NonNegativeInt, ProjectId, ProviderItemId, + TagId, ThreadId, TrimmedNonEmptyString, TurnId, @@ -117,6 +118,14 @@ const ChatAttachmentId = TrimmedNonEmptyString.check( ); export type ChatAttachmentId = typeof ChatAttachmentId.Type; +export const TAG_NAME_MAX_CHARS = 64; +export const TAG_NAME_PATTERN: RegExp = /^[\p{L}\p{N} _-]+$/u; +const TagName = TrimmedNonEmptyString.check( + Schema.isMaxLength(TAG_NAME_MAX_CHARS), + Schema.isPattern(TAG_NAME_PATTERN), +); +export type TagName = typeof TagName.Type; + export const ChatImageAttachment = Schema.Struct({ type: Schema.Literal("image"), id: ChatAttachmentId, @@ -161,6 +170,14 @@ export const ProjectScript = Schema.Struct({ }); export type ProjectScript = typeof ProjectScript.Type; +export const OrchestrationTagCatalogEntry = Schema.Struct({ + id: TagId, + name: TagName, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type OrchestrationTagCatalogEntry = typeof OrchestrationTagCatalogEntry.Type; + export const OrchestrationProject = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, @@ -168,6 +185,7 @@ export const OrchestrationProject = Schema.Struct({ repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), + tags: Schema.Array(TagId).pipe(Schema.withDecodingDefault(Effect.succeed([]))), createdAt: IsoDateTime, updatedAt: IsoDateTime, deletedAt: Schema.NullOr(IsoDateTime), @@ -323,6 +341,9 @@ export const OrchestrationReadModel = Schema.Struct({ snapshotSequence: NonNegativeInt, projects: Schema.Array(OrchestrationProject), threads: Schema.Array(OrchestrationThread), + tags: Schema.Array(OrchestrationTagCatalogEntry).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), updatedAt: IsoDateTime, }); export type OrchestrationReadModel = typeof OrchestrationReadModel.Type; @@ -334,6 +355,7 @@ export const OrchestrationProjectShell = Schema.Struct({ repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), + tags: Schema.Array(TagId).pipe(Schema.withDecodingDefault(Effect.succeed([]))), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -366,6 +388,9 @@ export const OrchestrationShellSnapshot = Schema.Struct({ snapshotSequence: NonNegativeInt, projects: Schema.Array(OrchestrationProjectShell), threads: Schema.Array(OrchestrationThreadShell), + tags: Schema.Array(OrchestrationTagCatalogEntry).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), updatedAt: IsoDateTime, }); export type OrchestrationShellSnapshot = typeof OrchestrationShellSnapshot.Type; @@ -391,6 +416,16 @@ export const OrchestrationShellStreamEvent = Schema.Union([ sequence: NonNegativeInt, threadId: ThreadId, }), + Schema.Struct({ + kind: Schema.Literal("tag-upserted"), + sequence: NonNegativeInt, + tag: OrchestrationTagCatalogEntry, + }), + Schema.Struct({ + kind: Schema.Literal("tag-removed"), + sequence: NonNegativeInt, + tagId: TagId, + }), ]); export type OrchestrationShellStreamEvent = typeof OrchestrationShellStreamEvent.Type; @@ -433,6 +468,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({ workspaceRoot: Schema.optional(TrimmedNonEmptyString), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), scripts: Schema.optional(Schema.Array(ProjectScript)), + tags: Schema.optional(Schema.Array(TagId)), }); const ProjectDeleteCommand = Schema.Struct({ @@ -442,6 +478,30 @@ const ProjectDeleteCommand = Schema.Struct({ force: Schema.optional(Schema.Boolean), }); +const TagCreateCommand = Schema.Struct({ + type: Schema.Literal("tag.create"), + commandId: CommandId, + tagId: TagId, + // Server normalizes; we deliberately do NOT use TagName here so client errors + // surface as invariant errors with a clear message rather than schema-decode + // failures at the WS boundary. + name: TrimmedNonEmptyString, + createdAt: IsoDateTime, +}); + +const TagRenameCommand = Schema.Struct({ + type: Schema.Literal("tag.rename"), + commandId: CommandId, + tagId: TagId, + name: TrimmedNonEmptyString, +}); + +const TagDeleteCommand = Schema.Struct({ + type: Schema.Literal("tag.delete"), + commandId: CommandId, + tagId: TagId, +}); + const ThreadCreateCommand = Schema.Struct({ type: Schema.Literal("thread.create"), commandId: CommandId, @@ -625,6 +685,9 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, + TagCreateCommand, + TagRenameCommand, + TagDeleteCommand, ]); export type DispatchableClientOrchestrationCommand = typeof DispatchableClientOrchestrationCommand.Type; @@ -646,6 +709,9 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, + TagCreateCommand, + TagRenameCommand, + TagDeleteCommand, ]); export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; @@ -754,10 +820,13 @@ export const OrchestrationEventType = Schema.Literals([ "thread.proposed-plan-upserted", "thread.turn-diff-completed", "thread.activity-appended", + "tag.created", + "tag.renamed", + "tag.deleted", ]); export type OrchestrationEventType = typeof OrchestrationEventType.Type; -export const OrchestrationAggregateKind = Schema.Literals(["project", "thread"]); +export const OrchestrationAggregateKind = Schema.Literals(["project", "thread", "tag"]); export type OrchestrationAggregateKind = typeof OrchestrationAggregateKind.Type; export const OrchestrationActorKind = Schema.Literals(["client", "server", "provider"]); @@ -768,6 +837,7 @@ export const ProjectCreatedPayload = Schema.Struct({ repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), + tags: Schema.Array(TagId).pipe(Schema.withDecodingDefault(Effect.succeed([]))), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -779,6 +849,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), scripts: Schema.optional(Schema.Array(ProjectScript)), + tags: Schema.optional(Schema.Array(TagId)), updatedAt: IsoDateTime, }); @@ -787,6 +858,27 @@ export const ProjectDeletedPayload = Schema.Struct({ deletedAt: IsoDateTime, }); +export const TagCreatedPayload = Schema.Struct({ + tagId: TagId, + name: TagName, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type TagCreatedPayload = typeof TagCreatedPayload.Type; + +export const TagRenamedPayload = Schema.Struct({ + tagId: TagId, + name: TagName, + updatedAt: IsoDateTime, +}); +export type TagRenamedPayload = typeof TagRenamedPayload.Type; + +export const TagDeletedPayload = Schema.Struct({ + tagId: TagId, + deletedAt: IsoDateTime, +}); +export type TagDeletedPayload = typeof TagDeletedPayload.Type; + export const ThreadCreatedPayload = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, @@ -941,7 +1033,7 @@ const EventBaseFields = { sequence: NonNegativeInt, eventId: EventId, aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), + aggregateId: Schema.Union([ProjectId, ThreadId, TagId]), occurredAt: IsoDateTime, commandId: Schema.NullOr(CommandId), causationEventId: Schema.NullOr(EventId), @@ -1060,6 +1152,21 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.activity-appended"), payload: ThreadActivityAppendedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("tag.created"), + payload: TagCreatedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("tag.renamed"), + payload: TagRenamedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("tag.deleted"), + payload: TagDeletedPayload, + }), ]); export type OrchestrationEvent = typeof OrchestrationEvent.Type;