diff --git a/.github/workflows/nix-release.yml b/.github/workflows/nix-release.yml new file mode 100644 index 000000000000..f9b2348a9aa6 --- /dev/null +++ b/.github/workflows/nix-release.yml @@ -0,0 +1,202 @@ +name: Build Nix release artifacts + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + version: + description: "Version to build (without v prefix)" + required: true + default: "0.0.24-fork.1" + +permissions: + contents: write + +jobs: + build-macos: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Resolve version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "tag=v${{ inputs.version }}" >> "$GITHUB_OUTPUT" + echo "version=${{ inputs.version }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + fi + + - name: Build desktop artifact + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + run: | + vp run dist:desktop:artifact \ + --platform mac \ + --target zip \ + --arch arm64 \ + --build-version "${{ steps.version.outputs.version }}" \ + --verbose + + - name: Build CLI + run: vp run --filter t3 build + + - name: Enable pnpm + # Vite+ embeds pnpm but does not expose a standalone `pnpm` on PATH, + # and `vp` has no `deploy` command. Activate the repo's pinned pnpm + # (the `packageManager` field) via corepack so the deploy step below + # can resolve the production dependency closure. + run: corepack enable + + - name: Package CLI tarball + run: | + mkdir -p release + # Produce a pruned production tree (bundle dist/ + only runtime deps) + # instead of taring the full multi-GB node_modules. pnpm deploy resolves + # the prod dependency closure for the `t3` package into a self-contained + # dir containing dist/, node_modules/, and package.json. + rm -rf cli-deploy + pnpm --filter t3 deploy --prod --legacy cli-deploy + tar -czf release/t3code-cli-aarch64-darwin.tar.gz -C cli-deploy \ + node_modules \ + dist \ + package.json + + - name: Create release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.tag }} + files: | + release/T3-Code-*.zip + release/t3code-cli-aarch64-darwin.tar.gz + + build-linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Resolve version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "tag=v${{ inputs.version }}" >> "$GITHUB_OUTPUT" + echo "version=${{ inputs.version }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + fi + + - name: Build CLI + run: vp run --filter t3 build + + - name: Enable pnpm + # Vite+ embeds pnpm but does not expose a standalone `pnpm` on PATH, + # and `vp` has no `deploy` command. Activate the repo's pinned pnpm + # (the `packageManager` field) via corepack so the deploy step below + # can resolve the production dependency closure. + run: corepack enable + + - name: Package CLI tarball + run: | + mkdir -p release + # Produce a pruned production tree (bundle dist/ + only runtime deps) + # instead of taring the full multi-GB node_modules. pnpm deploy resolves + # the prod dependency closure for the `t3` package into a self-contained + # dir containing dist/, node_modules/, and package.json. + rm -rf cli-deploy + pnpm --filter t3 deploy --prod --legacy cli-deploy + tar -czf release/t3code-cli-x86_64-linux.tar.gz -C cli-deploy \ + node_modules \ + dist \ + package.json + + - name: Create release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.tag }} + files: release/t3code-cli-x86_64-linux.tar.gz + + update-hashes: + needs: [build-macos, build-linux] + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: personal + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: cachix/install-nix-action@v31 + + - name: Download release artifacts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p artifacts + gh release download "${GITHUB_REF_NAME}" \ + --repo "${{ github.repository }}" \ + --dir artifacts \ + --pattern 't3code-cli-*.tar.gz' \ + --pattern 'T3-Code-*-arm64.zip' + + - name: Compute hashes and write nix-hashes.json + run: | + VERSION="${GITHUB_REF_NAME#v}" + + CLI_DARWIN=$(nix hash file --sri --type sha256 artifacts/t3code-cli-aarch64-darwin.tar.gz) + CLI_LINUX=$(nix hash file --sri --type sha256 artifacts/t3code-cli-x86_64-linux.tar.gz) + DESKTOP=$(nix hash file --sri --type sha256 artifacts/T3-Code-*-arm64.zip) + + echo "version: $VERSION" + echo "cli linux: $CLI_LINUX" + echo "cli darwin: $CLI_DARWIN" + echo "desktop darwin: $DESKTOP" + + jq -n \ + --arg version "$VERSION" \ + --arg cli_linux "$CLI_LINUX" \ + --arg cli_darwin "$CLI_DARWIN" \ + --arg desktop "$DESKTOP" \ + '{ + version: $version, + cli: { + "x86_64-linux": $cli_linux, + "aarch64-darwin": $cli_darwin + }, + desktop: { + "aarch64-darwin": $desktop + } + }' > nix-hashes.json + + cat nix-hashes.json + + - name: Commit and push + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add nix-hashes.json + if git diff --cached --quiet; then + echo "nix-hashes.json already up to date" + exit 0 + fi + git commit -m "chore(release): update artifact hashes for ${GITHUB_REF_NAME}" + git push origin HEAD:personal diff --git a/CLAUDE.md b/CLAUDE.md index c3170642553f..47dc3e3d863c 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md +AGENTS.md \ No newline at end of file diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts index 191afe03c181..54a20da31b40 100644 --- a/apps/mobile/src/lib/repositoryGroups.test.ts +++ b/apps/mobile/src/lib/repositoryGroups.test.ts @@ -17,6 +17,7 @@ function makeProject( repositoryIdentity: null, defaultModelSelection: null, scripts: [], + tags: [], createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", ...input, diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 19a4b56417c9..a8e76bed3b87 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -74,6 +74,7 @@ import { import { deriveServerPaths, ServerConfig } from "../src/config.ts"; import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts"; import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts"; +import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; @@ -319,6 +320,9 @@ export const makeOrchestrationIntegrationHarness = ( readonly newBranch: string; }) => Effect.succeed({ branch: input.newBranch }), }); + const gitVcsDriverLayer = Layer.mock(GitVcsDriver.GitVcsDriver)({ + readConfigValue: () => Effect.succeed(null), + }); const textGenerationLayer = Layer.succeed(TextGeneration, { generateBranchName: () => Effect.succeed({ branch: "update" }), generateThreadTitle: () => Effect.succeed({ title: "New thread" }), @@ -326,6 +330,7 @@ export const makeOrchestrationIntegrationHarness = ( const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(gitWorkflowLayer), + Layer.provideMerge(gitVcsDriverLayer), Layer.provideMerge(textGenerationLayer), Layer.provideMerge(serverSettingsLayer), ); diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 9f31532855a9..29df2136b4f7 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -108,6 +108,8 @@ describe("CheckpointDiffQueryLive", () => { }), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + listAllTags: () => Effect.succeed([]), + getTagById: () => Effect.succeed(Option.none()), }), ), ); @@ -200,6 +202,8 @@ describe("CheckpointDiffQueryLive", () => { getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + listAllTags: () => Effect.succeed([]), + getTagById: () => Effect.succeed(Option.none()), }), ), ); @@ -282,6 +286,8 @@ describe("CheckpointDiffQueryLive", () => { getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + listAllTags: () => Effect.succeed([]), + getTagById: () => Effect.succeed(Option.none()), }), ), ); @@ -349,6 +355,8 @@ describe("CheckpointDiffQueryLive", () => { getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + listAllTags: () => Effect.succeed([]), + getTagById: () => Effect.succeed(Option.none()), }), ), ); @@ -401,6 +409,8 @@ describe("CheckpointDiffQueryLive", () => { getFullThreadDiffContext: () => 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/GitManager.ts b/apps/server/src/git/GitManager.ts index 5423f4f14769..fd68ea35905a 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1297,6 +1297,21 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); + const githubDir = path.join(cwd, ".github"); + const prTemplate = yield* Effect.gen(function* () { + const entries = yield* fileSystem + .readDirectory(githubDir, { recursive: false }) + .pipe(Effect.option); + const match = Option.getOrElse(entries, () => [] as Array).find( + (name) => name.toLowerCase() === "pull_request_template.md", + ); + if (!match) return undefined; + return yield* fileSystem + .readFileString(path.join(githubDir, match)) + .pipe(Effect.option) + .pipe(Effect.map(Option.getOrUndefined)); + }); + const generated = yield* textGeneration.generatePrContent({ cwd, baseBranch, @@ -1304,6 +1319,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { commitSummary: limitContext(rangeContext.commitSummary, 20_000), diffSummary: limitContext(rangeContext.diffSummary, 20_000), diffPatch: limitContext(rangeContext.diffPatch, 60_000), + prTemplate, modelSelection, }); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 56876ec148ed..5a5fa08d6c44 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -114,6 +114,7 @@ describe("OrchestrationEngine", () => { const projectionSnapshot = { snapshotSequence: 7, updatedAt: "2026-03-03T00:00:04.000Z", + tags: [], projects: [ { id: asProjectId("project-bootstrap"), @@ -124,6 +125,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, @@ -181,6 +183,7 @@ describe("OrchestrationEngine", () => { snapshotSequence: projectionSnapshot.snapshotSequence, projects: [], threads: [], + tags: [], updatedAt: projectionSnapshot.updatedAt, }), getArchivedShellSnapshot: () => @@ -188,6 +191,7 @@ describe("OrchestrationEngine", () => { snapshotSequence: projectionSnapshot.snapshotSequence, projects: [], threads: [], + tags: [], updatedAt: projectionSnapshot.updatedAt, }), getSnapshotSequence: () => @@ -200,6 +204,8 @@ describe("OrchestrationEngine", () => { getFullThreadDiffContext: () => 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 7277663e9485..dcae54292acd 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"; @@ -57,8 +58,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": @@ -68,6 +69,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 369eea0f7a09..d6c30a2d7925 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -76,6 +76,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { workspaceRoot: "/tmp/project-1", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -350,6 +351,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { workspaceRoot: "/tmp/project-clear-attachments", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -479,6 +481,7 @@ it.layer( workspaceRoot: "/tmp/project-overwrite", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -628,6 +631,7 @@ it.layer( workspaceRoot: "/tmp/project-rollback", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -757,6 +761,7 @@ it.layer( workspaceRoot: "/tmp/project-revert-files", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -965,6 +970,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta workspaceRoot: "/tmp/project-delete-files", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -1128,6 +1134,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { workspaceRoot: "/tmp/project-a", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -1255,6 +1262,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 4a48de19d39c..b120e983d6a4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -17,6 +17,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 { @@ -36,6 +37,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"; @@ -43,6 +45,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, @@ -64,6 +67,7 @@ export const ORCHESTRATION_PROJECTOR_NAMES = { threadTurns: "projection.thread-turns", checkpoints: "projection.checkpoints", pendingApprovals: "projection.pending-approvals", + tags: "projection.tags", } as const; type ProjectorName = @@ -447,6 +451,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; @@ -470,6 +475,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, @@ -493,6 +499,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; @@ -518,6 +525,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, ) { @@ -1400,6 +1448,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* ( @@ -1503,4 +1555,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 7db2a23e5ec3..37785451f21c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -276,6 +276,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runOnWorktreeCreate: false, }, ], + tags: [], createdAt: "2026-02-24T00:00:00.000Z", updatedAt: "2026-02-24T00:00:01.000Z", deletedAt: null, @@ -387,6 +388,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 e629d1604b3e..8b871548b4dc 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -10,6 +10,7 @@ import { OrchestrationShellSnapshot, OrchestrationThread, ProjectScript, + TagId, TurnId, type OrchestrationCheckpointSummary, type OrchestrationLatestTurn, @@ -18,6 +19,7 @@ import { type OrchestrationProposedPlan, type OrchestrationProject, type OrchestrationSession, + type OrchestrationTagCatalogEntry, type OrchestrationThreadActivity, type OrchestrationThreadShell, ModelSelection, @@ -43,6 +45,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"; @@ -65,6 +72,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( @@ -147,6 +155,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 { @@ -234,6 +243,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, }; @@ -263,6 +282,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 resolveRepositoryIdentitiesForProjects = Effect.fn( "ProjectionSnapshotQuery.resolveRepositoryIdentitiesForProjects", @@ -307,6 +327,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" @@ -668,6 +689,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" @@ -690,6 +712,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" @@ -1006,6 +1029,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + projectionTagRepository.listAll(), ]), ) .pipe( @@ -1020,6 +1044,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { checkpointRows, latestTurnRows, stateRows, + tagRows, ]) => Effect.gen(function* () { const messagesByThread = new Map>(); @@ -1167,6 +1192,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, @@ -1197,6 +1223,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snapshotSequence: computeSnapshotSequence(stateRows), projects, threads, + tags: tagRows.map(mapTagRow), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", }; @@ -1267,11 +1294,20 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + projectionTagRepository.listAll(), ]), ) .pipe( Effect.flatMap( - ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) => + ([ + projectRows, + threadRows, + proposedPlanRows, + sessionRows, + latestTurnRows, + stateRows, + tagRows, + ]) => Effect.sync(() => { let updatedAt: string | null = null; const projects: OrchestrationProject[] = []; @@ -1289,6 +1325,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspaceRoot: row.workspaceRoot, defaultModelSelection: row.defaultModelSelection, scripts: row.scripts, + tags: row.tags, createdAt: row.createdAt, updatedAt: row.updatedAt, deletedAt: row.deletedAt, @@ -1396,6 +1433,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snapshotSequence: computeSnapshotSequence(stateRows), projects, threads, + tags: tagRows.map(mapTagRow), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", } satisfies OrchestrationReadModel; }), @@ -1452,85 +1490,89 @@ 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 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); } - } - for (const row of stateRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(projectRows); - 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 repositoryIdentities = + yield* resolveRepositoryIdentitiesForProjects(projectRows); + 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: Arr.filterMap(projectRows, (row) => - row.deletedAt === null - ? Result.succeed( - mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), - ) - : Result.failVoid, - ), - threads: Arr.filterMap(threadRows, (row) => - row.deletedAt === null - ? Result.succeed({ - 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, - } satisfies OrchestrationThreadShell) - : Result.failVoid, - ), - updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", - }; + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: Arr.filterMap(projectRows, (row) => + row.deletedAt === null + ? Result.succeed( + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ) + : Result.failVoid, + ), + threads: Arr.filterMap(threadRows, (row) => + row.deletedAt === null + ? Result.succeed({ + 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, + } satisfies OrchestrationThreadShell) + : Result.failVoid, + ), + tags: tagRows.map(mapTagRow), + updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", + }; - return yield* decodeShellSnapshot(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", + ), ), - ), - ); - }), + ); + }), ), Effect.mapError((error) => { if (isPersistenceError(error)) { @@ -1725,6 +1767,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, @@ -2033,6 +2076,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 { getCommandReadModel, getSnapshot, @@ -2047,10 +2098,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getFullThreadDiffContext, 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.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 0d5cfe2feba2..571350f0d5cb 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -60,6 +60,7 @@ import * as Clock from "effect/Clock"; import { ServerSettingsService } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService, type GitWorkflowServiceShape } from "../../git/GitWorkflowService.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); @@ -352,6 +353,11 @@ describe("ProviderCommandReactor", () => { renameBranch, } satisfies Partial), ), + Layer.provideMerge( + Layer.mock(GitVcsDriver.GitVcsDriver)({ + readConfigValue: () => Effect.succeed(null), + }), + ), Layer.provideMerge( Layer.succeed(VcsStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index e0db0fc320c7..3f9996d4488b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -41,6 +41,8 @@ import { import { ServerSettingsService } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; + const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderDriverKind = Schema.is(ProviderDriverKind); @@ -160,20 +162,15 @@ function buildGeneratedWorktreeBranchName(raw: string): string { .replace(/^refs\/heads\//, "") .replace(/['"`]/g, ""); - const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`) - ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length) - : normalized; - - const branchFragment = withoutPrefix + const branchName = normalized .replace(/[^a-z0-9/_-]+/g, "-") .replace(/\/+/g, "/") .replace(/-+/g, "-") .replace(/^[./_-]+|[./_-]+$/g, "") - .slice(0, 64) + .slice(0, 80) .replace(/[./_-]+$/g, ""); - const safeFragment = branchFragment.length > 0 ? branchFragment : "update"; - return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; + return branchName.length > 0 ? branchName : "update"; } const make = Effect.gen(function* () { @@ -183,6 +180,7 @@ const make = Effect.gen(function* () { const providerService = yield* ProviderService; const providerRegistry = yield* ProviderRegistry; const gitWorkflow = yield* GitWorkflowService; + const git = yield* GitVcsDriver.GitVcsDriver; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; @@ -657,10 +655,16 @@ const make = Effect.gen(function* () { const { textGenerationModelSelection: modelSelection } = yield* serverSettingsService.getSettings; + 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({ cwd, message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), + username: firstName, modelSelection, }); if (!generated) return; 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 7d85f0240f74..6bea32c9d686 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -13,9 +13,11 @@ import type { OrchestrationProjectShell, OrchestrationReadModel, OrchestrationShellSnapshot, + OrchestrationTagCatalogEntry, OrchestrationThread, OrchestrationThreadShell, ProjectId, + TagId, ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -157,6 +159,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 9c6c8bd2a18e..50244a0e0d98 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -24,6 +24,7 @@ const now = "2026-01-01T00:00:00.000Z"; const readModel: OrchestrationReadModel = { snapshotSequence: 2, updatedAt: now, + tags: [], projects: [ { id: ProjectId.make("project-a"), @@ -34,6 +35,7 @@ const readModel: OrchestrationReadModel = { model: "gpt-5-codex", }, scripts: [], + tags: [], createdAt: now, updatedAt: now, deletedAt: null, @@ -47,6 +49,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 f5ab794bce76..2004e285add9 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 * as Effect from "effect/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 fea36b5717fe..91be8b901c98 100644 --- a/apps/server/src/orchestration/decider.delete.test.ts +++ b/apps/server/src/orchestration/decider.delete.test.ts @@ -40,6 +40,7 @@ const seedReadModel = Effect.gen(function* () { 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 64ba159c740b..53a099dddc5b 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -63,6 +63,7 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { workspaceRoot: "/tmp/scripts", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -115,6 +116,7 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { workspaceRoot: "/tmp/project", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -212,6 +214,7 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { workspaceRoot: "/tmp/project", defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, }, @@ -290,6 +293,7 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { 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 0d4af771ca8a..2220aec06f1f 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -3,7 +3,9 @@ import { type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, + type TagId, } from "@t3tools/contracts"; +import { TAG_NAME_MAX_CHARS, TAG_NAME_PATTERN } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -12,8 +14,12 @@ import type * as PlatformError from "effect/PlatformError"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; import { listThreadsByProjectId, + normalizeTagName, requireProject, requireProjectAbsent, + requireTag, + requireTagNameAvailable, + requireTagsExist, requireThread, requireThreadArchived, requireThreadAbsent, @@ -62,9 +68,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< ReadonlyArray, OrchestrationCommandInvariantError | PlatformError.PlatformError, @@ -90,6 +98,11 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ } } + if (trailingEvent !== undefined) { + const trailing = trailingEvent(nextReadModel); + plannedEvents.push(trailing); + } + return plannedEvents; }); @@ -126,6 +139,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" workspaceRoot: command.workspaceRoot, defaultModelSelection: command.defaultModelSelection ?? null, scripts: [], + tags: [], createdAt: command.createdAt, updatedAt: command.createdAt, }, @@ -138,6 +152,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); + if (command.tags !== undefined) { + yield* requireTagsExist({ + readModel, + command, + tagIds: command.tags, + }); + } const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -155,6 +176,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? { defaultModelSelection: command.defaultModelSelection } : {}), ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), + ...(command.tags !== undefined ? { tags: command.tags } : {}), updatedAt: occurredAt, }, }; @@ -753,6 +775,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 { + ...(yield* 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 = yield* nowIso; + return { + ...(yield* 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 = yield* nowIso; + const tagDeletedEvent: PlannedOrchestrationEvent = { + ...(yield* 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 }; @@ -763,3 +877,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 0c92f965433b..409f1be49c96 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -14,6 +14,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 2bac5de920cb..76d8b19b8836 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts @@ -41,6 +41,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 18d0e9aa578b..3c83a4db4fa0 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"; @@ -35,7 +36,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), @@ -51,7 +52,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 c1ca6d3104e6..38c17f80ff2d 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -import { ModelSelection, ProjectScript } from "@t3tools/contracts"; +import { ModelSelection, ProjectScript, TagId } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionProjectInput, @@ -19,6 +19,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; @@ -36,6 +37,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root, default_model_selection_json, scripts_json, + tags_json, created_at, updated_at, deleted_at @@ -46,6 +48,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} @@ -56,6 +59,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 @@ -73,6 +77,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" @@ -92,6 +97,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 a2069e62a14c..6fc425eb903f 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -34,6 +34,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..2744420883ac --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionTags.ts @@ -0,0 +1,141 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +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/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts new file mode 100644 index 000000000000..fa0a945efaac --- /dev/null +++ b/apps/server/src/persistence/Migrations.test.ts @@ -0,0 +1,31 @@ +/** + * Migration registry-shape invariants. These tests are pure (no DB) and + * enforce the banded-id convention introduced alongside the set-difference + * runner: upstream ids occupy `1..999`, downstream-only ids occupy `>= 5000`. + */ + +import { describe, expect, it } from "vitest"; + +import { migrationEntries } from "./Migrations.ts"; + +const UPSTREAM_BAND_MIN = 1; +const UPSTREAM_BAND_MAX = 999; +const DOWNSTREAM_BAND_MIN = 5000; + +describe("migrationEntries", () => { + it("has no duplicate migration ids", () => { + const ids = migrationEntries.map(([id]) => id); + expect(new Set(ids).size).toBe(migrationEntries.length); + }); + + it("respects banded id ranges (upstream 1..999, downstream >= 5000)", () => { + for (const [id, name] of migrationEntries) { + const inUpstream = id >= UPSTREAM_BAND_MIN && id <= UPSTREAM_BAND_MAX; + const inDownstream = id >= DOWNSTREAM_BAND_MIN; + expect( + inUpstream || inDownstream, + `Migration ${id}_${name} is in the forbidden gap between upstream (1..${UPSTREAM_BAND_MAX}) and downstream (>= ${DOWNSTREAM_BAND_MIN})`, + ).toBe(true); + } + }); +}); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ba1131ee2597..962afec7679d 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -1,7 +1,7 @@ /** * MigrationsLive - Migration runner with inline loader * - * Uses Migrator.make with fromRecord to define migrations inline. + * Uses a custom set-difference runner over Migrator.fromRecord to define migrations inline. * All migrations are statically imported - no dynamic file system loading. * * Migrations run automatically when the MigrationLayer is provided, @@ -11,6 +11,9 @@ import * as Migrator from "effect/unstable/sql/Migrator"; import * as Layer from "effect/Layer"; import * as Effect from "effect/Effect"; +import { pipe } from "effect/Function"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; // Import all migrations statically import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; @@ -45,6 +48,7 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; +import Migration5001 from "./Migrations/5001_ProjectionTags.ts"; /** * Migration loader with all migrations defined inline. @@ -55,6 +59,18 @@ import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; * * Uses Migrator.fromRecord which parses the key format and * returns migrations sorted by ID. + * + * Banded migration id convention: + * - Upstream band: ids `1..999`. The next upstream id is `33`. Downstream files + * in this band are reserved for upstream merges only. + * - Downstream-only band: ids `>= 5000`. The first downstream-only migration + * is `5001_ProjectionTags`. The 4000-id gap is intentional headroom against + * upstream growth. + * + * The custom runner below uses set-difference semantics over the + * `effect_sql_migrations` tracking table so an applied downstream migration + * (e.g., `5001`) does not silently block a future upstream migration (e.g., + * `27`) from running. */ export const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -89,9 +105,10 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [5001, "ProjectionTags", Migration5001], ] as const; -export const makeMigrationLoader = (throughId?: number) => +export const makeMigrationLoader = (throughId?: number): Migrator.Loader => Migrator.fromRecord( Object.fromEntries( migrationEntries @@ -101,10 +118,201 @@ export const makeMigrationLoader = (throughId?: number) => ); /** - * Migrator run function - no schema dumping needed - * Uses the base Migrator.make without platform dependencies + * Build a Migrator-like runner that uses **set-difference** semantics over + * `effect_sql_migrations` instead of Effect's stock "max id" strategy. This + * mirrors `Migrator.make` from `effect/unstable/sql/Migrator.ts:74-296` + * line-for-line with two surgical changes: + * + * 1. Replace the single-row `latestMigration` query with a set-loading query: + * read **all** rows from `effect_sql_migrations` and build a `Set` + * of applied ids. + * 2. Replace `if (currentId <= latestMigrationId) continue` with + * `if (appliedIds.has(currentId)) continue`. + * + * The `effect_sql_migrations` schema is untouched; existing deployments adopt + * the new runner with no DB migration of the migration tracker itself. */ -const run = Migrator.make({}); +const makeRunner = + (): ((options: { + readonly loader: Migrator.Loader; + readonly table?: string; + }) => Effect.Effect< + ReadonlyArray, + Migrator.MigrationError | SqlError, + SqlClient.SqlClient + >) => + ({ loader, table = "effect_sql_migrations" }) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const ensureMigrationsTable = sql.onDialectOrElse({ + mssql: () => + sql`IF OBJECT_ID(N'${sql.literal(table)}', N'U') IS NULL + CREATE TABLE ${sql(table)} ( + migration_id INT NOT NULL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL DEFAULT GETDATE() + )`, + mysql: () => + sql`CREATE TABLE IF NOT EXISTS ${sql(table)} ( + migration_id INTEGER UNSIGNED NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + name VARCHAR(255) NOT NULL, + PRIMARY KEY (migration_id) +)`, + pg: () => + Effect.catch( + sql`select ${table}::regclass`, + () => + sql`CREATE TABLE ${sql(table)} ( + migration_id integer primary key, + created_at timestamp with time zone not null default now(), + name text not null +)`, + ), + orElse: () => + sql`CREATE TABLE IF NOT EXISTS ${sql(table)} ( + migration_id integer PRIMARY KEY NOT NULL, + created_at datetime NOT NULL DEFAULT current_timestamp, + name VARCHAR(255) NOT NULL +)`, + }); + + const insertMigrations = (rows: ReadonlyArray) => + sql`INSERT INTO ${sql(table)} ${sql.insert( + rows.map(([migration_id, name]) => ({ migration_id, name })), + )}`.withoutTransform; + + const loadAppliedIds = Effect.map( + sql<{ + readonly migration_id: number; + }>`SELECT migration_id FROM ${sql(table)}`.withoutTransform, + (rows) => new Set(rows.map((row) => row.migration_id)), + ); + + // Wrap each loaded migration into a typed effect that always fails with + // a `MigrationError` (success: void). This contains the upstream + // `Effect` shape from `Migrator.ResolvedMigration` + // at a single boundary so the rest of the runner stays free of `any`. + // The `unknown` error here is irreducible — migration files declare + // arbitrary failure types that we collapse into MigrationError below. + // @effect-diagnostics anyUnknownInErrorContext:off + const wrapMigration = ( + id: number, + name: string, + loadedEffect: Effect.Effect, + ): Effect.Effect => + loadedEffect.pipe( + Effect.asVoid, + Effect.mapError( + (error) => + new Migrator.MigrationError({ + cause: error, + kind: "Failed", + message: `Migration "${id}_${name}" failed`, + }), + ), + ); + + type WrappedMigration = readonly [ + id: number, + name: string, + effect: Effect.Effect, + ]; + + // === run + + const run = Effect.gen(function* () { + yield* sql.onDialectOrElse({ + pg: () => sql`LOCK TABLE ${sql(table)} IN ACCESS EXCLUSIVE MODE`, + orElse: () => Effect.void, + }); + + const [appliedIds, current] = yield* Effect.all([loadAppliedIds, loader]); + + if (new Set(current.map(([id]) => id)).size !== current.length) { + return yield* new Migrator.MigrationError({ + kind: "Duplicates", + message: "Found duplicate migration id's", + }); + } + + const required: Array = []; + + for (const resolved of current) { + const [currentId, currentName, load] = resolved; + if (appliedIds.has(currentId)) { + continue; + } + + // `load` is `Effect.succeed()` for our + // `fromRecord` loader; the inner value is the `Effect.Effect<...>` + // body of the migration. We yield once to extract it and then wrap + // it through `wrapMigration` to escape the upstream `any`/`unknown` + // error types in a single typed boundary. + const innerEffect = (yield* load) as Effect.Effect; + required.push([ + currentId, + currentName, + wrapMigration(currentId, currentName, innerEffect), + ] as const); + } + + if (required.length > 0) { + yield* pipe( + insertMigrations(required.map(([id, name]) => [id, name] as const)), + Effect.mapError((error): Migrator.MigrationError | SqlError => + error.reason._tag === "ConstraintError" + ? new Migrator.MigrationError({ + kind: "Locked", + message: "Migrations already running", + }) + : error, + ), + ); + } + + yield* Effect.forEach( + required, + ([id, name, effect]) => + Effect.logDebug(`Running migration`).pipe( + Effect.flatMap(() => Effect.orDie(effect)), + Effect.annotateLogs("migration_id", String(id)), + Effect.annotateLogs("migration_name", name), + Effect.withSpan(`Migrator ${id}_${name}`), + ), + { discard: true }, + ); + + yield* Effect.logDebug(`Migrations complete`).pipe( + Effect.annotateLogs("applied_count", String(appliedIds.size + required.length)), + ); + + return required.map(([id, name]) => [id, name] as const); + }); + + yield* ensureMigrationsTable; + + const completed = yield* pipe( + sql.withTransaction(run), + Effect.catchTag("MigrationError", (error) => + error.kind === "Locked" + ? Effect.as( + Effect.logDebug(error.message), + [] as ReadonlyArray, + ) + : Effect.fail(error), + ), + ); + + return completed; + }); + +/** + * Migrator run function with set-difference semantics over the + * `effect_sql_migrations` tracking table. + */ +const run = makeRunner(); export interface RunMigrationsOptions { readonly toMigrationInclusive?: number | undefined; @@ -114,7 +322,7 @@ export interface RunMigrationsOptions { * Run all pending migrations. * * Creates the migrations tracking table (effect_sql_migrations) if it doesn't exist, - * then runs any migrations with ID greater than the latest recorded migration. + * then runs any registered migration whose id is not yet present in that table. * * Returns array of [id, name] tuples for migrations that were run. * @@ -153,3 +361,10 @@ export const runMigrations = Effect.fn("runMigrations")(function* ({ * ``` */ export const MigrationsLive = Layer.effectDiscard(runMigrations()); + +/** + * Internal export for tests: build a runner and apply it to a custom loader, + * without going through the static `migrationEntries` registry. Tests use this + * to verify set-difference semantics with synthetic loaders. + */ +export const __runWithLoaderForTesting = (loader: Migrator.Loader) => run({ loader }); diff --git a/apps/server/src/persistence/Migrations/5001_ProjectionTags.test.ts b/apps/server/src/persistence/Migrations/5001_ProjectionTags.test.ts new file mode 100644 index 000000000000..49256f0d6162 --- /dev/null +++ b/apps/server/src/persistence/Migrations/5001_ProjectionTags.test.ts @@ -0,0 +1,121 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("5001_ProjectionTags", (it) => { + it.effect("creates projection_tags table with primary key and unique normalized name", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 26 }); + yield* runMigrations({ toMigrationInclusive: 5001 }); + + yield* sql` + INSERT INTO projection_tags ( + tag_id, + name, + name_normalized, + created_at, + updated_at + ) VALUES ( + 't1', + 'Foo', + 'foo', + '2026-04-01T00:00:00.000Z', + '2026-04-01T00:00:00.000Z' + ) + `; + + const rows = yield* sql<{ + readonly tagId: string; + readonly name: string; + readonly nameNormalized: string; + }>` + SELECT + tag_id AS "tagId", + name, + name_normalized AS "nameNormalized" + FROM projection_tags + `; + assert.deepStrictEqual(rows, [{ tagId: "t1", name: "Foo", nameNormalized: "foo" }]); + + const insertConflict = yield* Effect.exit( + sql` + INSERT INTO projection_tags ( + tag_id, + name, + name_normalized, + created_at, + updated_at + ) VALUES ( + 't2', + 'foo', + 'foo', + '2026-04-01T00:00:01.000Z', + '2026-04-01T00:00:01.000Z' + ) + `, + ); + assert.strictEqual(insertConflict._tag, "Failure"); + }), + ); + + it.effect("adds tags_json column to projection_projects with default '[]'", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 26 }); + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) VALUES ( + 'p1', + 'Project', + '/tmp/p1', + NULL, + '[]', + '2026-04-01T00:00:00.000Z', + '2026-04-01T00:00:00.000Z', + NULL + ) + `; + + yield* runMigrations({ toMigrationInclusive: 5001 }); + + const rows = yield* sql<{ + readonly tags_json: string; + }>` + SELECT tags_json FROM projection_projects WHERE project_id = 'p1' + `; + assert.deepStrictEqual(rows, [{ tags_json: "[]" }]); + }), + ); + + it.effect("is idempotent on re-run", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 5001 }); + yield* runMigrations({ toMigrationInclusive: 5001 }); + + const tableRows = yield* sql<{ + readonly name: string; + }>`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'projection_tags'`; + assert.strictEqual(tableRows.length, 1); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/5001_ProjectionTags.ts b/apps/server/src/persistence/Migrations/5001_ProjectionTags.ts new file mode 100644 index 000000000000..6df42e8b724b --- /dev/null +++ b/apps/server/src/persistence/Migrations/5001_ProjectionTags.ts @@ -0,0 +1,29 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_tags ( + tag_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + name_normalized TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_tags_updated_at + ON projection_tags(updated_at) + `; + + // Idempotent ALTER TABLE: SQLite throws if the column already exists, so we + // catch and turn it into a no-op. The DEFAULT '[]' ensures every existing + // row decodes through `Schema.fromJsonString(Schema.Array(TagId))`. + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN tags_json TEXT NOT NULL DEFAULT '[]' + `.pipe(Effect.catch(() => Effect.void)); +}); diff --git a/apps/server/src/persistence/MigrationsRunner.test.ts b/apps/server/src/persistence/MigrationsRunner.test.ts new file mode 100644 index 000000000000..2aae0e3da741 --- /dev/null +++ b/apps/server/src/persistence/MigrationsRunner.test.ts @@ -0,0 +1,141 @@ +/** + * Integration test for the custom set-difference migration runner. The + * runner under test reads the FULL set of applied migration ids from + * `effect_sql_migrations` and skips any registered migration whose id is in + * that set. This guarantees a downstream migration with a high id (e.g., + * `5001`) never silently blocks a future upstream migration with a low id + * (e.g., `27`) from running. + */ + +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Migrator from "effect/unstable/sql/Migrator"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { __runWithLoaderForTesting } from "./Migrations.ts"; +import * as NodeSqliteClient from "./NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +// `it.layer` from `@effect/vitest` builds the layer with `Effect.cached`, so +// the in-memory SQLite is shared across sibling `it.effect` blocks. Each test +// here seeds `effect_sql_migrations` from scratch, so we drop every user +// table before (re)creating the tracking table to guarantee isolation. +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; + for (const { name } of tables) { + yield* sql`DROP TABLE IF EXISTS ${sql(name)}`; + } + yield* sql` + CREATE TABLE effect_sql_migrations ( + migration_id integer PRIMARY KEY NOT NULL, + created_at datetime NOT NULL DEFAULT current_timestamp, + name VARCHAR(255) NOT NULL + ) + `; +}); + +const seedAppliedIds = (rows: ReadonlyArray) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + for (const [id, name] of rows) { + yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES (${id}, ${name})`; + } + }); + +const buildSyntheticLoader = ( + entries: ReadonlyArray, +): Migrator.Loader => + Effect.succeed( + entries.map(([id, name]) => { + const markerTable = `synthetic_marker_${id}`; + // The `unknown` error matches the shape of `Migrator.ResolvedMigration`'s + // load effect; the runner narrows this at its boundary. + // @effect-diagnostics anyUnknownInErrorContext:off + const create: Effect.Effect = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE IF NOT EXISTS ${sql(markerTable)} (id integer)`; + }); + return [id, name, Effect.succeed(create)] as const; + }), + ); + +const tableExists = (table: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ + readonly name: string; + }>`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${table}`.withoutTransform; + return rows.length === 1; + }); + +const listAppliedIds = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ + readonly migration_id: number; + }>`SELECT migration_id FROM effect_sql_migrations ORDER BY migration_id ASC`.withoutTransform; + return rows.map((row) => row.migration_id); +}); + +layer("MigrationsRunner — set-difference semantics", (it) => { + it.effect("runs a registered migration with id below the current max applied id", () => + Effect.gen(function* () { + // Seed: an environment that has already applied [1, 2, 5001]. + yield* resetMigrationsState; + yield* seedAppliedIds([ + [1, "InitialSchema"], + [2, "Followup"], + [5001, "DownstreamFeature"], + ]); + + // Loader registers id `3` (newer-than-2 in the upstream band) plus the + // pre-applied ids. The runner must run only `3`. + const loader = buildSyntheticLoader([ + [1, "InitialSchema"], + [2, "Followup"], + [3, "Synthetic"], + [5001, "DownstreamFeature"], + ]); + + const executed = yield* __runWithLoaderForTesting(loader); + + assert.deepStrictEqual( + executed.map(([id]) => id), + [3], + ); + assert.strictEqual(yield* tableExists("synthetic_marker_3"), true); + assert.strictEqual(yield* tableExists("synthetic_marker_1"), false); + assert.strictEqual(yield* tableExists("synthetic_marker_5001"), false); + assert.deepStrictEqual(yield* listAppliedIds, [1, 2, 3, 5001]); + }), + ); + + it.effect("is idempotent on re-run with the same loader", () => + Effect.gen(function* () { + yield* resetMigrationsState; + yield* seedAppliedIds([ + [1, "InitialSchema"], + [2, "Followup"], + [5001, "DownstreamFeature"], + ]); + + const loader = buildSyntheticLoader([ + [1, "InitialSchema"], + [2, "Followup"], + [3, "Synthetic"], + [5001, "DownstreamFeature"], + ]); + + yield* __runWithLoaderForTesting(loader); + const secondExecuted = yield* __runWithLoaderForTesting(loader); + + assert.deepStrictEqual(secondExecuted, []); + assert.deepStrictEqual(yield* listAppliedIds, [1, 2, 3, 5001]); + }), + ); +}); diff --git a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts index 1498984827e5..9a0f9fb6f832 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 * as Option from "effect/Option"; @@ -25,7 +26,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 5632205a2699..3e5e786a3550 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 * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; @@ -20,6 +20,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..9a59c9e0d790 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionTags.ts @@ -0,0 +1,84 @@ +/** + * 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 * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +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/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 75e76b5e8e29..c0921ea62a89 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -478,6 +478,81 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { }), ); + it.effect("resolves custom editors with {path} placeholder substitution", () => + Effect.gen(function* () { + const customEditors = [ + { + id: "nvim-ghostty", + name: "Neovim (Ghostty)", + command: ["ghostty", "-e", "nvim", "{path}"] as const, + }, + ]; + + const launch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "custom:nvim-ghostty" }, + "darwin", + { PATH: "" }, + customEditors, + ); + assert.deepEqual(launch, { + command: "ghostty", + args: ["-e", "nvim", "/tmp/workspace"], + }); + }), + ); + + it.effect("appends the target path when a custom editor command has no placeholder", () => + Effect.gen(function* () { + const customEditors = [{ id: "nvim", name: "Neovim", command: ["nvim"] as const }]; + + const launch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "custom:nvim" }, + "darwin", + { PATH: "" }, + customEditors, + ); + assert.deepEqual(launch, { + command: "nvim", + args: ["/tmp/workspace"], + }); + }), + ); + + it.effect("substitutes the placeholder in every argument containing it", () => + Effect.gen(function* () { + const customEditors = [ + { + id: "wezterm-nvim", + name: "Neovim (WezTerm)", + command: ["wezterm", "start", "--cwd={path}", "nvim", "{path}"] as const, + }, + ]; + + const launch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "custom:wezterm-nvim" }, + "linux", + { PATH: "" }, + customEditors, + ); + assert.deepEqual(launch, { + command: "wezterm", + args: ["start", "--cwd=/tmp/workspace", "nvim", "/tmp/workspace"], + }); + }), + ); + + it.effect("fails for custom editor ids without a matching definition", () => + Effect.gen(function* () { + const result = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "custom:missing" }, + "darwin", + { PATH: "" }, + [], + ).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }), + ); + it.effect("maps file-manager editor to OS open commands", () => Effect.gen(function* () { const launch1 = yield* resolveEditorLaunch( diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index da19864dcf81..730cbfbd204c 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -7,11 +7,14 @@ * @module ExternalLauncher */ import { + CUSTOM_EDITOR_PATH_PLACEHOLDER, EDITORS, ExternalLauncherError, + type CustomEditorDefinition, type EditorId, type LaunchEditorInput, } from "@t3tools/contracts"; +import { customEditorId, isCustomEditorId } from "@t3tools/shared/editors"; import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -109,6 +112,13 @@ function resolveEditorArgs( return [...baseArgs, ...resolveCommandEditorArgs(editor, target)]; } +function resolveCustomEditorLaunch(editor: CustomEditorDefinition, target: string): EditorLaunch { + const [command, ...args] = editor.command; + const hasPlaceholder = args.some((arg) => arg.includes(CUSTOM_EDITOR_PATH_PLACEHOLDER)); + const resolvedArgs = args.map((arg) => arg.replaceAll(CUSTOM_EDITOR_PATH_PLACEHOLDER, target)); + return { command, args: hasPlaceholder ? resolvedArgs : [...resolvedArgs, target] }; +} + function resolveAvailableCommand( commands: ReadonlyArray, options: CommandAvailabilityOptions = {}, @@ -249,8 +259,13 @@ export interface ExternalLauncherShape { * Launch a workspace path in a selected editor integration. * * Launches the editor as a detached process so server startup is not blocked. + * Custom editor ids are resolved against the caller-provided definitions + * (sourced from server settings). */ - readonly launchEditor: (input: LaunchEditorInput) => Effect.Effect; + readonly launchEditor: ( + input: LaunchEditorInput, + customEditors?: ReadonlyArray, + ) => Effect.Effect; } /** @@ -268,12 +283,26 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( input: LaunchEditorInput, platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, + customEditors: ReadonlyArray = [], ): Effect.fn.Return { yield* Effect.annotateCurrentSpan({ "externalLauncher.editor": input.editor, "externalLauncher.cwd": input.cwd, "externalLauncher.platform": platform, }); + if (isCustomEditorId(input.editor)) { + const requestedEditorId = input.editor; + const definition = customEditors.find( + (editor) => customEditorId(editor.id) === requestedEditorId, + ); + if (!definition) { + return yield* new ExternalLauncherError({ + message: `Unknown custom editor: ${input.editor}`, + }); + } + return resolveCustomEditorLaunch(definition, input.cwd); + } + const editorDef = EDITORS.find((editor) => editor.id === input.editor); if (!editorDef) { return yield* new ExternalLauncherError({ message: `Unknown editor: ${input.editor}` }); @@ -352,11 +381,13 @@ const make = Effect.gen(function* () { launchBrowser(target).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), - launchEditor: (input) => - Effect.flatMap(resolveEditorLaunch(input), (launch) => - launchEditorProcess(launch).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), + launchEditor: (input, customEditors) => + Effect.flatMap( + resolveEditorLaunch(input, process.platform, process.env, customEditors), + (launch) => + launchEditorProcess(launch).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), ), } satisfies ExternalLauncherShape; }); diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts index 051a7d20de00..c46c366e86a5 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts @@ -15,6 +15,7 @@ const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationPro workspaceRoot: "/repo/project", defaultModelSelection: null, scripts, + tags: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", deletedAt: null, @@ -39,6 +40,8 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), + listAllTags: () => Effect.succeed([]), + getTagById: () => Effect.succeed(Option.none()), }); describe("ProjectSetupScriptRunner", () => { diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 18e6166c1cdd..20f5c3999e1b 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -77,6 +77,7 @@ function makeReadModel( return { snapshotSequence: 0, updatedAt: now, + tags: [], projects: [ { id: projectId, @@ -84,6 +85,7 @@ function makeReadModel( workspaceRoot: "/tmp/provider-reaper-project", defaultModelSelection, scripts: [], + tags: [], createdAt: now, updatedAt: now, deletedAt: null, @@ -210,6 +212,8 @@ describe("ProviderSessionReaper", () => { : Option.none(), ), getThreadDetailById: () => Effect.die("unused"), + listAllTags: () => Effect.succeed([]), + getTagById: () => Effect.succeed(Option.none()), }), ), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index bbfbd236ad00..9b981bcb96a7 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -394,6 +394,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { repositoryIdentity: null, defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, } satisfies OrchestrationProjectShell; @@ -445,6 +446,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { snapshotSequence: 1, projects: [project], threads: [thread], + tags: [], updatedAt: now, } satisfies OrchestrationShellSnapshot), getThreadShellById: () => @@ -535,6 +537,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { repositoryIdentity: null, defaultModelSelection: null, scripts: [], + tags: [], createdAt: now, updatedAt: now, } satisfies OrchestrationProjectShell; @@ -615,6 +618,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { snapshotSequence: 1, projects: [project], threads: [thread], + tags: [], updatedAt: now, } satisfies OrchestrationShellSnapshot), getThreadShellById: () => Effect.succeed(Option.some(thread)), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0bf2f6589f0d..e2d10424c8c6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -162,6 +162,7 @@ const makeDefaultOrchestrationReadModel = () => { return { snapshotSequence: 0, updatedAt: now, + tags: [], projects: [ { id: defaultProjectId, @@ -169,6 +170,7 @@ const makeDefaultOrchestrationReadModel = () => { workspaceRoot: "/tmp/default-project", defaultModelSelection, scripts: [], + tags: [], createdAt: now, updatedAt: now, deletedAt: null, @@ -367,7 +369,9 @@ const buildAppUnderTest = (options?: { }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const tempBaseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" }); + const tempBaseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-router-test-", + }); const baseDir = options?.config?.baseDir ?? tempBaseDir; const devUrl = options?.config?.devUrl; const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); @@ -557,7 +561,10 @@ const buildAppUnderTest = (options?: { refreshInstance: () => Effect.succeed([]), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), + makeManualOnlyProviderMaintenanceCapabilities({ + provider, + packageName: null, + }), ), setProviderMaintenanceActionState: () => Effect.succeed([]), streamChanges: Stream.empty, @@ -680,6 +687,7 @@ const buildAppUnderTest = (options?: { snapshotSequence: 0, projects: [], threads: [], + tags: [], updatedAt: "1970-01-01T00:00:00.000Z", }), getArchivedShellSnapshot: () => @@ -687,6 +695,7 @@ const buildAppUnderTest = (options?: { snapshotSequence: 0, projects: [], threads: [], + tags: [], updatedAt: "1970-01-01T00:00:00.000Z", }), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), @@ -697,6 +706,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, }), ), @@ -1226,7 +1237,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-static-" }); + const staticDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-router-static-", + }); const indexPath = path.join(staticDir, "index.html"); yield* fileSystem.writeFileString(indexPath, "router-static-ok"); @@ -3839,7 +3852,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); assert.isNotNull(attachmentPath, "Attachment path should be resolvable"); - yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); + yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { + recursive: true, + }); yield* fileSystem.writeFileString(attachmentPath, "attachment-ok"); const response = yield* HttpClient.get(`/attachments/${attachmentId}`, { @@ -3864,7 +3879,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); assert.isNotNull(attachmentPath, "Attachment path should be resolvable"); - yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); + yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { + recursive: true, + }); yield* fileSystem.writeFileString(attachmentPath, "attachment-encoded-ok"); const response = yield* HttpClient.get( @@ -4261,7 +4278,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-auth-required-" }); + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-auth-required-", + }); yield* fs.writeFileString( path.join(workspaceDir, "needle-file.ts"), "export const needle = 1;", @@ -4437,7 +4456,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { version: 1 as const, sequence: 2, type: "ready" as const, - payload: { at: "2026-01-01T00:00:00.000Z", environment: testEnvironmentDescriptor }, + payload: { + at: "2026-01-01T00:00:00.000Z", + environment: testEnvironmentDescriptor, + }, }); yield* buildAppUnderTest({ @@ -4471,7 +4493,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-search-" }); + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-project-search-", + }); yield* fs.writeFileString( path.join(workspaceDir, "needle-file.ts"), "export const needle = 1;", @@ -4504,12 +4528,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { prefix: "t3-ws-project-search-gitignored-", }); yield* fs.writeFileString(path.join(workspaceDir, ".gitignore"), ".venv/\n"); - yield* fs.makeDirectory(path.join(workspaceDir, ".venv", "lib"), { recursive: true }); + yield* fs.makeDirectory(path.join(workspaceDir, ".venv", "lib"), { + recursive: true, + }); yield* fs.writeFileString( path.join(workspaceDir, ".venv", "lib", "ignored-search-target.ts"), "export const ignored = true;", ); - yield* fs.makeDirectory(path.join(workspaceDir, "src"), { recursive: true }); + yield* fs.makeDirectory(path.join(workspaceDir, "src"), { + recursive: true, + }); yield* fs.writeFileString( path.join(workspaceDir, "src", "tracked.ts"), "export const ok = 1;", @@ -4581,7 +4609,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-write-" }); + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-project-write-", + }); yield* buildAppUnderTest(); @@ -4606,7 +4636,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-create-" }); + const parentDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-project-create-", + }); const missingWorkspaceRoot = path.join(parentDir, "nested", "new-project"); yield* buildAppUnderTest(); @@ -4639,7 +4671,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc projects.writeFile errors", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-write-" }); + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-project-write-", + }); yield* buildAppUnderTest(); @@ -5394,6 +5428,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const snapshot = { snapshotSequence: 1, updatedAt: now, + tags: [], projects: [ { id: ProjectId.make("project-a"), @@ -5401,6 +5436,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { workspaceRoot: "/tmp/project-a", defaultModelSelection, scripts: [], + tags: [], createdAt: now, updatedAt: now, deletedAt: null, @@ -5567,6 +5603,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", }, @@ -5772,7 +5809,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectionSnapshotQuery: { getThreadShellById: () => Effect.succeed( - Option.some(makeDefaultOrchestrationThreadShell({ id: threadId, session: null })), + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + session: null, + }), + ), ), }, }, diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 90eebe338201..d01cf2acf17a 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -103,6 +103,8 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getFullThreadDiffContext: () => 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, @@ -154,6 +156,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, @@ -165,6 +168,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), + listAllTags: () => Effect.succeed([]), + getTagById: () => Effect.succeed(Option.none()), }), Effect.provideService(OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -207,6 +212,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), + listAllTags: () => Effect.succeed([]), + getTagById: () => Effect.succeed(Option.none()), }), Effect.provideService(OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -255,6 +262,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), + listAllTags: () => Effect.succeed([]), + getTagById: () => Effect.succeed(Option.none()), }), Effect.provideService(OrchestrationEngineService, { readEvents: () => Stream.empty, diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index c06a0bfc5604..2528c2640bc0 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -297,6 +297,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + prTemplate: input.prTemplate, }); const generated = yield* runClaudeJson({ @@ -319,6 +320,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, + username: input.username, }); const generated = yield* runClaudeJson({ diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index d42fb07aa034..e20bca76a3d9 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -334,6 +334,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + prTemplate: input.prTemplate, }); const generated = yield* runCodexJson({ @@ -360,6 +361,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, + username: input.username, }); const generated = yield* runCodexJson({ diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index c4ef1af21d10..9f1c8b19315d 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -211,6 +211,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + prTemplate: input.prTemplate, }); const generated = yield* runCursorJson({ @@ -233,6 +234,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, + username: input.username, }); const generated = yield* runCursorJson({ diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index b865b2e5ef57..73135ac7f131 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -401,6 +401,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + prTemplate: input.prTemplate, }); const generated = yield* runOpenCodeJson({ operation: "generatePrContent", @@ -422,6 +423,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, + username: input.username, }); const generated = yield* runOpenCodeJson({ operation: "generateBranchName", diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index d5d28e638ed1..e13620bbd0c6 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -37,6 +37,7 @@ export interface PrContentGenerationInput { commitSummary: string; diffSummary: string; diffPatch: string; + prTemplate?: string | undefined; /** What model and provider to use for generation. */ modelSelection: ModelSelection; } @@ -50,6 +51,7 @@ export interface BranchNameGenerationInput { cwd: string; message: string; attachments?: ReadonlyArray | undefined; + username?: string | undefined; /** What model and provider to use for generation. */ modelSelection: ModelSelection; } diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 6015e83b5d46..ae994abab2c5 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -85,6 +85,7 @@ export interface PrContentPromptInput { commitSummary: string; diffSummary: string; diffPatch: string; + prTemplate?: string | undefined; policy?: TextGenerationPolicy | undefined; } @@ -110,6 +111,9 @@ export function buildPrContentPrompt(input: PrContentPromptInput) { "", "Diff patch:", limitSection(input.diffPatch, 40_000), + ...(input.prTemplate + ? ["", "PR Template (follow this structure for the body):", input.prTemplate] + : []), ].join("\n"); const outputSchema = Schema.Struct({ @@ -127,6 +131,7 @@ export function buildPrContentPrompt(input: PrContentPromptInput) { export interface BranchNamePromptInput { message: string; attachments?: ReadonlyArray | undefined; + username?: string | undefined; policy?: TextGenerationPolicy | undefined; } @@ -166,13 +171,15 @@ function buildPromptFromMessage(input: PromptFromMessageInput): string { } export function buildBranchNamePrompt(input: BranchNamePromptInput) { + const userSegment = input.username ? `/${input.username}` : ""; const prompt = buildPromptFromMessage({ instruction: "You generate concise git branch names.", responseShape: "Return a JSON object with key: branch.", rules: [ - "Branch should describe the requested work from the user message.", - "Keep it short and specific (2-6 words).", - "Use plain words only, no issue prefixes and no punctuation-heavy text.", + "Branch must start with a type prefix: feature, fix, chore, docs, refactor, or test.", + `Format: ${userSegment}/`, + "Slug should describe the requested work in 2-5 words.", + "Use plain lowercase words only, no issue numbers, no punctuation beyond hyphens and slashes.", "If images are attached, use them as primary context for visual/UI issues.", ], message: input.message, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0f2a8f790bfb..b62e1f27878e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -478,6 +478,18 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => 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 "thread.unarchived": return projectionSnapshotQuery.getThreadShellById(event.payload.threadId).pipe( Effect.map((thread) => @@ -489,6 +501,14 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ), Effect.orElseSucceed(() => 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()); @@ -1167,9 +1187,22 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => { "rpc.aggregate": "workspace" }, ), [WS_METHODS.shellOpenInEditor]: (input) => - observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), { - "rpc.aggregate": "workspace", - }), + observeRpcEffect( + WS_METHODS.shellOpenInEditor, + serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new ExternalLauncher.ExternalLauncherError({ + message: "Failed to load custom editor settings", + cause, + }), + ), + Effect.flatMap((settings) => + externalLauncher.launchEditor(input, settings.customEditors), + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.filesystemBrowse]: (input) => observeRpcEffect( WS_METHODS.filesystemBrowse, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 5a92a244c526..8bf7f59f9057 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -379,11 +379,13 @@ function createSnapshotForTargetUser(options: { model: "gpt-5", }, scripts: [], + tags: [], createdAt: NOW_ISO, updatedAt: NOW_ISO, deletedAt: null, }, ], + tags: [], threads: [ { id: THREAD_ID, @@ -862,6 +864,7 @@ function createSnapshotWithSecondaryProject(options?: { workspaceRoot: "/repo/clients/docs-portal", defaultModelSelection: { instanceId: ProviderInstanceId.make("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 13bd175e0c99..eedae4950216 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -334,8 +334,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/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6ef644b2b225..bcca5ae716e5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -12,6 +12,7 @@ import { type ServerProvider, type ResolvedKeybindingsConfig, type ScopedThreadRef, + type TagId, type ThreadId, type TurnId, type KeybindingCommand, @@ -75,6 +76,7 @@ import { } from "../pendingUserInput"; import { selectProjectsAcrossEnvironments, + selectTagsAcrossEnvironments, selectThreadsAcrossEnvironments, useStore, } from "../store"; @@ -92,12 +94,15 @@ import { MAX_TERMINALS_PER_GROUP, type ChatMessage, type SessionPhase, + type Tag, type Thread, type TurnDiffSummary, } from "../types"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useCommandPaletteStore } from "../commandPaletteStore"; +import { useTagCreateDialogStore } from "../tagCreateDialogStore"; +import { toggleProjectTagAssignment } from "./Sidebar.logic"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; @@ -2412,6 +2417,53 @@ export default function ChatView(props: ChatViewProps) { [activeProject, persistProjectScripts], ); + const allTags = useStore(useShallow(selectTagsAcrossEnvironments)); + const availableTags = useMemo( + () => + activeProject + ? allTags.filter((tag) => tag.environmentId === activeProject.environmentId) + : [], + [allTags, activeProject], + ); + const handleToggleProjectTag = useCallback( + async (tagId: TagId, _nextChecked: boolean) => { + if (!activeProject) return; + const api = readEnvironmentApi(activeProject.environmentId); + if (!api) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to update tags for "${activeProject.name}"`, + description: "Project API unavailable.", + }), + ); + return; + } + const nextTagIds = toggleProjectTagAssignment(activeProject.tags, tagId); + try { + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId: activeProject.id, + tags: nextTagIds, + }); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to update tags for "${activeProject.name}"`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [activeProject], + ); + const openTagCreateDialog = useTagCreateDialogStore((s) => s.open); + const handleCreateProjectTag = useCallback(() => { + openTagCreateDialog(); + }, [openTagCreateDialog]); + const handleRuntimeModeChange = useCallback( (mode: RuntimeMode) => { if (mode === runtimeMode) return; @@ -3851,6 +3903,8 @@ export default function ChatView(props: ChatViewProps) { preferredScriptId={ activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null } + activeProjectTags={activeProject?.tags} + availableTags={availableTags} keybindings={keybindings} availableEditors={availableEditors} terminalAvailable={activeProject !== undefined} @@ -3863,6 +3917,8 @@ export default function ChatView(props: ChatViewProps) { onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} onDeleteProjectScript={deleteProjectScript} + onToggleProjectTag={handleToggleProjectTag} + onCreateProjectTag={handleCreateProjectTag} onToggleTerminal={toggleTerminalVisibility} onToggleDiff={onToggleDiff} /> diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index b7aa6d7a645b..eb464cf41366 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -184,11 +184,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/ProjectTagsControl.tsx b/apps/web/src/components/ProjectTagsControl.tsx new file mode 100644 index 000000000000..78053ce47ae9 --- /dev/null +++ b/apps/web/src/components/ProjectTagsControl.tsx @@ -0,0 +1,78 @@ +import { PlusIcon, TagsIcon } from "lucide-react"; +import { useMemo } from "react"; +import { type TagId } from "@t3tools/contracts"; + +import { cn } from "../lib/utils"; +import type { Tag } from "../types"; +import { Button } from "./ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; +import { Toggle } from "./ui/toggle"; + +interface ProjectTagsControlProps { + assignedTagIds: readonly TagId[]; + availableTags: readonly Tag[]; + onToggleTag: (tagId: TagId, nextChecked: boolean) => void | Promise; + onCreateTag: () => void; +} + +export default function ProjectTagsControl({ + assignedTagIds, + availableTags, + onToggleTag, + onCreateTag, +}: ProjectTagsControlProps) { + const assignedSet = useMemo(() => new Set(assignedTagIds), [assignedTagIds]); + const hasTags = availableTags.length > 0; + + return ( + + + + + Tags + + + } + /> + +
+ {availableTags.map((tag) => { + const pressed = assignedSet.has(tag.id); + return ( + { + void onToggleTag(tag.id, nextPressed); + }} + aria-pressed={pressed} + data-testid={`chat-header-project-tag-toggle-${tag.id}`} + 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} + + ); + })} + +
+ {!hasTags &&

No tags yet.

} +
+
+ ); +} 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 bdbbf6f84914..1b03bc0c0822 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -3,6 +3,7 @@ import { ProviderDriverKind } from "@t3tools/contracts"; import { createThreadJumpHintVisibilityController, + filterProjectSnapshotsByTags, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, @@ -20,14 +21,18 @@ import { shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, + toggleProjectTagAssignment, + toggleTagFilterSelection, } from "./Sidebar.logic"; import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ProviderInstanceId, + TagId, ThreadId, } from "@t3tools/contracts"; +import type { SidebarProjectSnapshot } from "../sidebarProjectGrouping"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -712,6 +717,7 @@ function makeProject(overrides: Partial = {}): Project { createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:00:00.000Z", scripts: [], + tags: [], ...rest, }; } @@ -969,3 +975,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 b9dd27dfb039..83de18e02610 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 dc5acaaadc71..cc8bc160cdf2 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"; @@ -64,12 +65,13 @@ import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { isMacPlatform, newCommandId } from "../lib/utils"; +import { isMacPlatform, newCommandId, randomUUID } from "../lib/utils"; import { selectProjectByRef, selectProjectsAcrossEnvironments, selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, + selectTagsAcrossEnvironments, selectThreadByRef, useStore, } from "../store"; @@ -162,6 +164,7 @@ import { getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, + filterProjectSnapshotsByTags, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, @@ -170,16 +173,21 @@ 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 { SidebarTagFilter } from "./SidebarTagFilter"; +import { SidebarUpdatePill } from "./SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; +import { useTagCreateDialogStore } from "../tagCreateDialogStore"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, @@ -190,7 +198,7 @@ import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; -import type { SidebarThreadSummary } from "../types"; +import type { SidebarThreadSummary, Tag } from "../types"; import { buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectSnapshots, @@ -924,6 +932,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) { @@ -944,6 +956,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec suppressProjectClickForContextMenuRef, isManualProjectSorting, dragHandleProps, + onOpenEditTagsForMember, } = props; const threadSortOrder = useSettings( (settings) => settings.sidebarThreadSortOrder, @@ -1514,19 +1527,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"), + editTagsItem, buildTargetedItem("grouping", "Group into..."), buildTargetedItem("copy-path", "Copy Path"), buildTargetedItem("delete", "Remove", { destructive: true, }), ], - { - x: event.clientX, - y: event.clientY, - }, + clickPosition, ); if (!clicked) { @@ -1539,6 +1581,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [ copyPathToClipboard, handleRemoveProject, + onOpenEditTagsForMember, openProjectGroupingDialog, openProjectRenameDialog, project.groupedProjectCount, @@ -2488,8 +2531,10 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ Code - + {APP_STAGE_LABEL} +
+ fork
} @@ -2552,6 +2597,17 @@ interface SidebarProjectsContentProps { threadPreviewCount: SidebarThreadPreviewCount; 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; @@ -2593,6 +2649,14 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( threadPreviewCount, updateSettings, openAddProject, + tagsForSidebar, + selectedTagIds, + onTagFilterToggle, + onClearTagFilter, + onOpenTagCreateDialog, + onRenameTag, + onDeleteTag, + onOpenEditTagsForMember, isManualProjectSorting, projectDnDSensors, projectCollisionDetection, @@ -2692,6 +2756,17 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( ) : null} + + +
@@ -2765,6 +2840,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( } isManualProjectSorting={isManualProjectSorting} dragHandleProps={dragHandleProps} + onOpenEditTagsForMember={onOpenEditTagsForMember} /> )} @@ -2795,6 +2871,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef} isManualProjectSorting={isManualProjectSorting} dragHandleProps={null} + onOpenEditTagsForMember={onOpenEditTagsForMember} /> ))} @@ -2812,10 +2889,209 @@ 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 = useTagCreateDialogStore((s) => s.isOpen); + const openTagCreateDialogStore = useTagCreateDialogStore((s) => s.open); + const closeTagCreateDialogStore = useTagCreateDialogStore((s) => s.close); + const [tagCreateName, setTagCreateName] = useState(""); + const [tagRenameTarget, setTagRenameTarget] = useState(null); + const [tagRenameName, setTagRenameName] = useState(""); + const [editTagsAnchor, setEditTagsAnchor] = useState<{ + physicalProjectKey: string; + 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({ + physicalProjectKey: member.physicalProjectKey, + x: position.x, + y: position.y, + }); + }, + [], + ); + const handleCloseEditTags = useCallback(() => { + setEditTagsAnchor(null); + }, []); + const openTagCreateDialog = useCallback(() => { + setTagCreateName(""); + openTagCreateDialogStore(); + }, [openTagCreateDialogStore]); + const closeTagCreateDialog = useCallback(() => { + closeTagCreateDialogStore(); + setTagCreateName(""); + }, [closeTagCreateDialogStore]); + // Reset name on every open transition so external triggers (e.g. the chat-header + // Tags popover) start with an empty input even when they open the dialog directly + // via the store. + useEffect(() => { + if (tagCreateDialogOpen) { + setTagCreateName(""); + } + }, [tagCreateDialogOpen]); + 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(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 navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const isOnSettings = pathname.startsWith("/settings"); @@ -2902,6 +3178,57 @@ export default function Sidebar() { () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), [sidebarProjects], ); + const editTagsMember = useMemo(() => { + if (!editTagsAnchor) return null; + for (const snapshot of sidebarProjects) { + const found = snapshot.memberProjects.find( + (m) => m.physicalProjectKey === editTagsAnchor.physicalProjectKey, + ); + if (found) return found; + } + return null; + }, [editTagsAnchor, sidebarProjects]); + useEffect(() => { + if (editTagsAnchor !== null && editTagsMember === null) { + setEditTagsAnchor(null); + } + }, [editTagsAnchor, editTagsMember]); + const handleToggleProjectTagAssignment = useCallback( + async (tagId: TagId, _nextChecked: boolean) => { + if (!editTagsMember) { + return; + } + const nextTagIds = toggleProjectTagAssignment(editTagsMember.tags, tagId); + const api = readEnvironmentApi(editTagsMember.environmentId); + if (!api) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to update tags for "${editTagsMember.name}"`, + description: "Project API unavailable.", + }), + ); + return; + } + try { + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId: editTagsMember.id, + tags: nextTagIds, + }); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to update tags for "${editTagsMember.name}"`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [editTagsMember], + ); const sidebarThreadByKey = useMemo( () => new Map( @@ -3063,10 +3390,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( @@ -3092,6 +3419,7 @@ export default function Sidebar() { sidebarProjectByKey, sidebarProjects, visibleThreads, + selectedTagIds, ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; const visibleSidebarThreadKeys = useMemo( @@ -3456,6 +3784,14 @@ export default function Sidebar() { threadPreviewCount={sidebarThreadPreviewCount} 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} @@ -3486,6 +3822,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 && editTagsMember !== 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/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 2520b3cb2528..50cb14366ff0 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -3,6 +3,7 @@ import { type EditorId, type ProjectScript, type ResolvedKeybindingsConfig, + type TagId, type ThreadId, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime"; @@ -13,8 +14,10 @@ import { DiffIcon, TerminalSquareIcon } from "lucide-react"; import { Badge } from "../ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; +import ProjectTagsControl from "../ProjectTagsControl"; import { Toggle } from "../ui/toggle"; import { SidebarTrigger } from "../ui/sidebar"; +import type { Tag } from "../../types"; import { OpenInPicker } from "./OpenInPicker"; import { usePrimaryEnvironmentId } from "../../environments/primary"; @@ -28,6 +31,8 @@ interface ChatHeaderProps { openInCwd: string | null; activeProjectScripts: ProjectScript[] | undefined; preferredScriptId: string | null; + activeProjectTags: readonly TagId[] | undefined; + availableTags: readonly Tag[]; keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; terminalAvailable: boolean; @@ -40,6 +45,8 @@ interface ChatHeaderProps { onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; onDeleteProjectScript: (scriptId: string) => Promise; + onToggleProjectTag: (tagId: TagId, nextChecked: boolean) => void | Promise; + onCreateProjectTag: () => void; onToggleTerminal: () => void; onToggleDiff: () => void; } @@ -66,6 +73,8 @@ export const ChatHeader = memo(function ChatHeader({ openInCwd, activeProjectScripts, preferredScriptId, + activeProjectTags, + availableTags, keybindings, availableEditors, terminalAvailable, @@ -78,6 +87,8 @@ export const ChatHeader = memo(function ChatHeader({ onAddProjectScript, onUpdateProjectScript, onDeleteProjectScript, + onToggleProjectTag, + onCreateProjectTag, onToggleTerminal, onToggleDiff, }: ChatHeaderProps) { @@ -120,6 +131,14 @@ export const ChatHeader = memo(function ChatHeader({ )}
+ {activeProjectTags !== undefined && ( + + )} {activeProjectScripts && ( ) => { +const resolveOptions = ( + platform: string, + availableEditors: ReadonlyArray, + customEditors: ReadonlyArray, +) => { const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ { label: "Cursor", @@ -147,7 +158,15 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray availableEditorSet.has(option.value)); + const customOptions = customEditors.map(({ id, name }) => ({ + label: name, + Icon: SquareTerminalIcon as Icon, + value: customEditorId(id), + })); + return [ + ...baseOptions.filter((option) => availableEditorSet.has(option.value)), + ...customOptions, + ]; }; export const OpenInPicker = memo(function OpenInPicker({ @@ -159,10 +178,15 @@ export const OpenInPicker = memo(function OpenInPicker({ availableEditors: ReadonlyArray; openInCwd: string | null; }) { - const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); + const customEditors = useServerCustomEditors(); + const selectableEditors = useMemo( + () => selectableEditorIds(availableEditors, customEditors), + [availableEditors, customEditors], + ); + const [preferredEditor, setPreferredEditor] = usePreferredEditor(selectableEditors); const options = useMemo( - () => resolveOptions(navigator.platform, availableEditors), - [availableEditors], + () => resolveOptions(navigator.platform, availableEditors, customEditors), + [availableEditors, customEditors], ); const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; @@ -172,7 +196,13 @@ export const OpenInPicker = memo(function OpenInPicker({ if (!api || !openInCwd) return; const editor = editorId ?? preferredEditor; if (!editor) return; - void api.shell.openInEditor(openInCwd, editor); + void api.shell.openInEditor(openInCwd, editor).catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Unable to open editor", + description: error instanceof Error ? error.message : "Unknown error opening editor.", + }); + }); setPreferredEditor(editor); }, [preferredEditor, openInCwd, setPreferredEditor], @@ -185,17 +215,15 @@ export const OpenInPicker = memo(function OpenInPicker({ useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { - const api = readLocalApi(); if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; - if (!api || !openInCwd) return; - if (!preferredEditor) return; + if (!openInCwd || !preferredEditor) return; e.preventDefault(); - void api.shell.openInEditor(openInCwd, preferredEditor); + openInEditor(preferredEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [preferredEditor, keybindings, openInCwd]); + }, [preferredEditor, keybindings, openInCwd, openInEditor]); return ( diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 3a36e2a51e50..e15a748862ef 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -18,9 +18,13 @@ import * as Option from "effect/Option"; import { ensureLocalApi } from "../../localApi"; import { cn } from "../../lib/utils"; -import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; +import { resolveAndPersistPreferredEditor, selectableEditorIds } from "../../editorPreferences"; import { formatRelativeTime } from "../../timestampFormat"; -import { useServerAvailableEditors, useServerObservability } from "../../rpc/serverState"; +import { + useServerAvailableEditors, + useServerCustomEditors, + useServerObservability, +} from "../../rpc/serverState"; import { useProcessDiagnostics, useProcessResourceHistory, @@ -805,6 +809,7 @@ function DiagnosticsRefreshButton({ export function DiagnosticsSettingsPanel() { const observability = useServerObservability(); const availableEditors = useServerAvailableEditors(); + const customEditors = useServerCustomEditors(); const [resourceWindowMs, setResourceWindowMs] = useState(15 * 60_000); const selectedResourceWindow = RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ?? @@ -833,7 +838,9 @@ export function DiagnosticsSettingsPanel() { const logsDirectoryPath = observability?.logsDirectoryPath ?? null; if (!logsDirectoryPath) return; - const editor = resolveAndPersistPreferredEditor(availableEditors ?? []); + const editor = resolveAndPersistPreferredEditor( + selectableEditorIds(availableEditors ?? [], customEditors), + ); if (!editor) { setOpenLogsDirectoryError("No available editors found."); return; @@ -851,7 +858,7 @@ export function DiagnosticsSettingsPanel() { .finally(() => { setIsOpeningLogsDirectory(false); }); - }, [availableEditors, observability?.logsDirectoryPath]); + }, [availableEditors, customEditors, observability?.logsDirectoryPath]); const isInitialLoading = isPending && data === null; const isProcessInitialLoading = isProcessPending && processData === null; diff --git a/apps/web/src/editorPreferences.ts b/apps/web/src/editorPreferences.ts index 38c59115a55d..35cbdfc8d74c 100644 --- a/apps/web/src/editorPreferences.ts +++ b/apps/web/src/editorPreferences.ts @@ -1,34 +1,46 @@ import { EDITORS, EditorId, LocalApi } from "@t3tools/contracts"; +import { selectableEditorIds } from "@t3tools/shared/editors"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; import { useMemo } from "react"; +export { selectableEditorIds }; + const LAST_EDITOR_KEY = "t3code:last-editor"; -export function usePreferredEditor(availableEditors: ReadonlyArray) { +function fallbackEditor(selectableEditors: ReadonlyArray): EditorId | null { + return ( + EDITORS.find((editor) => selectableEditors.includes(editor.id))?.id ?? + selectableEditors[0] ?? + null + ); +} + +export function usePreferredEditor(selectableEditors: ReadonlyArray) { const [lastEditor, setLastEditor] = useLocalStorage(LAST_EDITOR_KEY, null, EditorId); const effectiveEditor = useMemo(() => { - if (lastEditor && availableEditors.includes(lastEditor)) return lastEditor; - return EDITORS.find((editor) => availableEditors.includes(editor.id))?.id ?? null; - }, [lastEditor, availableEditors]); + if (lastEditor && selectableEditors.includes(lastEditor)) return lastEditor; + return fallbackEditor(selectableEditors); + }, [lastEditor, selectableEditors]); return [effectiveEditor, setLastEditor] as const; } export function resolveAndPersistPreferredEditor( - availableEditors: readonly EditorId[], + selectableEditors: ReadonlyArray, ): EditorId | null { - const availableEditorIds = new Set(availableEditors); const stored = getLocalStorageItem(LAST_EDITOR_KEY, EditorId); - if (stored && availableEditorIds.has(stored)) return stored; - const editor = EDITORS.find((editor) => availableEditorIds.has(editor.id))?.id ?? null; + if (stored && selectableEditors.includes(stored)) return stored; + const editor = fallbackEditor(selectableEditors); if (editor) setLocalStorageItem(LAST_EDITOR_KEY, editor, EditorId); - return editor ?? null; + return editor; } export async function openInPreferredEditor(api: LocalApi, targetPath: string): Promise { - const { availableEditors } = await api.server.getConfig(); - const editor = resolveAndPersistPreferredEditor(availableEditors); + const { availableEditors, settings } = await api.server.getConfig(); + const editor = resolveAndPersistPreferredEditor( + selectableEditorIds(availableEditors, settings.customEditors), + ); if (!editor) throw new Error("No available editors found."); await api.shell.openInEditor(targetPath, editor); return editor; diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index ae879c671f5c..1f37273af104 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 675a48680328..48d7c6853e58 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -188,6 +188,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 e50dbd9f5f8a..a7ca232d8122 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -415,6 +415,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/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 88283d451c3a..5424b1e54e1c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -28,7 +28,7 @@ import { ToastProvider, toastManager, } from "../components/ui/toast"; -import { resolveAndPersistPreferredEditor } from "../editorPreferences"; +import { resolveAndPersistPreferredEditor, selectableEditorIds } from "../editorPreferences"; import { readLocalApi } from "../localApi"; import { useSettings } from "../hooks/useSettings"; import { @@ -384,7 +384,9 @@ function EventRouter() { void Promise.resolve(serverConfig ?? api.server.getConfig()) .then((config) => { - const editor = resolveAndPersistPreferredEditor(config.availableEditors); + const editor = resolveAndPersistPreferredEditor( + selectableEditorIds(config.availableEditors, config.settings.customEditors), + ); if (!editor) { throw new Error("No available editors found."); } diff --git a/apps/web/src/rpc/serverState.ts b/apps/web/src/rpc/serverState.ts index 64bc2d80e5ae..46cb7b743833 100644 --- a/apps/web/src/rpc/serverState.ts +++ b/apps/web/src/rpc/serverState.ts @@ -1,6 +1,7 @@ import { useAtomSubscribe, useAtomValue } from "@effect/atom-react"; import { DEFAULT_SERVER_SETTINGS, + type CustomEditorDefinition, type EditorId, type ServerConfig, type ServerConfigStreamEvent, @@ -43,10 +44,13 @@ function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdated } const EMPTY_AVAILABLE_EDITORS: ReadonlyArray = []; +const EMPTY_CUSTOM_EDITORS: ReadonlyArray = []; const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; const selectAvailableEditors = (config: ServerConfig | null): ReadonlyArray => config?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; +const selectCustomEditors = (config: ServerConfig | null): ReadonlyArray => + config?.settings.customEditors ?? EMPTY_CUSTOM_EDITORS; const selectKeybindings = (config: ServerConfig | null) => config?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; const selectKeybindingsConfigPath = (config: ServerConfig | null) => @@ -284,6 +288,10 @@ export function useServerAvailableEditors(): ReadonlyArray { return useAtomValue(serverConfigAtom, selectAvailableEditors); } +export function useServerCustomEditors(): ReadonlyArray { + return useAtomValue(serverConfigAtom, selectCustomEditors); +} + export function useServerKeybindingsConfigPath(): string | null { return useAtomValue(serverConfigAtom, selectKeybindingsConfigPath); } 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 cad78ab9d357..171b46ffc9f3 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -101,6 +101,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], @@ -110,6 +111,8 @@ function makeState(thread: Thread): AppState { projectById: { [projectId]: project, }, + tagIds: [], + tagById: {}, threadIds: [thread.id], threadIdsByProjectId, threadShellById: { @@ -185,6 +188,8 @@ function makeEmptyState(overrides: Partial = {}): A const environmentState: EnvironmentState = { projectIds: [], projectById: {}, + tagIds: [], + tagById: {}, threadIds: [], threadIdsByProjectId: {}, threadShellById: {}, @@ -504,6 +509,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:00.000Z", updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], + tags: [], }, }, }); @@ -519,6 +525,7 @@ describe("incremental orchestration updates", () => { model: DEFAULT_MODEL, }, scripts: [], + tags: [], createdAt: "2026-02-27T00:00:01.000Z", updatedAt: "2026-02-27T00:00:01.000Z", }), @@ -559,6 +566,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:00.000Z", updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], + tags: [], }, [recreatedProjectId]: { id: recreatedProjectId, @@ -572,6 +580,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 7d995b5ea751..37dd42287b4a 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 { isProviderDriverKind, ProviderDriverKind } 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,12 +40,17 @@ import { import { resolveEnvironmentHttpUrl } from "./environments/runtime"; import { sanitizeThreadErrorMessage } from "./rpc/transportError"; import { getThreadFromEnvironmentState } from "./threadDerivation"; +import { useUiStateStore } from "./uiStateStore"; + const isProviderDriverKindValue = Schema.is(ProviderDriverKind); export interface EnvironmentState { projectIds: ProjectId[]; projectById: Record; + tagIds: TagId[]; + tagById: Record; + // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): // Web still stores shell snapshots and thread details in this denormalized // Zustand shape. Mobile uses createShellSnapshotManager and @@ -104,6 +112,8 @@ export interface AppState { const initialEnvironmentState: EnvironmentState = { projectIds: [], projectById: {}, + tagIds: [], + tagById: {}, threadIds: [], threadIdsByProjectId: {}, threadShellById: {}, @@ -233,6 +243,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, }; } @@ -1050,6 +1071,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, @@ -1087,10 +1115,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: {}, @@ -1172,6 +1202,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, @@ -1236,6 +1267,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 { @@ -1705,6 +1737,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), + }; + } } } @@ -1759,6 +1823,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/tagCreateDialogStore.ts b/apps/web/src/tagCreateDialogStore.ts new file mode 100644 index 000000000000..0392aebe4ad7 --- /dev/null +++ b/apps/web/src/tagCreateDialogStore.ts @@ -0,0 +1,13 @@ +import { create } from "zustand"; + +interface TagCreateDialogStore { + isOpen: boolean; + open: () => void; + close: () => void; +} + +export const useTagCreateDialogStore = create((set) => ({ + isOpen: false, + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false }), +})); diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index c2e4b235e214..31c05e96fa80 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, @@ -91,6 +92,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 c6f445b0c329..7557b935f3a5 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -25,6 +25,7 @@ function makeUiState(overrides: Partial = {}): UiState { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, + projectTagFilter: { selectedTagIds: [] }, ...overrides, }; } diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index f16495bed7fe..0d4aa19cd1de 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"; @@ -21,6 +22,7 @@ export interface PersistedUiState { projectOrderCwds?: string[]; defaultAdvertisedEndpointKey?: string | null; threadChangedFilesExpandedById?: Record>; + projectTagFilterSelectedTagIds?: string[]; } export interface UiProjectState { @@ -37,7 +39,9 @@ export interface UiEndpointState { defaultAdvertisedEndpointKey: string | null; } -export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {} +export interface UiState extends UiProjectState, UiThreadState, UiEndpointState { + projectTagFilter: { selectedTagIds: TagId[] }; +} export interface SyncProjectInput { /** Physical project key (env + cwd). Used for manual sort order. */ @@ -58,6 +62,7 @@ const initialState: UiState = { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, + projectTagFilter: { selectedTagIds: [] }, }; const persistedCollapsedProjectCwds = new Set(); @@ -103,6 +108,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; @@ -195,6 +205,7 @@ export function persistState(state: UiState): void { projectOrderCwds, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpandedById, + projectTagFilterSelectedTagIds: state.projectTagFilter.selectedTagIds.map((tagId) => tagId), } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -633,6 +644,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; @@ -647,6 +686,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) => ({ @@ -667,6 +708,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/docs/nix-packaging.md b/docs/nix-packaging.md new file mode 100644 index 000000000000..5dc13cbc347f --- /dev/null +++ b/docs/nix-packaging.md @@ -0,0 +1,358 @@ +# Nix packaging for the `kevinher7/t3code` fork + +> **Status:** CLI **builds and runs on `aarch64-darwin`** (`t3 serve` boots: +> migrations run, port binds, `node-pty` + `node:sqlite` both work). The +> `aarch64-darwin` FOD hash is filled in. Still TODO: the `x86_64-linux` FOD +> hash (build on the server) and the desktop hash (needs a published release). +> Resumable by a fresh agent — read this top to bottom. +> +> **Audience:** the maintainer of the `personal` branch on `kevinher7/t3code`, +> who runs a NixOS homelab + a nix-darwin MacBook and wants T3 Code installed +> declaratively (no Homebrew cask, no AppImage-by-hand). + +--- + +## 1. Goal + +Two consumers, both in a separate `nixos-config` flake (NOT this repo): + +| Host | Platform | What it needs | Why | +| -------------------- | ---------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------- | +| `uribo-btw` (server) | `x86_64-linux` | headless **`t3 serve`** behind nginx at `t3code.uribogoat.duckdns.org` (port 3773) as a systemd service | self-hosted web GUI for coding agents | +| `kebee` (MacBook) | `aarch64-darwin` | the **desktop app** (`T3 Code (Alpha).app`) | local GUI | + +Constraint: **we run our own fork**, so the Homebrew cask (`brew install --cask t3-code`) +and upstream release artifacts are off the table — everything must come from +`kevinher7/t3code`. + +--- + +## 2. Investigation findings (so we don't relitigate them) + +- **Upstream PR #2734 ("Add a flake.nix for NixOS Flake users")** does _not_ + build from source despite its description. It `fetchurl`s a prebuilt + **x86_64-linux AppImage** from `pingdotgg` releases and wraps it with + `appimage-run`. It exposes only `packages.x86_64-linux.default`, has **no CLI** + and **no macOS**. Useful only as a reference for the AppImage-wrapping trick. +- **The fork has no published GitHub releases** (`gh release list --repo +kevinher7/t3code` is empty). The local `release/` dir has locally-built mac + arm64 `.dmg`/`.zip`, but those are not fetchable URLs and `release/` is + gitignored. +- **The CLI (`apps/server`, package name `t3`, bin → `dist/bin.mjs`) is a Node + script, not the desktop GUI.** The server needs this, not the Electron app. +- **CLI build flow:** `turbo run build --filter=t3` builds `@t3tools/web` first + (it's a workspace dep of `t3`), then runs `apps/server`'s build + (`node scripts/cli.ts build` → `tsdown` bundle + copy `apps/web/dist` → + `apps/server/dist/client`). +- **`tsdown` only inlines internal `@t3tools/*` packages** (`noExternal`); every + npm dependency stays external, so the runtime needs the full `node_modules` + shipped next to `dist/bin.mjs`. +- **Native / runtime gotchas:** `node-pty` is a native addon loaded at runtime + via `import("node-pty")` (ESM resolution walks up from `dist/bin.mjs`, so + `node_modules` must be an ancestor dir — NODE_PATH won't help). + `@effect/sql-sqlite-bun` falls back to Node's built-in `node:sqlite` under + Node. +- **Desktop artifact naming** (electron-builder effective config): + `artifactName = "T3-Code-${version}-${arch}.${ext}"`, `productName = "T3 Code (Alpha)"`, + `appId = com.t3tools.t3code`. mac targets: `dmg` + `zip`. +- **nix-darwin integration:** `nixos-config` host `kebee` already has an + `activationScripts.aliasApplications` step that links + `~/Applications/Home Manager Apps/*.app` into `~/Applications`. So a darwin + derivation that drops `*.app` into `$out/Applications` integrates with **no + Homebrew cask** — satisfies the constraint. + +--- + +## 3. Decisions (locked) + +1. **Hybrid sourcing.** Build the **CLI from source** (so the server self-rebuilds + on fork changes); **fetch the prebuilt desktop** app (building Electron + + mac code-signing from source in Nix is not worth it). +2. **Server delivery = dedicated `t3` CLI package** (not the desktop app's + bundled server). +3. **Publish releases on the fork** and have the flake fetch the desktop `.zip`. +4. **No Linux desktop output for now** — only the CLI is used on Linux. + (Deferred; see §7.) +5. **No Homebrew cask.** + +--- + +## 4. Current state — `flake.nix` (this repo root) + +The CLI **builds and runs on `aarch64-darwin`**; the `aarch64-darwin` FOD hash +is filled in. Two hashes remain `lib.fakeHash`: `bunDepsHashes.x86_64-linux` +(build on the server) and `desktopHashes.aarch64-darwin` (needs a release). + +Outputs: + +| Attr | `x86_64-linux` | `aarch64-darwin` | +| ------------------------------------- | ------------------------ | -------------------------- | +| `packages..t3-cli` (= `default`) | ✅ built from source | ✅ built from source | +| `packages.aarch64-darwin.desktop` | — (intentionally absent) | ✅ fetched `.zip` → `.app` | +| `apps..default` | `t3` | `t3` | + +Build design: + +- **`nodeModules`** — a _fixed-output derivation_ running + `bun install --frozen-lockfile` (FOD ⇒ network allowed; output hashed; + system-specific because native addons differ). Toolchain provided: + `bun`, `nodejs_24`, `python3`, `pkg-config`. +- **CLI derivation** — offline; copies `nodeModules`, runs + `turbo run build --filter=t3 --no-daemon`, installs `dist/` + full + `node_modules` under `$out/libexec/t3code`, and `makeWrapper`s + `$out/bin/t3 → node …/dist/bin.mjs` (adds `nodejs` to PATH for child procs). +- **Desktop (darwin)** — `fetchurl` the `.zip`, `unzip`, copy `*.app` to + `$out/Applications`. + +### Maintaining the flake (per release) + +`flake.nix` keeps comments minimal; this is the canonical maintenance list. +When you cut a new release on `kevinher7/t3code`, update: + +1. **`version`** — e.g. `"0.0.25"` (drives both the desktop URL and the tag). +2. **`desktopHashes.aarch64-darwin`** — hash of the fetched desktop artifact: + ```bash + nix store prefetch-file --json \ + https://github.com/kevinher7/t3code/releases/download/v0.0.25/T3-Code-0.0.25-arm64.zip + ``` +3. **`bunDepsHashes.`** — hash of the FOD `node_modules`, one per system. + Set the slot to `lib.fakeHash`, run `nix build .#t3-cli` **on that system / + builder**, and paste the `got:` hash Nix prints. The linux hash must be built + on a linux machine — it can't be produced from the Mac. +4. **`flake.lock`** — `nix flake update` to refresh nixpkgs. + +### Bug fixes applied to get the CLI building (2026-05-31) + +The flake "evaluated" but had never been built. Building it surfaced four real +problems, all now fixed in `flake.nix` (these are bun-specific and unavoidable — +see "Why not the reference repo's approach" below): + +1. **FOD had no CA bundle.** `electron`'s postinstall download failed with + `unable to get local issuer certificate`. Fixed by adding `pkgs.cacert` + + `SSL_CERT_FILE`/`NODE_EXTRA_CA_CERTS`, **and** setting + `ELECTRON_SKIP_BINARY_DOWNLOAD=1` (the CLI never uses Electron, so the + download was both failing and pointless). +2. **turbo couldn't find its package manager.** The offline build derivation + was missing `bun` (turbo shells out to the declared `packageManager`). Added + `pkgs.bun` to the CLI derivation's `nativeBuildInputs`. +3. **Per-workspace `node_modules` were dropped.** bun's **isolated linker** + creates a `node_modules` dir inside _every_ workspace package (`apps/*`, + `packages/*`, `scripts`) with relative symlinks into the root `.bun` virtual + store. The FOD originally saved only the root `node_modules`, so + `apps/web/node_modules/.bin/vite` vanished and the web build failed. Fixed: + the FOD now captures every `node_modules` dir at its relative path, and the + offline build restores them all. +4. **Runtime module resolution was flattened wrong.** tsdown leaves npm deps + external; `bin.mjs` resolves them by walking up from its own dir. The install + originally put `dist/` next to the _root_ `node_modules`, but `effect` / + `node-pty` live in `apps/server/node_modules` (relative symlinks into root + `.bun`). Fixed by reproducing the real layout — + `apps/server/dist/bin.mjs` → `apps/server/node_modules` → root + `node_modules/.bun` — and dropping the dangling `@t3tools/*` workspace + symlinks (tsdown inlines those packages, so they're unused at runtime). + +### Things that must still be filled in / verified + +The flake's top-of-file `MAINTENANCE NOTES` block lists these too. + +1. **`bunDepsHashes.aarch64-darwin`** — ✅ captured + (`sha256-OWHMBirGRbmEO6ASo06jm0Fn1m4yTkKItHaEnUhoSSw=`). + **`bunDepsHashes.x86_64-linux`** — still `fakeHash`. Run `nix build .#t3-cli` + **on the server (or a linux builder)** — it can't be built from the Mac — and + paste the printed `got:` hash. +2. **`desktopHashes.aarch64-darwin`** — needs a published release first (§5), + then `nix store prefetch-file `. +3. **Risk: FOD reproducibility.** `bun install` may not be byte-reproducible. If + the hash won't stabilize across builds, switch the `nodeModules` step to + **`bun2nix`** (per-package hashing) — adds a flake input + a generated file + but removes FOD nondeterminism. (This is the bun-native equivalent of the + reference repo's `importNpmLock`; see below.) +4. **~~Risk: `node-pty` native build~~** — ✅ resolved. node-pty builds in the + FOD with the existing toolchain (`python3` + `pkg-config`) and loads at + runtime; `t3 serve` confirmed working. +5. **~~Risk: `node:sqlite` under `nodejs_24`~~** — ✅ resolved. Migrations run on + boot with **no extra flag** — the `--experimental-sqlite` wrapper flag is + **not** needed on `nodejs_24`. + +### Why not the reference repo's approach (`Sawrz/t3code-nix`) + +A community flake (`github:Sawrz/t3code-nix`) packages **upstream** +`pingdotgg/t3code`. Worth knowing what does and doesn't transfer: + +- **Desktop:** same pattern we use — `fetchurl` a prebuilt `.zip`/`.AppImage` + and extract the `.app`. Confirms our desktop approach; only the source repo + differs (we fetch from the fork, per §3). +- **CLI:** it uses nixpkgs' **`importNpmLock`** (reads `package-lock.json`, + fetches each tarball reproducibly with no FOD/manual hash) and + `dontNpmBuild = true` (repackages a prebuilt npm artifact, no source build). + **This does not transfer to our fork:** the fork is bun-based (`bun.lock`, + catalog deps, `patchedDependencies`, turbo workspace) — there is no + `package-lock.json` for `importNpmLock` to read — and decision §3 requires + building the CLI _from source_ (turbo → tsdown), which `dontNpmBuild` skips. + The reproducible-without-FOD goal is real, but the bun-native tool for it is + **bun2nix** (item 3 above), not `importNpmLock`. + +### Quick verification commands + +```bash +cd ~/Projects/t3code +nix flake lock +nix eval --raw .#packages.aarch64-darwin.t3-cli.name # darwin CLI +nix eval --raw .#packages.aarch64-darwin.desktop.name # darwin desktop +nix eval --raw .#packages.x86_64-linux.t3-cli.name # linux CLI +# real build (fills FOD hash on failure message): +nix build .#t3-cli # run on the matching system / builder +``` + +--- + +## 5. Release CI — publishing desktop artifacts from the fork + +### Why NOT reuse the existing `release.yml` + +The inherited `.github/workflows/release.yml` is tag-driven and does far more +than we need: mac/linux/win matrix, **npm publish**, **Vercel deploy**, +**Discord announce**, a **GitHub App token** (`RELEASE_APP_ID` / +`RELEASE_APP_PRIVATE_KEY`), Apple/Azure **signing secrets**, and a `finalize` +job that **commits a version bump to `main`**. On the fork those secrets don't +exist (jobs fail/skip) and we explicitly want **`main` untouched** (it tracks +upstream). Don't reuse it. + +### The plan: a slim, dedicated workflow + +Create `.github/workflows/release-fork.yml` that: + +- **Triggers on push to `personal`** (+ `workflow_dispatch` for manual runs). +- Builds **macOS arm64 only**, **unsigned**, on a **GitHub-hosted `macos-14`** + runner (free for public repos — no Blacksmith). +- Computes a unique, semver-valid version and tag, then publishes a **GitHub + Release** with the `.zip` (and `.dmg`) attached, using the default + `GITHUB_TOKEN` (needs `permissions: contents: write`). + +Versioning scheme (keeps the `T3-Code-${version}-${arch}.${ext}` filename and +makes each release unique + monotonic): + +- `build-version = -fork.` (e.g. `0.0.24-fork.7`) +- `tag = v` (e.g. `v0.0.24-fork.7`) +- artifact = `T3-Code-0.0.24-fork.7-arm64.zip` + +> The flake's desktop pin (`version` + `desktopHashes`) is updated **manually** +> per the maintenance notes, so the desktop release cadence and flake updates +> are decoupled — auto-publish often, bump the flake when you want a newer GUI. + +Draft (review before committing — auto-creating releases on every push is an +outward-facing automation; confirm cadence first): + +```yaml +name: Release (fork) +on: + push: + branches: [personal] + workflow_dispatch: + +permissions: + contents: write + +jobs: + desktop: + runs-on: macos-14 # GitHub-hosted arm64; free for public repos + timeout-minutes: 40 + steps: + - uses: actions/checkout@v6 + with: { fetch-depth: 0 } + - uses: oven-sh/setup-bun@v2 + with: { bun-version-file: package.json } + - uses: actions/setup-node@v6 + with: { node-version-file: package.json } + - run: bun install --frozen-lockfile + + - id: meta + run: | + base=$(node -p "require('./apps/desktop/package.json').version") + version="${base}-fork.${{ github.run_number }}" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=v$version" >> "$GITHUB_OUTPUT" + + # Build mac arm64 dmg+zip, UNSIGNED (script disables CSC auto-discovery + # when Apple secrets are absent). + - run: | + bun run dist:desktop:artifact -- \ + --platform mac --target dmg --arch arm64 \ + --build-version "${{ steps.meta.outputs.version }}" --verbose + + - uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.meta.outputs.tag }} + target_commitish: ${{ github.sha }} + name: T3 Code ${{ steps.meta.outputs.version }} (fork) + prerelease: true + generate_release_notes: true + files: | + release/*.zip + release/*.dmg + fail_on_unmatched_files: true + token: ${{ secrets.GITHUB_TOKEN }} +``` + +**Caveats to verify when this is enabled:** + +- **Unsigned Gatekeeper:** the `.app` is unsigned. Even installed via Nix + (fetchurl doesn't set the quarantine xattr), first launch may be blocked — + clear with `xattr -dr com.apple.quarantine ""` or allow in System + Settings → Privacy & Security. If this is painful, add Apple Developer ID + secrets later and pass `--signed`. +- **macOS runner minutes** are limited on private repos; this fork appears + public, so they're free within fair-use limits. +- Confirm the `--build-version` value flows into the artifact filename exactly + as `T3-Code--arm64.zip` (electron-builder requires valid semver; + `-fork.N` is a valid prerelease identifier). + +--- + +## 6. Wiring into `nixos-config` (separate repo, not done yet) + +This is the original homelab plan; unchanged except the package source is now +this flake. Files in `~/nixos-config`: + +| File | Change | +| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `flake.nix` | add input `t3code.url = "github:kevinher7/t3code"; inputs.nixpkgs.follows = "nixpkgs";` | +| `modules/services/t3code.nix` (new) | `options.myHomelab.t3code.{enable,port}` (port default 3773); `systemd.services.t3code` running `${inputs.t3code.packages.${system}.t3-cli}/bin/t3 serve --host 127.0.0.1 --port ` | +| `modules/services/default.nix` | import `./t3code.nix` | +| `modules/services/nginx-proxy.nix` | add `t3code.${domain}` to ACME `extraDomainNames` + vhost proxying `127.0.0.1:3773` with WebSocket upgrade headers | +| `modules/services/homepage.nix` | dashboard entry + `siteMonitor` | +| `hosts/server/default.nix` | `myHomelab.t3code.enable = true;` | +| `home/hosts/macbook.nix` (or a darwin module) | add `inputs.t3code.packages.${pkgs.system}.desktop` to `home.packages` so the `.app` is aliased into `~/Applications` | + +No DNS changes needed: Pi-hole `dnsmasq_lines` already resolves +`*.uribogoat.duckdns.org` to the Tailscale IP, and the DuckDNS wildcard cert +covers the new subdomain once added to `extraDomainNames`. + +--- + +## 7. Deferred work + +- **Linux desktop output (AppImage + `appimage-run`).** Not built — only the CLI + is used on Linux today. **TODO: open a tracking issue** on `kevinher7/t3code` + ("Add `packages.x86_64-linux.desktop` AppImage output to flake"). Pattern is + the PR #2734 trick: `fetchurl` the `T3-Code--x86_64.AppImage`, wrap + with `appimage-run`, and have the fork CI also build the linux target. When + re-adding, restore `mkDesktopLinux` + the `x86_64-linux` entries in + `desktopHashes` and the `desktop` system map in `flake.nix`. + +--- + +## 8. Resume checklist + +- [x] Build CLI on `aarch64-darwin`; `bunDepsHashes.aarch64-darwin` captured. +- [ ] Build CLI on `x86_64-linux` (server/linux builder); capture + `bunDepsHashes.x86_64-linux`. +- [ ] (If FOD won't stabilize) migrate `nodeModules` to `bun2nix`. +- [x] Smoke-test `t3 serve` from the built CLI — boots clean on darwin + (migrations run, port binds, `node-pty` + `node:sqlite` OK). Re-verify on + linux after that build. +- [ ] Add `.github/workflows/release-fork.yml` (§5); push to `personal`; confirm + a release with the `.zip` appears. +- [ ] Fill `desktopHashes.aarch64-darwin` from the published `.zip`. +- [ ] Wire into `nixos-config` (§6). +- [ ] Open the Linux-desktop tracking issue (§7). diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000000..407024209a29 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1779560665, + "narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000000..a30175834b43 --- /dev/null +++ b/flake.nix @@ -0,0 +1,144 @@ +{ + description = "T3 Code — prebuilt CLI + desktop app"; + + # Releases are automated: push a tag (e.g. `git tag v0.0.24-fork.3 && git push + # origin v0.0.24-fork.3`) and `.github/workflows/nix-release.yml` builds the + # artifacts, computes their hashes, and commits the updated `nix-hashes.json` + # back to `personal`. Do not edit `nix-hashes.json` by hand. + # See docs/ci-auto-hash-plan.md. + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = { + self, + nixpkgs, + }: let + lib = nixpkgs.lib; + + owner = "kevinher7"; + repo = "t3code"; + + # Version + artifact hashes are written by CI on tag push (see header comment). + release = builtins.fromJSON (builtins.readFile ./nix-hashes.json); + version = release.version; + releaseTag = "v${version}"; + + artifactUrl = name: "https://github.com/${owner}/${repo}/releases/download/${releaseTag}/${name}"; + + hashes = {inherit (release) cli desktop;}; + + systems = [ + "x86_64-linux" + "aarch64-darwin" + ]; + + forAllSystems = lib.genAttrs systems; + pkgsFor = system: import nixpkgs {inherit system;}; + + mkCli = system: let + pkgs = pkgsFor system; + nodejs = pkgs.nodejs_24; + in + pkgs.stdenvNoCC.mkDerivation { + pname = "t3code-cli"; + inherit version; + + src = pkgs.fetchurl { + url = artifactUrl "t3code-cli-${system}.tar.gz"; + hash = hashes.cli.${system}; + }; + + nativeBuildInputs = [pkgs.makeWrapper]; + sourceRoot = "."; + + installPhase = '' + runHook preInstall + mkdir -p $out/libexec/t3code + # The release tarball is a pruned `pnpm deploy --prod` tree: the bundled + # CLI output (dist/bin.mjs) plus only the runtime dependency closure, + # with node_modules/, dist/, and package.json at the tarball root. + cp -R node_modules dist package.json $out/libexec/t3code/ + # Drop the workspace self-reference symlink (and any other dangling + # links); the prod runtime symlinks into .pnpm are relative and survive. + find $out/libexec/t3code -xtype l -delete 2>/dev/null || true + + makeWrapper ${nodejs}/bin/node $out/bin/t3 \ + --add-flags "$out/libexec/t3code/dist/bin.mjs" \ + --prefix PATH : ${lib.makeBinPath [nodejs]} + runHook postInstall + ''; + + meta = { + description = "T3 Code CLI"; + homepage = "https://t3.codes"; + license = lib.licenses.mit; + mainProgram = "t3"; + platforms = [system]; + }; + }; + + mkDesktopDarwin = system: let + pkgs = pkgsFor system; + in + pkgs.stdenvNoCC.mkDerivation { + pname = "t3code-desktop"; + inherit version; + + src = pkgs.fetchurl { + url = artifactUrl "T3-Code-${version}-arm64.zip"; + hash = hashes.desktop.${system}; + }; + + nativeBuildInputs = [pkgs.unzip]; + sourceRoot = "."; + unpackPhase = "unzip -q $src"; + + installPhase = '' + runHook preInstall + mkdir -p "$out/Applications" + cp -R *.app "$out/Applications/" + runHook postInstall + ''; + + meta = { + description = "T3 Code desktop app (macOS, prebuilt)"; + homepage = "https://t3.codes"; + license = lib.licenses.mit; + platforms = ["aarch64-darwin"]; + }; + }; + in { + packages = forAllSystems ( + system: let + cli = mkCli system; + desktop = {aarch64-darwin = mkDesktopDarwin;}.${system} or null; + in + { + t3-cli = cli; + default = cli; + } + // lib.optionalAttrs (desktop != null) { + desktop = desktop system; + } + ); + + apps = forAllSystems (system: { + default = { + type = "app"; + program = "${self.packages.${system}.t3-cli}/bin/t3"; + }; + }); + + devShells = forAllSystems (system: let + pkgs = pkgsFor system; + in { + default = pkgs.mkShell { + packages = with pkgs; [bun nodejs turbo]; + }; + }); + + formatter = forAllSystems (system: (pkgsFor system).nixfmt-rfc-style); + }; +} diff --git a/nix-hashes.json b/nix-hashes.json new file mode 100644 index 000000000000..b31f52ff6d9d --- /dev/null +++ b/nix-hashes.json @@ -0,0 +1,10 @@ +{ + "version": "0.0.27-fork.1", + "cli": { + "x86_64-linux": "sha256-Nl19yVyKxWnHNoxL7mRyl5wjj2q8HMoTc6u/OomJ9q0=", + "aarch64-darwin": "sha256-WTM4qx+p5J1AsnTmWVrHU1PdfUKNchAzCcmg0hQwbIw=" + }, + "desktop": { + "aarch64-darwin": "sha256-bkUbINruD1VMy2+2STg5rnu6kCHgGC2fR6eN/NOWkRs=" + } +} diff --git a/packages/client-runtime/src/addProject.test.ts b/packages/client-runtime/src/addProject.test.ts index fb665996a98c..d4e029acf5b4 100644 --- a/packages/client-runtime/src/addProject.test.ts +++ b/packages/client-runtime/src/addProject.test.ts @@ -103,6 +103,7 @@ describe("add project shared logic", () => { repositoryIdentity: null, defaultModelSelection: null, scripts: [], + tags: [], }, { environmentId: env, @@ -114,6 +115,7 @@ describe("add project shared logic", () => { repositoryIdentity: null, defaultModelSelection: null, scripts: [], + tags: [], }, ]; diff --git a/packages/client-runtime/src/archivedThreadsState.test.ts b/packages/client-runtime/src/archivedThreadsState.test.ts index 3a819fa30b91..43328250f083 100644 --- a/packages/client-runtime/src/archivedThreadsState.test.ts +++ b/packages/client-runtime/src/archivedThreadsState.test.ts @@ -22,6 +22,7 @@ function createSnapshot(id: string): OrchestrationShellSnapshot { snapshotSequence: 1, projects: [], threads: [], + tags: [], updatedAt: `2026-05-08T00:00:00.000Z`, id, } as OrchestrationShellSnapshot; diff --git a/packages/client-runtime/src/shellSnapshotReducer.test.ts b/packages/client-runtime/src/shellSnapshotReducer.test.ts index 69ae5e5d69fe..0ad987c968c4 100644 --- a/packages/client-runtime/src/shellSnapshotReducer.test.ts +++ b/packages/client-runtime/src/shellSnapshotReducer.test.ts @@ -9,6 +9,7 @@ const baseSnapshot: OrchestrationShellSnapshot = { snapshotSequence: 0, projects: [], threads: [], + tags: [], updatedAt: "2026-04-01T00:00:00.000Z", }; @@ -19,6 +20,7 @@ const stubProject = { repositoryIdentity: null, defaultModelSelection: null, scripts: [], + tags: [], createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", } as const; diff --git a/packages/client-runtime/src/shellSnapshotState.test.ts b/packages/client-runtime/src/shellSnapshotState.test.ts index f7adfee63883..d342aaaab2a1 100644 --- a/packages/client-runtime/src/shellSnapshotState.test.ts +++ b/packages/client-runtime/src/shellSnapshotState.test.ts @@ -21,6 +21,7 @@ function resetAtomRegistry() { const BASE_SNAPSHOT: OrchestrationShellSnapshot = { snapshotSequence: 1, updatedAt: "2026-04-01T00:00:00.000Z", + tags: [], projects: [ { id: ProjectId.make("project-1"), @@ -29,6 +30,7 @@ const BASE_SNAPSHOT: OrchestrationShellSnapshot = { repositoryIdentity: null, defaultModelSelection: null, scripts: [], + tags: [], createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", }, diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 614ea5131fbc..8997d23056c8 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -58,3 +58,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/editor.ts b/packages/contracts/src/editor.ts index c180cf242944..6e4626111e9d 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -41,9 +41,51 @@ export const EDITORS = [ { id: "file-manager", label: "File Manager", commands: null, launchStyle: "direct-path" }, ] as const satisfies ReadonlyArray; -export const EditorId = Schema.Literals(EDITORS.map((e) => e.id)); +export const BuiltinEditorId = Schema.Literals(EDITORS.map((e) => e.id)); +export type BuiltinEditorId = typeof BuiltinEditorId.Type; + +export const MAX_CUSTOM_EDITOR_ID_LENGTH = 32; +export const MAX_CUSTOM_EDITORS_COUNT = 32; + +/** + * Placeholder replaced with the target path when launching a custom editor. + * When no command argument contains it, the target path is appended instead. + */ +export const CUSTOM_EDITOR_PATH_PLACEHOLDER = "{path}"; + +export const CUSTOM_EDITOR_ID_PREFIX = "custom:"; + +export const CustomEditorSlug = Schema.NonEmptyString.check( + Schema.isMaxLength(MAX_CUSTOM_EDITOR_ID_LENGTH), + Schema.isPattern(/^[a-z0-9][a-z0-9-]*$/), +); +export type CustomEditorSlug = typeof CustomEditorSlug.Type; + +export const CustomEditorId = Schema.TemplateLiteral([ + Schema.Literal(CUSTOM_EDITOR_ID_PREFIX), + CustomEditorSlug, +]); +export type CustomEditorId = typeof CustomEditorId.Type; + +export const EditorId = Schema.Union([BuiltinEditorId, CustomEditorId]); export type EditorId = typeof EditorId.Type; +/** + * User-defined editor launched via an arbitrary command, e.g. a terminal + * editor wrapped in a terminal emulator: `["ghostty", "-e", "nvim", "{path}"]`. + */ +export const CustomEditorDefinition = Schema.Struct({ + id: CustomEditorSlug, + name: TrimmedNonEmptyString, + command: Schema.NonEmptyArray(TrimmedNonEmptyString), +}); +export type CustomEditorDefinition = typeof CustomEditorDefinition.Type; + +export const CustomEditorsConfig = Schema.Array(CustomEditorDefinition).check( + Schema.isMaxLength(MAX_CUSTOM_EDITORS_COUNT), +); +export type CustomEditorsConfig = typeof CustomEditorsConfig.Type; + export const LaunchEditorInput = Schema.Struct({ cwd: TrimmedNonEmptyString, editor: EditorId, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 218d0de74375..fc7b99add419 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -16,6 +16,7 @@ import { NonNegativeInt, ProjectId, ProviderItemId, + TagId, ThreadId, TrimmedNonEmptyString, TurnId, @@ -153,6 +154,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, @@ -197,6 +206,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, @@ -204,6 +221,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), @@ -360,6 +378,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; @@ -371,6 +392,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, }); @@ -403,6 +425,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; @@ -428,6 +453,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; @@ -470,6 +505,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({ @@ -479,6 +515,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, @@ -662,6 +722,9 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, + TagCreateCommand, + TagRenameCommand, + TagDeleteCommand, ]); export type DispatchableClientOrchestrationCommand = typeof DispatchableClientOrchestrationCommand.Type; @@ -683,6 +746,9 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, + TagCreateCommand, + TagRenameCommand, + TagDeleteCommand, ]); export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; @@ -791,10 +857,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"]); @@ -805,6 +874,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, }); @@ -816,6 +886,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, }); @@ -824,6 +895,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, @@ -978,7 +1070,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), @@ -1097,6 +1189,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; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 33781f56c949..81bc868fb715 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -3,6 +3,7 @@ import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { CustomEditorsConfig } from "./editor.ts"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts"; import { ModelSelection } from "./orchestration.ts"; import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts"; @@ -374,6 +375,10 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + // User-defined "Open in" editors (e.g. nvim wrapped in a terminal + // emulator). Edited by hand in settings.json; the file watcher hot-reloads + // changes and streams them to clients via the server config subscription. + customEditors: CustomEditorsConfig.pipe(Schema.withDecodingDefault(Effect.succeed([]))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( Effect.succeed({ diff --git a/packages/shared/package.json b/packages/shared/package.json index 97af1fa58404..46bb70c17621 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -39,6 +39,10 @@ "types": "./src/shell.ts", "import": "./src/shell.ts" }, + "./editors": { + "types": "./src/editors.ts", + "import": "./src/editors.ts" + }, "./semver": { "types": "./src/semver.ts", "import": "./src/semver.ts" diff --git a/packages/shared/src/editors.ts b/packages/shared/src/editors.ts new file mode 100644 index 000000000000..c5a081a7809d --- /dev/null +++ b/packages/shared/src/editors.ts @@ -0,0 +1,36 @@ +/** + * Editors - Shared helpers for editor identifiers. + * + * Maps user-defined custom editor definitions to namespaced `EditorId` + * values so they can flow through the same RPC/preference plumbing as + * built-in editors without colliding with built-in ids. + * + * @module Editors + */ +import { + CUSTOM_EDITOR_ID_PREFIX, + type CustomEditorDefinition, + type CustomEditorId, + type EditorId, +} from "@t3tools/contracts"; + +export function customEditorId(slug: CustomEditorDefinition["id"]): CustomEditorId { + return `${CUSTOM_EDITOR_ID_PREFIX}${slug}`; +} + +export function isCustomEditorId(editor: EditorId): editor is CustomEditorId { + return editor.startsWith(CUSTOM_EDITOR_ID_PREFIX); +} + +/** + * Full list of editor ids a user can pick from: built-in editors detected on + * the server plus all configured custom editors. Custom editors are not + * availability-checked — the user opted into them explicitly, and a missing + * command surfaces as a launch error instead of a silently hidden entry. + */ +export function selectableEditorIds( + availableEditors: ReadonlyArray, + customEditors: ReadonlyArray, +): ReadonlyArray { + return [...availableEditors, ...customEditors.map((editor) => customEditorId(editor.id))]; +}