From c20e4337b23007d0f3e612aeea967e44669aef8b Mon Sep 17 00:00:00 2001 From: TheIcarusWings <10465470+TheIcarusWings@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:57:33 +0100 Subject: [PATCH 1/3] feat(sidebar,git): thread folders, worktree labels, git sync control, CI status Re-submits four previously reviewed features, rebuilt on current main after the sidebar refactor. Replaces #3069, #3070, #3071, #3075 and #3088, which were closed during backlog cleanup while stale. - sidebar: user-defined thread folders with drag reorder - web: custom display labels for worktrees - git-sync: VS Code-style publish/pull/push/sync control - composer: CI status next to the PR pill The multi-select thread context menu now composes both the folder move submenu and the upstream archive action: mark unread / move to folder / archive / delete. --- apps/server/src/git/GitManager.test.ts | 6 +- apps/server/src/git/GitManager.ts | 6 +- apps/server/src/git/GitWorkflowService.ts | 33 + .../src/sourceControl/GitHubCli.test.ts | 91 ++- apps/server/src/sourceControl/GitHubCli.ts | 6 +- .../GitHubSourceControlProvider.test.ts | 2 +- .../GitHubSourceControlProvider.ts | 3 +- .../src/sourceControl/gitHubPullRequests.ts | 75 +- apps/server/src/vcs/GitVcsDriver.ts | 9 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 160 ++++ apps/server/src/vcs/GitVcsDriverCore.ts | 153 ++++ apps/server/src/ws.ts | 44 + .../web/src/components/BranchToolbar.logic.ts | 20 +- .../BranchToolbarBranchSelector.tsx | 47 +- .../BranchToolbarEnvModeSelector.tsx | 33 +- apps/web/src/components/GitSyncControl.tsx | 334 ++++++++ apps/web/src/components/Sidebar.logic.ts | 12 +- apps/web/src/components/Sidebar.tsx | 770 +++++++++++++++--- .../src/components/SidebarThreadGroupRow.tsx | 141 ++++ .../src/components/ThreadStatusIndicators.tsx | 67 +- .../src/components/WorktreeRenameDialog.tsx | 96 +++ apps/web/src/hooks/useThreadActions.ts | 11 +- .../web/src/hooks/useWorktreeRenameTrigger.ts | 62 ++ apps/web/src/lib/sourceControlActions.ts | 3 + apps/web/src/routes/__root.tsx | 2 + apps/web/src/sidebarThreadGrouping.test.ts | 123 +++ apps/web/src/sidebarThreadGrouping.ts | 72 ++ apps/web/src/state/sourceControlActions.ts | 123 +++ apps/web/src/uiStateStore.test.ts | 242 ++++++ apps/web/src/uiStateStore.ts | 654 ++++++++++++++- apps/web/src/worktreeCleanup.ts | 18 + apps/web/src/worktreeRenameStore.ts | 26 + .../client-runtime/src/state/threadSort.ts | 5 + packages/client-runtime/src/state/vcs.ts | 18 + .../client-runtime/src/state/vcsAction.ts | 3 + packages/contracts/src/git.ts | 70 +- packages/contracts/src/rpc.ts | 31 + packages/contracts/src/settings.ts | 2 +- packages/contracts/src/sourceControl.ts | 16 +- 39 files changed, 3451 insertions(+), 138 deletions(-) create mode 100644 apps/web/src/components/GitSyncControl.tsx create mode 100644 apps/web/src/components/SidebarThreadGroupRow.tsx create mode 100644 apps/web/src/components/WorktreeRenameDialog.tsx create mode 100644 apps/web/src/hooks/useWorktreeRenameTrigger.ts create mode 100644 apps/web/src/sidebarThreadGrouping.test.ts create mode 100644 apps/web/src/sidebarThreadGrouping.ts create mode 100644 apps/web/src/worktreeRenameStore.ts diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index e1924c03ade..d61c29784be 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -527,7 +527,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner,statusCheckRollup", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as unknown[]), @@ -571,7 +571,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner,statusCheckRollup", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), @@ -1071,7 +1071,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { state: "open", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner,statusCheckRollup", ); }), 20_000, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f1fb03e7e45..68da8dc6583 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -49,7 +49,7 @@ import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; -import type { ChangeRequest } from "@t3tools/contracts"; +import type { ChangeRequest, ChangeRequestChecks } from "@t3tools/contracts"; export interface GitActionProgressReporter { readonly publish: (event: GitActionProgressEvent) => Effect.Effect; @@ -114,6 +114,7 @@ interface OpenPrInfo { interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; updatedAt: Option.Option; + checks?: ChangeRequestChecks | null; } const pullRequestUpdatedAtDescOrder: Order.Order = Order.mapInput( @@ -317,6 +318,7 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { ...(summary.headRepositoryOwnerLogin !== undefined ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } : {}), + ...(summary.checks !== undefined ? { checks: summary.checks } : {}), }; } @@ -461,6 +463,7 @@ function toStatusPr(pr: PullRequestInfo): { baseRef: string; headRef: string; state: "open" | "closed" | "merged"; + checks?: ChangeRequestChecks | null; } { return { number: pr.number, @@ -469,6 +472,7 @@ function toStatusPr(pr: PullRequestInfo): { baseRef: pr.baseRefName, headRef: pr.headRefName, state: pr.state, + ...(pr.checks !== undefined ? { checks: pr.checks } : {}), }; } diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 100b9beadba..adcfd56aca4 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -5,6 +5,11 @@ import * as Layer from "effect/Layer"; import { GitManagerError, GitCommandError, + type GitDivergedError, + type VcsFetchResult, + type VcsPushResult, + type VcsSyncInput, + type VcsSyncResult, type VcsSwitchRefInput, type VcsSwitchRefResult, type VcsCreateRefInput, @@ -49,6 +54,12 @@ export class GitWorkflowService extends Context.Service< readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; readonly invalidateStatus: (cwd: string) => Effect.Effect; readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly fetchCurrentBranch: (cwd: string) => Effect.Effect; + readonly pushCurrentBranch: (cwd: string) => Effect.Effect; + readonly syncCurrentBranch: ( + cwd: string, + options?: { readonly mode?: VcsSyncInput["mode"] }, + ) => Effect.Effect; readonly runStackedAction: ( input: GitRunStackedActionInput, options?: GitManager.GitRunStackedActionOptions, @@ -277,6 +288,28 @@ export const make = Effect.gen(function* () { ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe( Effect.andThen(git.pullCurrentBranch(cwd)), ), + fetchCurrentBranch: (cwd) => + ensureGitCommand("GitWorkflowService.fetchCurrentBranch", cwd).pipe( + Effect.andThen(git.fetchCurrentBranch(cwd)), + ), + pushCurrentBranch: (cwd) => + ensureGitCommand("GitWorkflowService.pushCurrentBranch", cwd).pipe( + // Reuse the exact driver call the stacked-action push path uses so the + // standalone push and the bundled push share upstream-setting logic. + Effect.andThen(git.pushCurrentBranch(cwd, null)), + Effect.map( + (result): VcsPushResult => ({ + status: result.status, + refName: result.branch, + upstreamRef: result.upstreamBranch ?? null, + setUpstream: result.setUpstream ?? false, + }), + ), + ), + syncCurrentBranch: (cwd, options) => + ensureGitCommand("GitWorkflowService.syncCurrentBranch", cwd).pipe( + Effect.andThen(git.syncCurrentBranch(cwd, options)), + ), runStackedAction: (input, options) => ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe( Effect.andThen(gitManager.runStackedAction(input, options)), diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5df4862b409..0ea897679e2 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -103,7 +103,7 @@ describe("GitHubCli.layer", () => { "view", "#42", "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner,statusCheckRollup", ], cwd: "/repo", timeoutMs: 30_000, @@ -111,6 +111,95 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("derives a failing checks summary from statusCheckRollup", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 42, + title: "Add PR thread creation", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/pr-threads", + state: "OPEN", + mergedAt: null, + statusCheckRollup: [ + { __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS" }, + { __typename: "CheckRun", status: "COMPLETED", conclusion: "FAILURE" }, + { __typename: "StatusContext", state: "ERROR" }, + { __typename: "CheckRun", status: "IN_PROGRESS", conclusion: null }, + ], + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" }); + + assert.deepStrictEqual(result.checks, { state: "failing", failingCount: 2 }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("derives a pending checks summary when a run is still in progress", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 42, + title: "Add PR thread creation", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/pr-threads", + state: "OPEN", + mergedAt: null, + statusCheckRollup: [ + { __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS" }, + { __typename: "CheckRun", status: "IN_PROGRESS", conclusion: null }, + ], + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" }); + + assert.deepStrictEqual(result.checks, { state: "pending", failingCount: 0 }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("omits checks when statusCheckRollup is empty", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 42, + title: "Add PR thread creation", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/pr-threads", + state: "OPEN", + mergedAt: null, + statusCheckRollup: [], + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" }); + + expect(result.checks).toBeUndefined(); + }).pipe(Effect.provide(layer)), + ); + it.effect("trims pull request fields decoded from gh json", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index bf3f27378b5..faf3b7e80b7 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -7,6 +7,7 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString, + type ChangeRequestChecks, type SourceControlRepositoryVisibility, type VcsError, } from "@t3tools/contracts"; @@ -188,6 +189,7 @@ export interface GitHubPullRequestSummary { readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; readonly headRepositoryOwnerLogin?: string | null; + readonly checks?: ChangeRequestChecks | null; } export interface GitHubRepositoryCloneUrls { @@ -332,7 +334,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner,statusCheckRollup", ], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -366,7 +368,7 @@ export const make = Effect.gen(function* () { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner,statusCheckRollup", ], }).pipe( Effect.map((result) => result.stdout.trim()), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 9e8a6829566..48ea59c1757 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -150,7 +150,7 @@ it.effect("uses gh json listing for non-open change request state queries", () = "--limit", "10", "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner,statusCheckRollup", ]); assert.strictEqual(changeRequests[0]?.provider, "github"); assert.strictEqual(changeRequests[0]?.state, "merged"); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b5d5d3a55f8..bf9afd10ef0 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -39,6 +39,7 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq ...(summary.headRepositoryOwnerLogin !== undefined ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } : {}), + ...(summary.checks !== undefined ? { checks: summary.checks } : {}), }; } @@ -139,7 +140,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 20), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner,statusCheckRollup", ], }) .pipe( diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index d9dcb7f9ad1..bb02bfb07b0 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -4,7 +4,7 @@ import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { PositiveInt, TrimmedNonEmptyString, type ChangeRequestChecks } from "@t3tools/contracts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; export interface NormalizedGitHubPullRequestRecord { @@ -18,6 +18,7 @@ export interface NormalizedGitHubPullRequestRecord { readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; readonly headRepositoryOwnerLogin?: string | null; + readonly checks?: ChangeRequestChecks | null; } const GitHubPullRequestSchema = Schema.Struct({ @@ -44,8 +45,78 @@ const GitHubPullRequestSchema = Schema.Struct({ }), ), ), + statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), }); +const FAILING_CHECK_CONCLUSIONS = new Set([ + "FAILURE", + "ERROR", + "CANCELLED", + "TIMED_OUT", + "ACTION_REQUIRED", + "STARTUP_FAILURE", +]); +const PENDING_CHECK_STATUSES = new Set([ + "QUEUED", + "IN_PROGRESS", + "PENDING", + "WAITING", + "REQUESTED", +]); + +function readUpperString(record: Record, key: string): string { + const value = record[key]; + return typeof value === "string" ? value.trim().toUpperCase() : ""; +} + +/** + * Classify a single `statusCheckRollup` entry. Entries are either GitHub Actions + * `CheckRun`s (status + conclusion) or legacy `StatusContext`s (state). + */ +function classifyCheckRollupEntry(entry: unknown): "passing" | "failing" | "pending" { + if (typeof entry !== "object" || entry === null) { + return "pending"; + } + const record = entry as Record; + const state = readUpperString(record, "state"); + if (state.length > 0) { + if (state === "FAILURE" || state === "ERROR") return "failing"; + if (state === "SUCCESS") return "passing"; + return "pending"; + } + const conclusion = readUpperString(record, "conclusion"); + if (FAILING_CHECK_CONCLUSIONS.has(conclusion)) return "failing"; + const status = readUpperString(record, "status"); + if (PENDING_CHECK_STATUSES.has(status)) return "pending"; + if (conclusion.length === 0) return "pending"; + return "passing"; +} + +/** + * Derive an overall CI summary from a `statusCheckRollup` array. Returns null + * when there are no checks (no CI configured), so the UI renders nothing. + */ +function deriveChecksSummary( + rollup: ReadonlyArray | null | undefined, +): ChangeRequestChecks | null { + if (!rollup || rollup.length === 0) { + return null; + } + let failingCount = 0; + let anyPending = false; + for (const entry of rollup) { + const classification = classifyCheckRollupEntry(entry); + if (classification === "failing") { + failingCount += 1; + } else if (classification === "pending") { + anyPending = true; + } + } + if (failingCount > 0) return { state: "failing", failingCount }; + if (anyPending) return { state: "pending", failingCount: 0 }; + return { state: "passing", failingCount: 0 }; +} + function trimOptionalString(value: string | null | undefined): string | null { const trimmed = value?.trim() ?? ""; return trimmed.length > 0 ? trimmed : null; @@ -77,6 +148,7 @@ function normalizeGitHubPullRequestRecord( (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/") ? (headRepositoryNameWithOwner.split("/")[0] ?? null) : null); + const checks = deriveChecksSummary(raw.statusCheckRollup); return { number: raw.number, @@ -91,6 +163,7 @@ function normalizeGitHubPullRequestRecord( : {}), ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), + ...(checks ? { checks } : {}), }; } diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 55aa8f38835..f4cc1dedeae 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -11,6 +11,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, + type GitDivergedError, VcsProcessExitError, type VcsSwitchRefInput, type VcsSwitchRefResult, @@ -23,7 +24,10 @@ import { type VcsInitInput, type VcsListRefsInput, type VcsListRefsResult, + type VcsFetchResult, type VcsPullResult, + type VcsSyncInput, + type VcsSyncResult, type VcsRemoveWorktreeInput, type VcsStatusInput, type VcsStatusResult, @@ -229,6 +233,11 @@ export class GitVcsDriver extends Context.Service< input: VcsListRefsInput, ) => Effect.Effect; readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly fetchCurrentBranch: (cwd: string) => Effect.Effect; + readonly syncCurrentBranch: ( + cwd: string, + options?: { readonly mode?: VcsSyncInput["mode"] }, + ) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 8c627525b4c..3422d30e658 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -898,4 +898,164 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); }); + + describe("branch sync", () => { + // Helper: a repo on `main` tracking a bare `origin`, both at the initial commit. + const initRepoWithRemote = (cwd: string, remote: string) => + Effect.gen(function* () { + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + }); + + it.effect("pushes when the branch is ahead of its upstream", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithRemote(cwd, remote); + yield* writeTextFile(cwd, "ahead.txt", "ahead\n"); + yield* git(cwd, ["add", "ahead.txt"]); + yield* git(cwd, ["commit", "-m", "ahead commit"]); + + const result = yield* (yield* GitVcsDriver.GitVcsDriver).syncCurrentBranch(cwd); + + assert.deepStrictEqual(result, { + refName: "main", + fetched: true, + pull: "skipped", + push: "pushed", + setUpstream: false, + }); + assert.equal(yield* git(remote, ["log", "-1", "--pretty=%s", "main"]), "ahead commit"); + }), + ); + + it.effect("fast-forward pulls when the branch is behind its upstream", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithRemote(cwd, remote); + // Advance the upstream, then rewind the local branch so it is purely behind. + yield* writeTextFile(cwd, "second.txt", "second\n"); + yield* git(cwd, ["add", "second.txt"]); + yield* git(cwd, ["commit", "-m", "second commit"]); + yield* git(cwd, ["push", "origin", "main"]); + yield* git(cwd, ["reset", "--hard", "HEAD~1"]); + + const result = yield* (yield* GitVcsDriver.GitVcsDriver).syncCurrentBranch(cwd); + + assert.deepStrictEqual(result, { + refName: "main", + fetched: true, + pull: "pulled", + push: "skipped", + setUpstream: false, + }); + assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "second commit"); + }), + ); + + it.effect("fails with GitDivergedError when history has diverged", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithRemote(cwd, remote); + // Upstream gains a commit the local branch never sees... + yield* writeTextFile(cwd, "upstream.txt", "upstream\n"); + yield* git(cwd, ["add", "upstream.txt"]); + yield* git(cwd, ["commit", "-m", "upstream commit"]); + yield* git(cwd, ["push", "origin", "main"]); + yield* git(cwd, ["reset", "--hard", "HEAD~1"]); + // ...while the local branch grows its own commit. + yield* writeTextFile(cwd, "local.txt", "local\n"); + yield* git(cwd, ["add", "local.txt"]); + yield* git(cwd, ["commit", "-m", "local commit"]); + + const error = yield* (yield* GitVcsDriver.GitVcsDriver) + .syncCurrentBranch(cwd) + .pipe(Effect.flip); + + assert.equal(error._tag, "GitDivergedError"); + if (error._tag === "GitDivergedError") { + assert.equal(error.refName, "main"); + assert.isAtLeast(error.aheadCount, 1); + assert.isAtLeast(error.behindCount, 1); + } + // The working tree must be left clean — no half-finished rebase/merge. + assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "local commit"); + }), + ); + + it.effect("rebases and pushes when the diverged sync opts into rebase", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithRemote(cwd, remote); + yield* writeTextFile(cwd, "upstream.txt", "upstream\n"); + yield* git(cwd, ["add", "upstream.txt"]); + yield* git(cwd, ["commit", "-m", "upstream commit"]); + yield* git(cwd, ["push", "origin", "main"]); + yield* git(cwd, ["reset", "--hard", "HEAD~1"]); + yield* writeTextFile(cwd, "local.txt", "local\n"); + yield* git(cwd, ["add", "local.txt"]); + yield* git(cwd, ["commit", "-m", "local commit"]); + + const result = yield* (yield* GitVcsDriver.GitVcsDriver).syncCurrentBranch(cwd, { + mode: "rebase", + }); + + assert.deepStrictEqual(result, { + refName: "main", + fetched: true, + pull: "rebased", + push: "pushed", + setUpstream: false, + }); + // Local commit replayed on top of the upstream commit and pushed back. + assert.equal(yield* git(remote, ["log", "-1", "--pretty=%s", "main"]), "local commit"); + }), + ); + + it.effect("publishes a branch that has no upstream", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithRemote(cwd, remote); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createRef({ cwd, refName: "feature/publish-sync" }); + yield* driver.switchRef({ cwd, refName: "feature/publish-sync" }); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "feature.txt"]); + yield* git(cwd, ["commit", "-m", "feature commit"]); + + const result = yield* driver.syncCurrentBranch(cwd); + + assert.deepStrictEqual(result, { + refName: "feature/publish-sync", + fetched: true, + pull: "skipped", + push: "pushed", + setUpstream: true, + }); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "origin/feature/publish-sync", + ); + }), + ); + + it.effect("reports the branch and upstream when fetching", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithRemote(cwd, remote); + + const result = yield* (yield* GitVcsDriver.GitVcsDriver).fetchCurrentBranch(cwd); + + assert.deepStrictEqual(result, { refName: "main", hasUpstream: true }); + }), + ); + }); }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 6293ff7c29c..911a82fb241 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -21,9 +21,12 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, + GitDivergedError, type ReviewDiffPreviewInput, type ReviewDiffPreviewSource, + type VcsFetchResult, type VcsRef, + type VcsSyncResult, } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import { compactTraceAttributes } from "@t3tools/shared/observability"; @@ -1778,6 +1781,154 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); + // Force an immediate fetch (independent of the cached status refresh) so the + // remote-tracking ref behind @{u} reflects the latest upstream commits. + const fetchCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchCurrentBranch"] = Effect.fn( + "fetchCurrentBranch", + )(function* (cwd) { + const details = yield* statusDetails(cwd); + const upstream = yield* resolveCurrentUpstream(cwd).pipe(Effect.orElseSucceed(() => null)); + if (upstream) { + // Update exactly the tracking ref the ahead/behind counts are measured against. + yield* fetchRemoteTrackingBranch({ + cwd, + remoteName: upstream.remoteName, + remoteBranch: upstream.branchName, + }); + } else if (details.branch) { + // No upstream yet: fetch the branch's configured push remote so a newly + // published upstream becomes visible. Skip quietly when no remote exists. + const remoteName = yield* resolvePushRemoteName(cwd, details.branch).pipe( + Effect.orElseSucceed(() => null), + ); + if (remoteName) { + yield* runGit("GitVcsDriver.fetchCurrentBranch.fetchRemote", cwd, [ + "fetch", + "--quiet", + "--no-tags", + remoteName, + ]); + } + } + return { + refName: details.branch, + hasUpstream: details.hasUpstream, + } satisfies VcsFetchResult; + }); + + // `git pull --rebase` that never leaves the tree mid-rebase: on conflict it + // aborts and surfaces a clear error so the user can resolve manually. + const rebaseOntoUpstream = Effect.fn("rebaseOntoUpstream")(function* (cwd: string) { + const result = yield* executeGit( + "GitVcsDriver.syncCurrentBranch.rebase", + cwd, + ["pull", "--rebase"], + { allowNonZeroExit: true, timeoutMs: 30_000 }, + ); + if (result.exitCode !== 0) { + yield* executeGit("GitVcsDriver.syncCurrentBranch.rebaseAbort", cwd, ["rebase", "--abort"], { + allowNonZeroExit: true, + }); + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.syncCurrentBranch.rebase", + cwd, + args: ["pull", "--rebase"], + }), + detail: + "Rebase hit conflicts and was aborted. Resolve the divergence manually, then sync again.", + }); + } + }); + + // One-click sync: fetch, then fast-forward pull and/or push as needed, pushing + // to the branch's configured push remote. Diverged history is reported as a + // typed GitDivergedError unless the caller opts into a rebase. + const syncCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["syncCurrentBranch"] = Effect.fn( + "syncCurrentBranch", + )(function* (cwd, options) { + const mode = options?.mode ?? "ff"; + yield* fetchCurrentBranch(cwd); + const details = yield* statusDetails(cwd); + const refName = details.branch; + if (!refName) { + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.syncCurrentBranch", + cwd, + args: ["sync"], + }), + detail: "Cannot sync from detached HEAD.", + }); + } + + // No upstream yet: publish the branch (push -u sets the upstream). + if (!details.hasUpstream) { + const published = yield* pushCurrentBranch(cwd, refName); + return { + refName, + fetched: true, + pull: "skipped" as const, + push: published.status === "pushed" ? ("pushed" as const) : ("skipped" as const), + setUpstream: published.setUpstream ?? false, + } satisfies VcsSyncResult; + } + + const ahead = details.aheadCount; + const behind = details.behindCount; + + if (ahead > 0 && behind > 0) { + if (mode !== "rebase") { + return yield* new GitDivergedError({ + operation: "GitVcsDriver.syncCurrentBranch", + cwd, + refName, + aheadCount: ahead, + behindCount: behind, + }); + } + yield* rebaseOntoUpstream(cwd); + const pushed = yield* pushCurrentBranch(cwd, refName); + return { + refName, + fetched: true, + pull: "rebased" as const, + push: pushed.status === "pushed" ? ("pushed" as const) : ("skipped" as const), + setUpstream: pushed.setUpstream ?? false, + } satisfies VcsSyncResult; + } + + if (behind > 0) { + const pulled = yield* pullCurrentBranch(cwd); + return { + refName, + fetched: true, + pull: pulled.status === "pulled" ? ("pulled" as const) : ("skipped_up_to_date" as const), + push: "skipped" as const, + setUpstream: false, + } satisfies VcsSyncResult; + } + + if (ahead > 0) { + const pushed = yield* pushCurrentBranch(cwd, refName); + return { + refName, + fetched: true, + pull: "skipped" as const, + push: pushed.status === "pushed" ? ("pushed" as const) : ("skipped" as const), + setUpstream: pushed.setUpstream ?? false, + } satisfies VcsSyncResult; + } + + return { + refName, + fetched: true, + pull: "skipped_up_to_date" as const, + push: "skipped" as const, + setUpstream: false, + } satisfies VcsSyncResult; + }); + const readRangeContext: GitVcsDriver.GitVcsDriver["Service"]["readRangeContext"] = Effect.fn( "readRangeContext", )(function* (cwd, baseRef) { @@ -2554,6 +2705,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* commit, pushCurrentBranch, pullCurrentBranch, + fetchCurrentBranch, + syncCurrentBranch, readRangeContext, getReviewDiffPreview, readConfigValue, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 08b1770a0a2..70db0f78846 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -320,6 +320,9 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], + [WS_METHODS.vcsFetch, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPush, AuthOrchestrationOperateScope], + [WS_METHODS.vcsSync, AuthOrchestrationOperateScope], [WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope], [WS_METHODS.gitResolvePullRequest, AuthOrchestrationOperateScope], [WS_METHODS.gitPreparePullRequestThread, AuthOrchestrationOperateScope], @@ -1766,6 +1769,47 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "git" }, ), + [WS_METHODS.vcsFetch]: (input) => + observeRpcEffect( + WS_METHODS.vcsFetch, + gitWorkflow.fetchCurrentBranch(input.cwd).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Effect.failCause(cause), + onSuccess: (result) => + refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), + }), + ), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.vcsPush]: (input) => + observeRpcEffect( + WS_METHODS.vcsPush, + gitWorkflow.pushCurrentBranch(input.cwd).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Effect.failCause(cause), + onSuccess: (result) => + refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), + }), + ), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.vcsSync]: (input) => + observeRpcEffect( + WS_METHODS.vcsSync, + gitWorkflow + .syncCurrentBranch(input.cwd, input.mode ? { mode: input.mode } : undefined) + .pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Effect.failCause(cause), + onSuccess: (result) => + refreshGitStatus(input.cwd).pipe( + Effect.ignore({ log: true }), + Effect.as(result), + ), + }), + ), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitRunStackedAction]: (input) => observeRpcStream( WS_METHODS.gitRunStackedAction, diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 65388962c08..c888dcfa4c3 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -46,12 +46,24 @@ export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } -export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string { - return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local"); +export function resolveCurrentWorkspaceLabel( + activeWorktreePath: string | null, + worktreeLabel?: string | null, +): string { + if (!activeWorktreePath) { + return resolveEnvModeLabel("local"); + } + return normalizeDisplayLabel(worktreeLabel) ?? "Current worktree"; } -export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): string { - return activeWorktreePath ? "Worktree" : "Local checkout"; +export function resolveLockedWorkspaceLabel( + activeWorktreePath: string | null, + worktreeLabel?: string | null, +): string { + if (!activeWorktreePath) { + return "Local checkout"; + } + return normalizeDisplayLabel(worktreeLabel) ?? "Worktree"; } export function resolveEffectiveEnvMode(input: { diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index e2ee24c3608..fffd1b95b20 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -20,6 +20,7 @@ import { } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { useSourceControlActionRunning } from "../lib/sourceControlActions"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { usePaginatedBranches } from "../state/queries"; import { useProject, useThread } from "../state/entities"; @@ -38,8 +39,11 @@ import { resolveEffectiveEnvMode, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; +import { GitSyncControl, SYNC_BUSY_ACTIONS } from "./GitSyncControl"; import { ChangeRequestStatusIcon, + ChecksStatusIcon, + checksStatusIndicator, prStatusIndicator, resolveThreadPr, } from "./ThreadStatusIndicators"; @@ -226,6 +230,10 @@ export function BranchToolbarBranchSelector({ input: { cwd: branchCwd }, }), ); + // Block ref switches/creates while a sync action (fetch/push/pull/sync) is in + // flight on the same cwd — checking out mid-operation would race the git op. + const syncScope = useMemo(() => ({ environmentId, cwd: branchCwd }), [environmentId, branchCwd]); + const isSyncBusy = useSourceControlActionRunning(syncScope, SYNC_BUSY_ACTIONS); const trimmedBranchQuery = branchQuery.trim(); const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); const branchRefTarget = useMemo( @@ -539,6 +547,16 @@ export function BranchToolbarBranchSelector({ const branchPrTooltip = branchPr ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state}) in browser` : ""; + // CI status chip shown next to the PR pill when the PR has checks. + const branchPrChecks = checksStatusIndicator(branchPr?.checks); + const branchPrChecksUrl = branchPr ? `${branchPr.url}/checks` : ""; + // For an open PR with CI, color the pill by the check status too (red/amber/green) + // so the whole PR cluster reflects health at a glance. Otherwise keep the PR + // state color (merged = violet, closed = gray). + const branchPrPillColorClass = + branchPr?.state === "open" && branchPrChecks + ? branchPrChecks.colorClass + : branchPrStatus?.colorClass; const openPrLink = useOpenPrLink(); function renderPickerItem(itemValue: string, index: number) { @@ -648,7 +666,7 @@ export function BranchToolbarBranchSelector({ onClick={(event) => openPrLink(event, branchPrStatus.url)} className={cn( "inline-flex shrink-0 items-center gap-0.5 rounded px-1 py-0.5 text-[11px] font-medium tabular-nums transition-colors hover:bg-muted/60", - branchPrStatus.colorClass, + branchPrPillColorClass, )} /> } @@ -659,15 +677,40 @@ export function BranchToolbarBranchSelector({ {branchPrTooltip} ) : null} + {branchPr && branchPrChecks ? ( + + openPrLink(event, branchPrChecksUrl)} + className={cn( + "inline-flex shrink-0 items-center rounded px-1 py-0.5 transition-colors hover:bg-muted/60", + branchPrChecks.colorClass, + )} + /> + } + > + + + {branchPrChecks.tooltip} + + ) : null} } className="min-w-0 text-muted-foreground/70 hover:text-foreground/80" - disabled={isInitialBranchesLoadPending || isBranchActionPending} + disabled={isInitialBranchesLoadPending || isBranchActionPending || isSyncBusy} > {triggerLabel} +
diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 6d06882662f..cdc0e1981f0 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -1,6 +1,8 @@ import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; import { memo, useMemo } from "react"; +import { useWorktreeRenameTrigger } from "../hooks/useWorktreeRenameTrigger"; +import { useWorktreeLabel } from "../uiStateStore"; import { resolveCurrentWorkspaceLabel, resolveEnvModeLabel, @@ -30,26 +32,35 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe activeWorktreePath, onEnvModeChange, }: BranchToolbarEnvModeSelectorProps) { + const worktreeLabel = useWorktreeLabel(activeWorktreePath); + // Double-click or right-click the workspace label to rename the active + // worktree (cosmetic label only). No-op when the thread isn't on a worktree. + const renameTrigger = useWorktreeRenameTrigger(activeWorktreePath); const envModeItems = useMemo( () => [ - { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) }, + { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath, worktreeLabel) }, { value: "worktree", label: resolveEnvModeLabel("worktree") }, ], - [activeWorktreePath], + [activeWorktreePath, worktreeLabel], ); if (envLocked) { return ( - + {activeWorktreePath ? ( <> - {resolveLockedWorkspaceLabel(activeWorktreePath)} + {resolveLockedWorkspaceLabel(activeWorktreePath, worktreeLabel)} ) : ( <> - {resolveLockedWorkspaceLabel(activeWorktreePath)} + {resolveLockedWorkspaceLabel(activeWorktreePath, worktreeLabel)} )} @@ -63,7 +74,15 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe onValueChange={(value) => onEnvModeChange(value as EnvMode)} items={envModeItems} > - + {effectiveEnvMode === "worktree" ? ( ) : activeWorktreePath ? ( @@ -83,7 +102,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe ) : ( )} - {resolveCurrentWorkspaceLabel(activeWorktreePath)} + {resolveCurrentWorkspaceLabel(activeWorktreePath, worktreeLabel)} diff --git a/apps/web/src/components/GitSyncControl.tsx b/apps/web/src/components/GitSyncControl.tsx new file mode 100644 index 00000000000..29138f536bc --- /dev/null +++ b/apps/web/src/components/GitSyncControl.tsx @@ -0,0 +1,334 @@ +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, VcsStatusResult } from "@t3tools/contracts"; +import { ArrowDownIcon, ArrowUpIcon, CheckIcon, RefreshCwIcon, UploadIcon } from "lucide-react"; +import { useCallback, useMemo, type ReactNode } from "react"; + +import { + useSourceControlActionRunning, + useVcsFetchAction, + useVcsPullAction, + useVcsPushAction, + useVcsSyncAction, +} from "../lib/sourceControlActions"; +import { cn } from "../lib/utils"; +import { Button } from "./ui/button"; +import { Spinner } from "./ui/spinner"; +import { stackedThreadToast, toastManager } from "./ui/toast"; + +interface GitSyncControlProps { + environmentId: EnvironmentId; + cwd: string | null; + /** Latest VCS status (reused from the branch selector — no extra subscription). */ + status: VcsStatusResult | null; + className?: string; +} + +// Kept in sync with the action kinds this control can launch so any one of them +// (including a concurrent stacked action) disables the whole control. Exported so +// the branch picker can disable ref switches while a sync op is in flight. +export const SYNC_BUSY_ACTIONS = ["runStackedAction", "pull", "fetch", "push", "sync"] as const; + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "An error occurred."; +} + +function isDivergedError( + error: unknown, +): error is { _tag: "GitDivergedError"; refName: string; aheadCount: number; behindCount: number } { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + (error as { _tag?: unknown })._tag === "GitDivergedError" + ); +} + +/** + * VS Code-style ahead/behind indicator + one-click sync, shown next to the + * branch in the composer toolbar. States: publish (no upstream), pull (behind), + * push (ahead), sync (both), or up-to-date. A standalone Fetch button forces an + * immediate refresh of the ahead/behind counts. + */ +export function GitSyncControl({ environmentId, cwd, status, className }: GitSyncControlProps) { + const scope = useMemo(() => ({ environmentId, cwd }), [environmentId, cwd]); + const fetchAction = useVcsFetchAction(scope); + const pullAction = useVcsPullAction(scope); + const pushAction = useVcsPushAction(scope); + const syncAction = useVcsSyncAction(scope); + const isRunning = useSourceControlActionRunning(scope, SYNC_BUSY_ACTIONS); + + const onFetch = useCallback(() => { + const toastId = toastManager.add({ type: "loading", title: "Fetching...", timeout: 0 }); + void (async () => { + const result = await fetchAction.run(); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + toastManager.close(toastId); + return; + } + toastManager.update(toastId, { + type: "error", + title: "Fetch failed", + description: errorMessage(squashAtomCommandFailure(result)), + }); + return; + } + // vcs.fetch only updates remote-tracking refs; the branch may now be ahead + // or behind, so don't claim "Up to date" here — the badge reflects the state. + toastManager.update(toastId, { + type: "success", + title: "Fetched", + description: "Updated remote-tracking refs.", + }); + })(); + }, [fetchAction]); + + const onPull = useCallback(() => { + const toastId = toastManager.add({ type: "loading", title: "Pulling...", timeout: 0 }); + void (async () => { + const result = await pullAction.run(); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + toastManager.close(toastId); + return; + } + toastManager.update(toastId, { + type: "error", + title: "Pull failed", + description: errorMessage(squashAtomCommandFailure(result)), + }); + return; + } + const value = result.value; + toastManager.update(toastId, { + type: "success", + title: value.status === "pulled" ? "Pulled" : "Already up to date", + description: + value.status === "pulled" + ? `Updated ${value.refName} from ${value.upstreamRef ?? "upstream"}` + : `${value.refName} is already synchronized.`, + }); + })(); + }, [pullAction]); + + const onPush = useCallback( + (publish: boolean) => { + const toastId = toastManager.add({ + type: "loading", + title: publish ? "Publishing branch..." : "Pushing...", + timeout: 0, + }); + void (async () => { + const result = await pushAction.run(); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + toastManager.close(toastId); + return; + } + toastManager.update(toastId, { + type: "error", + title: publish ? "Publish failed" : "Push failed", + description: errorMessage(squashAtomCommandFailure(result)), + }); + return; + } + const value = result.value; + toastManager.update(toastId, { + type: "success", + title: value.setUpstream + ? "Branch published" + : value.status === "pushed" + ? "Pushed" + : "Already up to date", + description: value.upstreamRef + ? `${value.refName} → ${value.upstreamRef}` + : value.refName, + }); + })(); + }, + [pushAction], + ); + + const onSync = useCallback( + (mode?: "rebase") => { + const toastId = toastManager.add({ + type: "loading", + title: mode === "rebase" ? "Rebasing..." : "Syncing...", + timeout: 0, + }); + void (async () => { + const result = await syncAction.run(mode ? { mode } : undefined); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + toastManager.close(toastId); + return; + } + const error = squashAtomCommandFailure(result); + if (isDivergedError(error)) { + toastManager.update( + toastId, + stackedThreadToast({ + type: "error", + title: "Branch has diverged", + description: `Local and upstream both changed (${error.aheadCount} ahead, ${error.behindCount} behind). A fast-forward isn't possible.`, + data: { + secondaryActionProps: { + children: "Rebase & sync", + onClick: () => onSync("rebase"), + }, + secondaryActionVariant: "outline", + }, + }), + ); + return; + } + toastManager.update(toastId, { + type: "error", + title: "Sync failed", + description: errorMessage(error), + }); + return; + } + toastManager.update(toastId, { + type: "success", + title: "Synced", + description: describeSyncResult(result.value), + }); + })(); + }, + [syncAction], + ); + + // Only meaningful inside a git repo that has a remote to sync against. + if (!cwd || !status?.isRepo || !status.hasPrimaryRemote) { + return null; + } + + const hasUpstream = status.hasUpstream; + const ahead = status.aheadCount; + const behind = status.behindCount; + const busy = + isRunning || + fetchAction.isPending || + pullAction.isPending || + pushAction.isPending || + syncAction.isPending; + + let primary: { content: ReactNode; title: string; onClick: () => void } | null = null; + if (!hasUpstream) { + primary = { + content: ( + <> + {busy ? : } + Publish + + ), + title: "Publish branch (push and set upstream)", + onClick: () => onPush(true), + }; + } else if (ahead > 0 && behind > 0) { + primary = { + content: ( + <> + {busy ? : } + + + ), + title: `Sync: pull ${behind} and push ${ahead}`, + onClick: () => onSync(), + }; + } else if (behind > 0) { + primary = { + content: ( + <> + {busy ? : } + {behind} + + ), + title: `Pull ${behind} commit${behind === 1 ? "" : "s"} from upstream`, + onClick: onPull, + }; + } else if (ahead > 0) { + primary = { + content: ( + <> + {busy ? : } + {ahead} + + ), + title: `Push ${ahead} commit${ahead === 1 ? "" : "s"} to upstream`, + onClick: () => onPush(false), + }; + } + + return ( +
+ {primary ? ( + + ) : ( + + + + )} + +
+ ); +} + +function SyncCounts({ ahead, behind }: { ahead: number; behind: number }) { + return ( + + + + {behind} + + + + {ahead} + + + ); +} + +function describeSyncResult(result: { + pull: "pulled" | "rebased" | "skipped_up_to_date" | "skipped"; + push: "pushed" | "skipped"; + setUpstream: boolean; +}): string { + const parts: string[] = []; + if (result.pull === "pulled") parts.push("pulled"); + else if (result.pull === "rebased") parts.push("rebased"); + if (result.push === "pushed") parts.push(result.setUpstream ? "published" : "pushed"); + return parts.length > 0 ? `Branch ${parts.join(" and ")}.` : "Already up to date."; +} diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 1a565efd878..2fdfde1f1e5 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -74,9 +74,19 @@ export async function archiveSelectedThreadEntries< export function buildMultiSelectThreadContextMenuItems(input: { count: number; hasRunningThread: boolean; -}): readonly ContextMenuItem<"mark-unread" | "archive" | "delete">[] { + folderMenuChildren?: readonly ContextMenuItem[]; +}): readonly ContextMenuItem[] { return [ { id: "mark-unread", label: `Mark unread (${input.count})` }, + ...(input.folderMenuChildren + ? [ + { + id: "move-submenu", + label: `Move ${input.count} to folder`, + children: input.folderMenuChildren, + }, + ] + : []), { id: "archive", label: `Archive (${input.count})`, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4b4b1e52a94..4633a25ab57 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -30,10 +30,12 @@ import { DndContext, type DragCancelEvent, type CollisionDetection, + DragOverlay, PointerSensor, type DragStartEvent, closestCorners, pointerWithin, + useDroppable, useSensor, useSensors, type DragEndEvent, @@ -94,9 +96,17 @@ import { useAtomCommand } from "../state/use-atom-command"; import { previewEnvironment } from "../state/preview"; import { legacyProjectCwdPreferenceKey, + newThreadGroupId, resolveProjectExpanded, useUiStateStore, } from "../uiStateStore"; +import { useWorktreeRenameStore } from "../worktreeRenameStore"; +import { + buildGroupedThreadLayout, + type ThreadGroupSection, + threadKeyOf, +} from "../sidebarThreadGrouping"; +import SidebarThreadGroupRow, { groupHeaderDndId } from "./SidebarThreadGroupRow"; import { resolveShortcutCommand, shortcutLabelForCommand, @@ -232,12 +242,15 @@ const SIDEBAR_SORT_LABELS: Record = { const SIDEBAR_THREAD_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", + manual: "Manual", }; const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", } as const; const EMPTY_THREAD_JUMP_LABELS = new Map(); +// Stable empty array so per-project folder-order selectors don't churn renders. +const EMPTY_STRING_ARRAY: readonly string[] = []; const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", @@ -356,6 +369,10 @@ interface SidebarThreadRowProps { cancelRename: () => void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; openPrLink: (event: React.MouseEvent, prUrl: string) => void; + threadDragInProgressRef: React.RefObject; + suppressThreadClickAfterDragRef: React.RefObject; + /** When true the row sits inside a folder and is inset with a guide line. */ + indented?: boolean; } export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { @@ -383,9 +400,23 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr attemptArchiveThread, openPrLink, thread, + threadDragInProgressRef, + suppressThreadClickAfterDragRef, + indented = false, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); + const isRenamingThisRow = renamingThreadKey === threadKey; + const { + attributes: dragAttributes, + listeners: dragListeners, + setNodeRef: setDragNodeRef, + transform: dragTransform, + transition: dragTransition, + isDragging, + } = useSortable({ id: threadKey, disabled: isRenamingThisRow }); + // Suppress drag listeners while renaming so typing never starts a drag. + const rowDragHandleProps = isRenamingThisRow ? {} : { ...dragAttributes, ...dragListeners }; const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const runningTerminalIds = useThreadRunningTerminalIds({ @@ -497,9 +528,28 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ); const handleRowClick = useCallback( (event: React.MouseEvent) => { + // Mirror the project drag-vs-click guards: a drag in flight, or the + // trailing click dnd-kit emits after a drop, must not navigate/select. + if (threadDragInProgressRef.current) { + event.preventDefault(); + event.stopPropagation(); + return; + } + if (suppressThreadClickAfterDragRef.current) { + suppressThreadClickAfterDragRef.current = false; + event.preventDefault(); + event.stopPropagation(); + return; + } handleThreadClick(event, threadRef, orderedProjectThreadKeys); }, - [handleThreadClick, orderedProjectThreadKeys, threadRef], + [ + handleThreadClick, + orderedProjectThreadKeys, + suppressThreadClickAfterDragRef, + threadDragInProgressRef, + threadRef, + ], ); const handleRowDoubleClick = useCallback( (event: React.MouseEvent) => { @@ -674,7 +724,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr return (
{prStatus && ( @@ -890,13 +945,36 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ); }); +/** dnd id for the per-project "drop here to remove from folder" zone. */ +function ungroupedDropId(projectKey: string): string { + return `ungrouped:${projectKey}`; +} + +/** A drop target shown during a drag for moving a thread out of any folder. */ +function UngroupedDropZone({ projectKey }: { projectKey: string }) { + const { setNodeRef, isOver } = useDroppable({ id: ungroupedDropId(projectKey) }); + return ( + +
+ Remove from folder +
+
+ ); +} + interface SidebarProjectThreadListProps { projectKey: string; projectExpanded: boolean; hasOverflowingThreads: boolean; hiddenThreadStatus: ThreadStatusPill | null; orderedProjectThreadKeys: readonly string[]; - renderedThreads: readonly SidebarThreadSummary[]; + pinnedCollapsedThread: SidebarThreadSummary | null; + sections: readonly ThreadGroupSection[]; + ungroupedRenderedThreads: readonly SidebarThreadSummary[]; showEmptyThreadState: boolean; shouldShowThreadPanel: boolean; isThreadListExpanded: boolean; @@ -936,6 +1014,23 @@ interface SidebarProjectThreadListProps { openPrLink: (event: React.MouseEvent, prUrl: string) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; + // Folder header wiring. + renamingGroupId: string | null; + renamingGroupTitle: string; + setRenamingGroupTitle: (title: string) => void; + onToggleGroup: (groupId: string) => void; + onGroupContextMenu: (groupId: string, position: { x: number; y: number }) => void; + commitGroupRename: (groupId: string) => void; + cancelGroupRename: () => void; + // Thread/folder drag-and-drop wiring. + dndSensors: ReturnType; + dndCollisionDetection: CollisionDetection; + onThreadDragStart: (event: DragStartEvent) => void; + onThreadDragEnd: (event: DragEndEvent) => void; + onThreadDragCancel: (event: DragCancelEvent) => void; + activeDragLabel: string | null; + threadDragInProgressRef: React.RefObject; + suppressThreadClickAfterDragRef: React.RefObject; } const SidebarProjectThreadList = memo(function SidebarProjectThreadList( @@ -947,7 +1042,9 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( hasOverflowingThreads, hiddenThreadStatus, orderedProjectThreadKeys, - renderedThreads, + pinnedCollapsedThread, + sections, + ungroupedRenderedThreads, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, @@ -976,93 +1073,224 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( openPrLink, expandThreadListForProject, collapseThreadListForProject, + renamingGroupId, + renamingGroupTitle, + setRenamingGroupTitle, + onToggleGroup, + onGroupContextMenu, + commitGroupRename, + cancelGroupRename, + dndSensors, + dndCollisionDetection, + onThreadDragStart, + onThreadDragEnd, + onThreadDragCancel, + activeDragLabel, + threadDragInProgressRef, + suppressThreadClickAfterDragRef, } = props; const showMoreButtonRender = useMemo(() => + + + + + ); +} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index dbde5acce99..a67807b196d 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -22,7 +22,8 @@ import { readLocalApi } from "../localApi"; import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; -import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; +import { getOrphanedWorktreePathForThread, worktreeDisplayName } from "../worktreeCleanup"; +import { useUiStateStore } from "../uiStateStore"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; @@ -188,7 +189,7 @@ export function useThreadActions() { threadRef.threadId, ); const displayWorktreePath = orphanedWorktreePath - ? formatWorktreePathForDisplay(orphanedWorktreePath) + ? worktreeDisplayName(orphanedWorktreePath, useUiStateStore.getState().worktreeLabelByPath) : null; const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null; const localApi = readLocalApi(); @@ -296,6 +297,12 @@ export function useThreadActions() { force: true, }, }); + if (removeResult._tag === "Success") { + // Drop any custom display label for the now-deleted worktree so it + // doesn't linger in persisted state or get inherited by a future + // worktree reusing the same path. + useUiStateStore.getState().setWorktreeLabel(orphanedWorktreePath, ""); + } const refreshResult = removeResult._tag === "Success" ? await refreshVcsStatus({ diff --git a/apps/web/src/hooks/useWorktreeRenameTrigger.ts b/apps/web/src/hooks/useWorktreeRenameTrigger.ts new file mode 100644 index 00000000000..99a0a997c9c --- /dev/null +++ b/apps/web/src/hooks/useWorktreeRenameTrigger.ts @@ -0,0 +1,62 @@ +import { useCallback, type MouseEvent } from "react"; + +import { readLocalApi } from "../localApi"; +import { useWorktreeRenameStore } from "../worktreeRenameStore"; + +export interface WorktreeRenameTriggerHandlers { + onDoubleClick: (event: MouseEvent) => void; + onContextMenu: (event: MouseEvent) => void; +} + +/** + * Shared interaction handlers for renaming the active worktree from a label + * surface (e.g. the bottom-bar workspace label). Double-click opens the rename + * dialog directly; right-click shows a native "Rename worktree" context menu. + * Both are no-ops when the thread isn't on a worktree, so they're safe to wire + * unconditionally. The rename is a cosmetic label only — no disk move. + */ +export function useWorktreeRenameTrigger( + activeWorktreePath: string | null, +): WorktreeRenameTriggerHandlers { + const openWorktreeRename = useWorktreeRenameStore((state) => state.openWorktreeRename); + + const onDoubleClick = useCallback( + (event: MouseEvent) => { + if (!activeWorktreePath) { + return; + } + event.preventDefault(); + event.stopPropagation(); + openWorktreeRename(activeWorktreePath); + }, + [activeWorktreePath, openWorktreeRename], + ); + + const onContextMenu = useCallback( + (event: MouseEvent) => { + if (!activeWorktreePath) { + return; + } + event.preventDefault(); + event.stopPropagation(); + // Capture coordinates before the await — the event may be reused. + const position = { x: event.clientX, y: event.clientY }; + const api = readLocalApi(); + if (!api) { + // No native context menu available; open the dialog directly. + openWorktreeRename(activeWorktreePath); + return; + } + void api.contextMenu + .show([{ id: "rename-worktree", label: "Rename worktree" }], position) + .then((clicked) => { + if (clicked === "rename-worktree") { + openWorktreeRename(activeWorktreePath); + } + }); + }, + [activeWorktreePath, openWorktreeRename], + ); + + return { onDoubleClick, onContextMenu }; +} diff --git a/apps/web/src/lib/sourceControlActions.ts b/apps/web/src/lib/sourceControlActions.ts index 2d857c8e4b7..a9bbcd1420c 100644 --- a/apps/web/src/lib/sourceControlActions.ts +++ b/apps/web/src/lib/sourceControlActions.ts @@ -5,6 +5,9 @@ export { usePullRequestResolutionState as usePullRequestResolution, useSourceControlActionRunning, useSourceControlPublishRepositoryAction, + useVcsFetchAction, useVcsInitAction, useVcsPullAction, + useVcsPushAction, + useVcsSyncAction, } from "../state/sourceControlActions"; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index ff6bc5b3952..412b3d585ae 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -15,6 +15,7 @@ import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; +import { WorktreeRenameDialog } from "../components/WorktreeRenameDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; @@ -120,6 +121,7 @@ function RootRouteView() { + ); diff --git a/apps/web/src/sidebarThreadGrouping.test.ts b/apps/web/src/sidebarThreadGrouping.test.ts new file mode 100644 index 00000000000..a0a5fccd9ee --- /dev/null +++ b/apps/web/src/sidebarThreadGrouping.test.ts @@ -0,0 +1,123 @@ +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildGroupedThreadLayout, threadKeyOf } from "./sidebarThreadGrouping"; +import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "./types"; +import type { ThreadGroup } from "./uiStateStore"; +import type { SidebarThreadSummary } from "./types"; + +const ENV = EnvironmentId.make("env-1"); +const PROJ = ProjectId.make("proj-1"); + +function makeThread(id: string): SidebarThreadSummary { + return { + id: ThreadId.make(id), + environmentId: ENV, + projectId: PROJ, + title: id, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.3-codex", + }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + session: null, + createdAt: "2026-06-13T00:00:00.000Z", + updatedAt: "2026-06-13T00:00:00.000Z", + archivedAt: null, + latestTurn: null, + branch: null, + worktreePath: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +function group(id: string, threads: SidebarThreadSummary[]): ThreadGroup { + return { id, projectKey: "pk", name: id, threadKeys: threads.map(threadKeyOf) }; +} + +describe("buildGroupedThreadLayout", () => { + const t1 = makeThread("t1"); + const t2 = makeThread("t2"); + const t3 = makeThread("t3"); + const t4 = makeThread("t4"); + + it("splits threads into folder sections (in folder order) plus ungrouped", () => { + const g1 = group("g1", [t2]); + const g2 = group("g2", [t3]); + const layout = buildGroupedThreadLayout({ + visibleProjectThreads: [t1, t2, t3, t4], + projectKey: "pk", + groups: { g1, g2 }, + groupOrder: ["g2", "g1"], + groupExpandedById: {}, + }); + + expect(layout.sections.map((s) => s.group.id)).toEqual(["g2", "g1"]); + expect(layout.sections[0]!.threads).toEqual([t3]); + expect(layout.sections[1]!.threads).toEqual([t2]); + expect(layout.ungroupedThreads).toEqual([t1, t4]); + }); + + it("orders threads within a folder by the folder's threadKeys, not sort order", () => { + const g1: ThreadGroup = { + id: "g1", + projectKey: "pk", + name: "g1", + threadKeys: [threadKeyOf(t3), threadKeyOf(t1)], + }; + const layout = buildGroupedThreadLayout({ + visibleProjectThreads: [t1, t2, t3], + projectKey: "pk", + groups: { g1 }, + groupOrder: ["g1"], + groupExpandedById: {}, + }); + expect(layout.sections[0]!.threads).toEqual([t3, t1]); + expect(layout.ungroupedThreads).toEqual([t2]); + }); + + it("defaults a folder to expanded and honours an explicit collapse", () => { + const g1 = group("g1", [t1]); + const layout = buildGroupedThreadLayout({ + visibleProjectThreads: [t1], + projectKey: "pk", + groups: { g1 }, + groupOrder: ["g1"], + groupExpandedById: { g1: false }, + }); + expect(layout.sections[0]!.expanded).toBe(false); + }); + + it("ignores folders from a different project and threads no longer visible", () => { + const sameProject = group("g1", [t1]); + const otherProject: ThreadGroup = { + id: "g2", + projectKey: "other", + name: "g2", + threadKeys: [threadKeyOf(t2)], + }; + // g3 references t4, which is not in the visible set -> contributes no row. + const staleMember: ThreadGroup = { + id: "g3", + projectKey: "pk", + name: "g3", + threadKeys: [threadKeyOf(t4)], + }; + const layout = buildGroupedThreadLayout({ + visibleProjectThreads: [t1, t2], + projectKey: "pk", + groups: { g1: sameProject, g2: otherProject, g3: staleMember }, + groupOrder: ["g1", "g2", "g3"], + groupExpandedById: {}, + }); + + expect(layout.sections.map((s) => s.group.id)).toEqual(["g1", "g3"]); + expect(layout.sections.find((s) => s.group.id === "g3")!.threads).toEqual([]); + // t2 belongs to a folder scoped to another project, so it stays ungrouped here. + expect(layout.ungroupedThreads).toEqual([t2]); + }); +}); diff --git a/apps/web/src/sidebarThreadGrouping.ts b/apps/web/src/sidebarThreadGrouping.ts new file mode 100644 index 00000000000..72ae18d13b3 --- /dev/null +++ b/apps/web/src/sidebarThreadGrouping.ts @@ -0,0 +1,72 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; + +import type { ThreadGroup } from "./uiStateStore"; +import type { SidebarThreadSummary } from "./types"; + +/** A folder plus the visible threads it currently holds, in folder order. */ +export interface ThreadGroupSection { + group: ThreadGroup; + threads: SidebarThreadSummary[]; + expanded: boolean; +} + +export interface GroupedThreadLayout { + /** Folder sections in the project's folder order. */ + sections: ThreadGroupSection[]; + /** Threads not in any folder, preserving the input sort order. */ + ungroupedThreads: SidebarThreadSummary[]; +} + +export function threadKeyOf(thread: SidebarThreadSummary): string { + return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); +} + +/** + * Split a project's (already sorted, non-archived) threads into ordered folder + * sections plus the remaining ungrouped threads. Pure: no store access, so it is + * straightforward to unit test. Folders that reference threads not present in + * `visibleProjectThreads` simply render fewer rows; threads in no folder fall + * through to `ungroupedThreads`. + */ +export function buildGroupedThreadLayout(input: { + visibleProjectThreads: readonly SidebarThreadSummary[]; + projectKey: string; + groups: Record; + groupOrder: readonly string[]; + groupExpandedById: Record; +}): GroupedThreadLayout { + const { visibleProjectThreads, projectKey, groups, groupOrder, groupExpandedById } = input; + + const threadByKey = new Map(); + for (const thread of visibleProjectThreads) { + threadByKey.set(threadKeyOf(thread), thread); + } + + const claimed = new Set(); + const sections: ThreadGroupSection[] = []; + for (const groupId of groupOrder) { + const group = groups[groupId]; + if (!group || group.projectKey !== projectKey) { + continue; + } + const threads: SidebarThreadSummary[] = []; + for (const threadKey of group.threadKeys) { + const thread = threadByKey.get(threadKey); + if (thread) { + threads.push(thread); + claimed.add(threadKey); + } + } + sections.push({ + group, + threads, + expanded: groupExpandedById[groupId] ?? true, + }); + } + + const ungroupedThreads = visibleProjectThreads.filter( + (thread) => !claimed.has(threadKeyOf(thread)), + ); + + return { sections, ungroupedThreads }; +} diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index 297ae5717df..8629a709d84 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -32,6 +32,9 @@ import { vcsActionManager, vcsEnvironment } from "./vcs"; export type SourceControlActionKind = | "init" | "pull" + | "fetch" + | "push" + | "sync" | "publishRepository" | "runStackedAction" | "preparePullRequestThread"; @@ -58,6 +61,9 @@ interface SourceControlActionState< const ACTION_OPERATION = { init: "init", pull: "pull", + fetch: "fetch", + push: "push", + sync: "sync", publishRepository: "publish_repository", runStackedAction: "run_change_request", preparePullRequestThread: "prepare_pull_request_thread", @@ -198,6 +204,123 @@ export function useVcsPullAction(scope: SourceControlActionScope) { }); } +export function useVcsFetchAction(scope: SourceControlActionScope) { + const fetch = useAtomCommand(vcsEnvironment.fetch, { reportFailure: false }); + const status = useEnvironmentQuery( + scope.environmentId !== null && scope.cwd !== null + ? vcsEnvironment.status({ + environmentId: scope.environmentId, + input: { cwd: scope.cwd }, + }) + : null, + ); + const action = useCallback(async () => { + const target = resolveScope(scope); + if (target === null) { + return AsyncResult.failure( + Cause.fail( + new VcsActionUnavailableError({ + operation: "fetch", + environmentId: scope.environmentId, + cwd: scope.cwd, + }), + ), + ); + } + return fetch({ + environmentId: target.environmentId, + input: { cwd: target.cwd }, + }); + }, [fetch, scope]); + return useAction({ + kind: "fetch", + label: "Fetching from remote", + scope, + action, + onSuccess: status.refresh, + }); +} + +export function useVcsPushAction(scope: SourceControlActionScope) { + const push = useAtomCommand(vcsEnvironment.push, { reportFailure: false }); + const status = useEnvironmentQuery( + scope.environmentId !== null && scope.cwd !== null + ? vcsEnvironment.status({ + environmentId: scope.environmentId, + input: { cwd: scope.cwd }, + }) + : null, + ); + const action = useCallback(async () => { + const target = resolveScope(scope); + if (target === null) { + return AsyncResult.failure( + Cause.fail( + new VcsActionUnavailableError({ + operation: "push", + environmentId: scope.environmentId, + cwd: scope.cwd, + }), + ), + ); + } + return push({ + environmentId: target.environmentId, + input: { cwd: target.cwd }, + }); + }, [push, scope]); + return useAction({ + kind: "push", + label: "Pushing to remote", + scope, + action, + onSuccess: status.refresh, + }); +} + +export function useVcsSyncAction(scope: SourceControlActionScope) { + const sync = useAtomCommand(vcsEnvironment.sync, { reportFailure: false }); + const status = useEnvironmentQuery( + scope.environmentId !== null && scope.cwd !== null + ? vcsEnvironment.status({ + environmentId: scope.environmentId, + input: { cwd: scope.cwd }, + }) + : null, + ); + const action = useCallback( + async (input?: { mode?: "ff" | "rebase" }) => { + const target = resolveScope(scope); + if (target === null) { + return AsyncResult.failure( + Cause.fail( + new VcsActionUnavailableError({ + operation: "sync", + environmentId: scope.environmentId, + cwd: scope.cwd, + }), + ), + ); + } + return sync({ + environmentId: target.environmentId, + input: { + cwd: target.cwd, + ...(input?.mode ? { mode: input.mode } : {}), + }, + }); + }, + [sync, scope], + ); + return useAction({ + kind: "sync", + label: "Syncing with remote", + scope, + action, + onSuccess: status.refresh, + }); +} + export function useGitStackedAction(scope: SourceControlActionScope) { const runStackedAction = useAtomCommand(vcsActionManager.runStackedAction(scope), { reportFailure: false, diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 0fbbd79ec27..3abc0a98925 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -2,18 +2,28 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { + createThreadGroup, + deleteThreadGroup, legacyProjectCwdPreferenceKey, markThreadUnread, markThreadVisited, + moveThreadsToGroup, parsePersistedState, PERSISTED_STATE_KEY, type PersistedUiState, persistState, + renameThreadGroup, reorderProjects, + reorderThreadGroups, + reorderThreads, resolveProjectExpanded, + sanitizePersistedThreadGroups, setDefaultAdvertisedEndpointKey, setProjectExpanded, setThreadChangedFilesExpanded, + setThreadGroupExpanded, + syncThreadGroups, + toggleThreadGroup, type UiState, } from "./uiStateStore"; @@ -23,7 +33,13 @@ function makeUiState(overrides: Partial = {}): UiState { projectOrder: [], threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, + threadOrderByProject: {}, defaultAdvertisedEndpointKey: null, + worktreeLabelByPath: {}, + threadGroupsById: {}, + threadGroupOrderByProjectKey: {}, + threadGroupExpandedById: {}, + groupIdByThreadKey: {}, ...overrides, }; } @@ -171,12 +187,18 @@ describe("parsePersistedState", () => { threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, + threadOrderByProject: {}, defaultAdvertisedEndpointKey: "desktop-core:lan:http", threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, }, }, + worktreeLabelByPath: {}, + threadGroupsById: {}, + threadGroupOrderByProjectKey: {}, + threadGroupExpandedById: {}, + groupIdByThreadKey: {}, }); }); @@ -283,6 +305,11 @@ describe("uiStateStore persistence", () => { "turn-1": false, }, }, + worktreeLabelByPath: {}, + threadGroups: [], + threadGroupOrderByProjectKey: {}, + collapsedThreadGroupIds: [], + threadOrderByProject: {}, }); expect(parsePersistedState(persisted)).toEqual({ ...state, @@ -306,4 +333,219 @@ describe("uiStateStore persistence", () => { ) as PersistedUiState; expect(resolveProjectExpanded(persisted.projectExpandedById ?? {}, ["unknown"])).toBe(true); }); + + it("persists manual thread order verbatim across restart", () => { + // Thread keys are stable, so unlike project order this round-trips with no + // id→cwd remapping: what reorderThreads stores is exactly what reloads. + const state = reorderThreads( + makeUiState(), + "env-local:proj-a", + ["t-1", "t-2", "t-3"], + ["t-3"], + "t-1", + ); + expect(state.threadOrderByProject["env-local:proj-a"]).toEqual(["t-3", "t-1", "t-2"]); + + persistState(state); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + expect(persisted.threadOrderByProject).toEqual({ + "env-local:proj-a": ["t-3", "t-1", "t-2"], + }); + expect(parsePersistedState(persisted).threadOrderByProject).toEqual({ + "env-local:proj-a": ["t-3", "t-1", "t-2"], + }); + }); + + it("round-trips thread folders (membership, order, collapse) across restart", () => { + let state = createThreadGroup(makeUiState(), { + projectKey: "proj-A", + id: "g1", + name: "PRs in review", + threadKeys: ["env:t1", "env:t2"], + }); + state = createThreadGroup(state, { projectKey: "proj-A", id: "g2", name: "Experiments" }); + state = reorderThreadGroups(state, "proj-A", "g2", "g1"); + state = setThreadGroupExpanded(state, "g1", false); + persistState(state); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + expect(persisted.threadGroupOrderByProjectKey).toEqual({ "proj-A": ["g2", "g1"] }); + expect(persisted.collapsedThreadGroupIds).toEqual(["g1"]); + + const rehydrated = sanitizePersistedThreadGroups(persisted); + expect(rehydrated.threadGroupsById.g1!.threadKeys).toEqual(["env:t1", "env:t2"]); + expect(rehydrated.threadGroupOrderByProjectKey).toEqual({ "proj-A": ["g2", "g1"] }); + expect(rehydrated.threadGroupExpandedById).toEqual({ g1: false }); + expect(rehydrated.groupIdByThreadKey).toEqual({ "env:t1": "g1", "env:t2": "g1" }); + }); +}); + +describe("uiStateStore thread order", () => { + it("reorderThreads moves a thread down past its target within a project", () => { + const next = reorderThreads(makeUiState(), "proj", ["t1", "t2", "t3"], ["t1"], "t3"); + expect(next.threadOrderByProject.proj).toEqual(["t2", "t3", "t1"]); + }); + + it("reorderThreads moves a thread up before its target within a project", () => { + const next = reorderThreads(makeUiState(), "proj", ["t1", "t2", "t3"], ["t3"], "t1"); + expect(next.threadOrderByProject.proj).toEqual(["t3", "t1", "t2"]); + }); + + it("reorderThreads is a no-op when dragging onto itself", () => { + const state = makeUiState(); + expect(reorderThreads(state, "proj", ["t1", "t2"], ["t1"], "t1")).toBe(state); + }); + + it("reorderThreads is a no-op when the target is not in the live list", () => { + const state = makeUiState(); + expect(reorderThreads(state, "proj", ["t1", "t2"], ["t1"], "missing")).toBe(state); + }); +}); + +describe("uiStateStore thread folders", () => { + const P = "proj-A"; + + it("createThreadGroup registers the folder and appends to project order", () => { + const next = createThreadGroup(makeUiState(), { projectKey: P, id: "g1", name: "Experiments" }); + + expect(next.threadGroupsById.g1).toEqual({ + id: "g1", + projectKey: P, + name: "Experiments", + threadKeys: [], + }); + expect(next.threadGroupOrderByProjectKey[P]).toEqual(["g1"]); + }); + + it("createThreadGroup with members removes them from prior folders and indexes them", () => { + let state = createThreadGroup(makeUiState(), { projectKey: P, id: "g1", name: "A" }); + state = moveThreadsToGroup(state, ["t1", "t2"], "g1"); + state = createThreadGroup(state, { + projectKey: P, + id: "g2", + name: "B", + threadKeys: ["t2", "t3"], + }); + + expect(state.threadGroupsById.g1!.threadKeys).toEqual(["t1"]); + expect(state.threadGroupsById.g2!.threadKeys).toEqual(["t2", "t3"]); + expect(state.groupIdByThreadKey).toEqual({ t1: "g1", t2: "g2", t3: "g2" }); + }); + + it("moveThreadsToGroup is a single-folder move (at most one folder per thread)", () => { + let state = createThreadGroup(makeUiState(), { projectKey: P, id: "g1", name: "A" }); + state = createThreadGroup(state, { projectKey: P, id: "g2", name: "B" }); + state = moveThreadsToGroup(state, ["t1"], "g1"); + state = moveThreadsToGroup(state, ["t1"], "g2"); + + expect(state.threadGroupsById.g1!.threadKeys).toEqual([]); + expect(state.threadGroupsById.g2!.threadKeys).toEqual(["t1"]); + expect(state.groupIdByThreadKey.t1).toBe("g2"); + }); + + it("moveThreadsToGroup inserts before the target thread for ordering", () => { + let state = createThreadGroup(makeUiState(), { + projectKey: P, + id: "g1", + name: "A", + threadKeys: ["t1", "t2", "t3"], + }); + state = moveThreadsToGroup(state, ["t3"], "g1", "t1"); + + expect(state.threadGroupsById.g1!.threadKeys).toEqual(["t3", "t1", "t2"]); + }); + + it("moveThreadsToGroup with null target removes membership (back to ungrouped)", () => { + let state = createThreadGroup(makeUiState(), { + projectKey: P, + id: "g1", + name: "A", + threadKeys: ["t1", "t2"], + }); + state = moveThreadsToGroup(state, ["t1"], null); + + expect(state.threadGroupsById.g1!.threadKeys).toEqual(["t2"]); + expect(state.groupIdByThreadKey).toEqual({ t2: "g1" }); + }); + + it("deleteThreadGroup returns members to ungrouped and drops order/expanded entries", () => { + let state = createThreadGroup(makeUiState(), { + projectKey: P, + id: "g1", + name: "A", + threadKeys: ["t1"], + }); + state = setThreadGroupExpanded(state, "g1", false); + state = deleteThreadGroup(state, "g1"); + + expect(state.threadGroupsById).toEqual({}); + expect(state.threadGroupOrderByProjectKey).toEqual({}); + expect(state.threadGroupExpandedById).toEqual({}); + expect(state.groupIdByThreadKey).toEqual({}); + }); + + it("renameThreadGroup trims and ignores empty names", () => { + let state = createThreadGroup(makeUiState(), { projectKey: P, id: "g1", name: "A" }); + state = renameThreadGroup(state, "g1", " PRs in review "); + expect(state.threadGroupsById.g1!.name).toBe("PRs in review"); + expect(renameThreadGroup(state, "g1", " ")).toBe(state); + }); + + it("toggleThreadGroup flips collapse, defaulting to expanded", () => { + let state = createThreadGroup(makeUiState(), { projectKey: P, id: "g1", name: "A" }); + state = toggleThreadGroup(state, "g1"); + expect(state.threadGroupExpandedById.g1).toBe(false); + state = toggleThreadGroup(state, "g1"); + expect(state.threadGroupExpandedById.g1).toBe(true); + }); + + it("reorderThreadGroups moves a folder before another within the project", () => { + let state = createThreadGroup(makeUiState(), { projectKey: P, id: "g1", name: "A" }); + state = createThreadGroup(state, { projectKey: P, id: "g2", name: "B" }); + state = createThreadGroup(state, { projectKey: P, id: "g3", name: "C" }); + state = reorderThreadGroups(state, P, "g3", "g1"); + expect(state.threadGroupOrderByProjectKey[P]).toEqual(["g3", "g1", "g2"]); + }); + + it("syncThreadGroups prunes dead threads and drops empty folders in dead projects", () => { + let state = createThreadGroup(makeUiState(), { + projectKey: P, + id: "g1", + name: "A", + threadKeys: ["t1", "t2"], + }); + state = createThreadGroup(state, { projectKey: "dead-proj", id: "g2", name: "B" }); + + const next = syncThreadGroups(state, { + liveThreadKeys: new Set(["t1"]), + liveProjectKeys: new Set([P]), + }); + + expect(next.threadGroupsById.g1!.threadKeys).toEqual(["t1"]); + expect(next.threadGroupsById.g2).toBeUndefined(); + expect(next.groupIdByThreadKey).toEqual({ t1: "g1" }); + }); + + it("syncThreadGroups keeps empty folders that belong to a live project", () => { + const state = createThreadGroup(makeUiState(), { projectKey: P, id: "g1", name: "A" }); + const next = syncThreadGroups(state, { + liveThreadKeys: new Set(), + liveProjectKeys: new Set([P]), + }); + expect(next).toBe(state); + }); + + it("syncThreadGroups prunes stale manual ungrouped order entries", () => { + const state = reorderThreads(makeUiState(), P, ["t1", "t2", "t3"], ["t3"], "t1"); + const next = syncThreadGroups(state, { + liveThreadKeys: new Set(["t1", "t3"]), + liveProjectKeys: new Set([P]), + }); + expect(next.threadOrderByProject[P]).toEqual(["t3", "t1"]); + }); }); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 4a97f0542b4..0553340da1e 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,6 +1,7 @@ import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; import { normalizeProjectPathForComparison } from "./lib/projectPaths"; +import { randomUUID } from "./lib/utils"; export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; const LEGACY_PERSISTED_STATE_KEYS = [ @@ -25,6 +26,18 @@ export interface PersistedUiState { projectOrderCwds?: string[]; defaultAdvertisedEndpointKey?: string | null; threadChangedFilesExpandedById?: Record>; + /** + * Cosmetic display labels for worktrees, keyed by worktree PATH (not thread). + * A worktree can be shared by multiple threads, so keying by path keeps the + * label consistent across every thread pointing at the same worktree. + */ + worktreeLabelByPath?: Record; + threadGroups?: Array<{ id: string; projectKey: string; name: string; threadKeys: string[] }>; + threadGroupOrderByProjectKey?: Record; + collapsedThreadGroupIds?: string[]; + // Manual thread order, keyed by sidebar project (logical) key. Thread keys are + // stable (env + threadId), so this persists directly without id→cwd remapping. + threadOrderByProject?: Record; } export interface UiProjectState { @@ -35,20 +48,60 @@ export interface UiProjectState { export interface UiThreadState { threadLastVisitedAtById: Record; threadChangedFilesExpandedById: Record>; + /** Manual thread order per sidebar project (logical) key. */ + threadOrderByProject: Record; } export interface UiEndpointState { defaultAdvertisedEndpointKey: string | null; } -export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {} +export interface UiWorktreeState { + /** worktree path -> custom display label. See PersistedUiState.worktreeLabelByPath. */ + worktreeLabelByPath: Record; +} + +/** + * A user-defined, client-only folder that groups a single project's threads in + * the sidebar. Membership and within-folder order are both represented by the + * ordered `threadKeys` array (the single source of truth). Folders are scoped to + * one logical project via `projectKey`. + */ +export interface ThreadGroup { + /** Stable generated id (see newThreadGroupId); never derived from contents. */ + id: string; + /** Logical project key the folder lives under (same space as projectExpandedById). */ + projectKey: string; + name: string; + /** Ordered membership; the array order is the within-folder display order. */ + threadKeys: string[]; +} + +export interface UiGroupState { + threadGroupsById: Record; + /** Ordered folder ids per logical project key. */ + threadGroupOrderByProjectKey: Record; + /** Folder collapse state. Absent id defaults to expanded, like projectExpandedById. */ + threadGroupExpandedById: Record; + /** Derived reverse index (threadKey -> groupId). Rebuilt on mutation; not persisted. */ + groupIdByThreadKey: Record; +} + +export interface UiState + extends UiProjectState, UiThreadState, UiEndpointState, UiWorktreeState, UiGroupState {} const initialState: UiState = { projectExpandedById: {}, projectOrder: [], threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, + threadOrderByProject: {}, defaultAdvertisedEndpointKey: null, + worktreeLabelByPath: {}, + threadGroupsById: {}, + threadGroupOrderByProjectKey: {}, + threadGroupExpandedById: {}, + groupIdByThreadKey: {}, }; const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:"; @@ -96,6 +149,148 @@ function sanitizeTimestampRecord(value: unknown): Record { ); } +function dedupePreserveOrder(keys: readonly string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const key of keys) { + if (!seen.has(key)) { + seen.add(key); + result.push(key); + } + } + return result; +} + +function stringListsEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +/** Recompute the derived threadKey -> groupId reverse index from scratch. */ +function rebuildGroupIndex(groups: Record): Record { + const index: Record = {}; + for (const group of Object.values(groups)) { + for (const threadKey of group.threadKeys) { + index[threadKey] = group.id; + } + } + return index; +} + +/** Drop the given threadKeys from every folder. Returns the same map if unchanged. */ +function removeThreadKeysFromGroups( + groups: Record, + threadKeys: ReadonlySet, +): Record { + let changed = false; + const next: Record = {}; + for (const [id, group] of Object.entries(groups)) { + const filtered = group.threadKeys.filter((key) => !threadKeys.has(key)); + if (filtered.length !== group.threadKeys.length) { + changed = true; + next[id] = { ...group, threadKeys: filtered }; + } else { + next[id] = group; + } + } + return changed ? next : groups; +} + +function sanitizePersistedThreadOrderByProject( + value: PersistedUiState["threadOrderByProject"], +): Record { + if (!value || typeof value !== "object") { + return {}; + } + + const nextState: Record = {}; + for (const [projectKey, threadKeys] of Object.entries(value)) { + if (!projectKey || !Array.isArray(threadKeys)) { + continue; + } + const filtered = dedupePreserveOrder( + threadKeys.filter((key): key is string => typeof key === "string" && key.length > 0), + ); + if (filtered.length > 0) { + nextState[projectKey] = filtered; + } + } + + return nextState; +} + +export function sanitizePersistedThreadGroups( + parsed: PersistedUiState, +): Pick< + UiState, + | "threadGroupsById" + | "threadGroupOrderByProjectKey" + | "threadGroupExpandedById" + | "groupIdByThreadKey" +> { + const threadGroupsById: Record = {}; + for (const raw of parsed.threadGroups ?? []) { + if ( + !raw || + typeof raw !== "object" || + typeof raw.id !== "string" || + raw.id.length === 0 || + typeof raw.projectKey !== "string" || + raw.projectKey.length === 0 || + typeof raw.name !== "string" + ) { + continue; + } + const threadKeys = Array.isArray(raw.threadKeys) + ? dedupePreserveOrder( + raw.threadKeys.filter((key): key is string => typeof key === "string" && key.length > 0), + ) + : []; + threadGroupsById[raw.id] = { + id: raw.id, + projectKey: raw.projectKey, + name: raw.name, + threadKeys, + }; + } + + const threadGroupOrderByProjectKey: Record = {}; + for (const [projectKey, order] of Object.entries(parsed.threadGroupOrderByProjectKey ?? {})) { + if (typeof projectKey !== "string" || !Array.isArray(order)) { + continue; + } + const filtered = dedupePreserveOrder( + order.filter((id): id is string => typeof id === "string" && id in threadGroupsById), + ); + if (filtered.length > 0) { + threadGroupOrderByProjectKey[projectKey] = filtered; + } + } + // Defensively append any folder missing from its project's order list. + for (const group of Object.values(threadGroupsById)) { + const order = threadGroupOrderByProjectKey[group.projectKey] ?? []; + if (!order.includes(group.id)) { + threadGroupOrderByProjectKey[group.projectKey] = [...order, group.id]; + } + } + + const collapsed = new Set( + (parsed.collapsedThreadGroupIds ?? []).filter((id): id is string => typeof id === "string"), + ); + const threadGroupExpandedById: Record = {}; + for (const id of Object.keys(threadGroupsById)) { + if (collapsed.has(id)) { + threadGroupExpandedById[id] = false; + } + } + + return { + threadGroupsById, + threadGroupOrderByProjectKey, + threadGroupExpandedById, + groupIdByThreadKey: rebuildGroupIndex(threadGroupsById), + }; +} + export function parsePersistedState(parsed: PersistedUiState): UiState { const projectExpandedById = parsed.projectExpandedById === undefined @@ -127,14 +322,36 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( parsed.threadChangedFilesExpandedById, ), + threadOrderByProject: sanitizePersistedThreadOrderByProject(parsed.threadOrderByProject), defaultAdvertisedEndpointKey: typeof parsed.defaultAdvertisedEndpointKey === "string" && parsed.defaultAdvertisedEndpointKey.length > 0 ? parsed.defaultAdvertisedEndpointKey : null, + worktreeLabelByPath: sanitizePersistedWorktreeLabels(parsed.worktreeLabelByPath), + ...sanitizePersistedThreadGroups(parsed), }; } +function sanitizePersistedWorktreeLabels( + value: PersistedUiState["worktreeLabelByPath"], +): Record { + if (!value || typeof value !== "object") { + return {}; + } + const nextState: Record = {}; + for (const [path, label] of Object.entries(value)) { + if (!path || typeof label !== "string") { + continue; + } + const trimmed = label.trim(); + if (trimmed.length > 0) { + nextState[path] = trimmed; + } + } + return nextState; +} + function readPersistedState(): UiState { if (typeof window === "undefined") { return initialState; @@ -203,6 +420,15 @@ export function persistState(state: UiState): void { return Object.keys(nextTurns).length > 0 ? [[threadId, nextTurns]] : []; }), ); + const threadGroups = Object.values(state.threadGroupsById).map((group) => ({ + id: group.id, + projectKey: group.projectKey, + name: group.name, + threadKeys: group.threadKeys, + })); + const collapsedThreadGroupIds = Object.entries(state.threadGroupExpandedById) + .filter(([, expanded]) => !expanded) + .map(([id]) => id); window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ @@ -211,6 +437,11 @@ export function persistState(state: UiState): void { threadLastVisitedAtById: state.threadLastVisitedAtById, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpandedById, + worktreeLabelByPath: state.worktreeLabelByPath, + threadGroups, + threadGroupOrderByProjectKey: state.threadGroupOrderByProjectKey, + collapsedThreadGroupIds, + threadOrderByProject: state.threadOrderByProject, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -334,6 +565,42 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu }; } +export function setWorktreeLabel(state: UiState, worktreePath: string, label: string): UiState { + // The worktree path is an exact identifier — key by it verbatim so writers + // and readers (useWorktreeLabel, worktreeDisplayName) always agree. Reject a + // blank path outright. + if (!worktreePath.trim()) { + return state; + } + const trimmedLabel = label.trim(); + const currentLabel = state.worktreeLabelByPath[worktreePath]; + + // Empty label clears any existing custom name (falls back to the path-derived + // display name). + if (trimmedLabel.length === 0) { + if (currentLabel === undefined) { + return state; + } + const nextWorktreeLabelByPath = { ...state.worktreeLabelByPath }; + delete nextWorktreeLabelByPath[worktreePath]; + return { + ...state, + worktreeLabelByPath: nextWorktreeLabelByPath, + }; + } + + if (currentLabel === trimmedLabel) { + return state; + } + return { + ...state, + worktreeLabelByPath: { + ...state.worktreeLabelByPath, + [worktreePath]: trimmedLabel, + }, + }; +} + export function resolveProjectExpanded( projectExpandedById: Readonly>, preferenceKeys: readonly string[], @@ -411,17 +678,375 @@ export function reorderProjects( }; } +/** Generate a stable, unique thread-folder id. */ +export function newThreadGroupId(): string { + return `grp_${randomUUID()}`; +} + +export function createThreadGroup( + state: UiState, + args: { projectKey: string; id: string; name: string; threadKeys?: readonly string[] }, +): UiState { + const { projectKey, id, name } = args; + if (id in state.threadGroupsById) { + return state; + } + const memberKeys = dedupePreserveOrder(args.threadKeys ?? []); + const groupsWithoutMembers = + memberKeys.length > 0 + ? removeThreadKeysFromGroups(state.threadGroupsById, new Set(memberKeys)) + : state.threadGroupsById; + const nextGroups: Record = { + ...groupsWithoutMembers, + [id]: { id, projectKey, name, threadKeys: memberKeys }, + }; + const order = state.threadGroupOrderByProjectKey[projectKey] ?? []; + return { + ...state, + threadGroupsById: nextGroups, + threadGroupOrderByProjectKey: { + ...state.threadGroupOrderByProjectKey, + [projectKey]: [...order, id], + }, + groupIdByThreadKey: rebuildGroupIndex(nextGroups), + }; +} + +export function renameThreadGroup(state: UiState, groupId: string, name: string): UiState { + const group = state.threadGroupsById[groupId]; + const trimmed = name.trim(); + if (!group || trimmed.length === 0 || group.name === trimmed) { + return state; + } + return { + ...state, + threadGroupsById: { + ...state.threadGroupsById, + [groupId]: { ...group, name: trimmed }, + }, + }; +} + +export function deleteThreadGroup(state: UiState, groupId: string): UiState { + const group = state.threadGroupsById[groupId]; + if (!group) { + return state; + } + const nextGroups = { ...state.threadGroupsById }; + delete nextGroups[groupId]; + + const nextOrderByProject = { ...state.threadGroupOrderByProjectKey }; + const order = nextOrderByProject[group.projectKey]; + if (order) { + const filtered = order.filter((id) => id !== groupId); + if (filtered.length > 0) { + nextOrderByProject[group.projectKey] = filtered; + } else { + delete nextOrderByProject[group.projectKey]; + } + } + + const nextExpanded = { ...state.threadGroupExpandedById }; + delete nextExpanded[groupId]; + + return { + ...state, + threadGroupsById: nextGroups, + threadGroupOrderByProjectKey: nextOrderByProject, + threadGroupExpandedById: nextExpanded, + groupIdByThreadKey: rebuildGroupIndex(nextGroups), + }; +} + +export function toggleThreadGroup(state: UiState, groupId: string): UiState { + if (!(groupId in state.threadGroupsById)) { + return state; + } + const expanded = state.threadGroupExpandedById[groupId] ?? true; + return { + ...state, + threadGroupExpandedById: { + ...state.threadGroupExpandedById, + [groupId]: !expanded, + }, + }; +} + +export function setThreadGroupExpanded( + state: UiState, + groupId: string, + expanded: boolean, +): UiState { + if ( + !(groupId in state.threadGroupsById) || + (state.threadGroupExpandedById[groupId] ?? true) === expanded + ) { + return state; + } + return { + ...state, + threadGroupExpandedById: { + ...state.threadGroupExpandedById, + [groupId]: expanded, + }, + }; +} + +/** + * Move one or more threads into a folder (or out to ungrouped when + * targetGroupId is null). Also performs intra-folder reordering: each thread is + * first removed from whatever folder holds it, then inserted into the target at + * `beforeThreadKey` (or appended when null/absent). Mirrors reorderProjects in + * taking an array so multi-select moves work in one call. + */ +export function moveThreadsToGroup( + state: UiState, + threadKeys: readonly string[], + targetGroupId: string | null, + beforeThreadKey?: string | null, +): UiState { + if (threadKeys.length === 0) { + return state; + } + if (targetGroupId !== null && !(targetGroupId in state.threadGroupsById)) { + return state; + } + const movingKeys = dedupePreserveOrder(threadKeys); + const movingSet = new Set(movingKeys); + const groupsAfterRemoval = removeThreadKeysFromGroups(state.threadGroupsById, movingSet); + + let nextGroups = groupsAfterRemoval; + if (targetGroupId !== null) { + const target = groupsAfterRemoval[targetGroupId]!; + const insertAt = + beforeThreadKey != null + ? (() => { + const idx = target.threadKeys.indexOf(beforeThreadKey); + return idx < 0 ? target.threadKeys.length : idx; + })() + : target.threadKeys.length; + const nextThreadKeys = [ + ...target.threadKeys.slice(0, insertAt), + ...movingKeys, + ...target.threadKeys.slice(insertAt), + ]; + nextGroups = { + ...groupsAfterRemoval, + [targetGroupId]: { ...target, threadKeys: nextThreadKeys }, + }; + } + + if (nextGroups === state.threadGroupsById) { + return state; + } + return { + ...state, + threadGroupsById: nextGroups, + groupIdByThreadKey: rebuildGroupIndex(nextGroups), + }; +} + +/** Reorder a folder within its project's folder list, inserting before overGroupId. */ +export function reorderThreadGroups( + state: UiState, + projectKey: string, + draggedGroupId: string, + overGroupId: string, +): UiState { + if (draggedGroupId === overGroupId) { + return state; + } + const order = state.threadGroupOrderByProjectKey[projectKey]; + if (!order) { + return state; + } + const fromIndex = order.indexOf(draggedGroupId); + const toIndex = order.indexOf(overGroupId); + if (fromIndex < 0 || toIndex < 0) { + return state; + } + const next = [...order]; + next.splice(fromIndex, 1); + const insertAt = next.indexOf(overGroupId); + next.splice(insertAt, 0, draggedGroupId); + return { + ...state, + threadGroupOrderByProjectKey: { + ...state.threadGroupOrderByProjectKey, + [projectKey]: next, + }, + }; +} + +/** + * Reorder threads within a single sidebar project (or group). `orderedThreadKeys` + * is the full, currently-displayed order — it seeds the persisted order even when + * the user has never dragged this project before. Mirrors `reorderProjects`, but + * operates on the live order rather than a pre-existing persisted array so it + * works the first time a thread is dragged. + */ +export function reorderThreads( + state: UiState, + projectKey: string, + orderedThreadKeys: readonly string[], + draggedThreadKeys: readonly string[], + targetThreadKey: string, +): UiState { + if (draggedThreadKeys.length === 0) { + return state; + } + const draggedSet = new Set(draggedThreadKeys); + if (draggedSet.has(targetThreadKey)) { + return state; + } + + const nextOrder = [...orderedThreadKeys]; + const originalTargetIndex = nextOrder.indexOf(targetThreadKey); + if (originalTargetIndex < 0) { + return state; + } + + const removed: string[] = []; + let draggedBeforeTarget = 0; + for (let i = nextOrder.length - 1; i >= 0; i--) { + if (draggedSet.has(nextOrder[i]!)) { + removed.unshift(nextOrder.splice(i, 1)[0]!); + if (i < originalTargetIndex) { + draggedBeforeTarget++; + } + } + } + if (removed.length === 0) { + return state; + } + + const insertIndex = originalTargetIndex - Math.max(0, draggedBeforeTarget - 1); + nextOrder.splice(insertIndex, 0, ...removed); + + const previousOrder = state.threadOrderByProject[projectKey]; + if (previousOrder && stringListsEqual(previousOrder, nextOrder)) { + return state; + } + + return { + ...state, + threadOrderByProject: { + ...state.threadOrderByProject, + [projectKey]: nextOrder, + }, + }; +} + +/** + * Garbage-collect folder state against the live snapshot: drop memberships for + * threads that no longer exist, and drop folders that have become empty AND + * whose project is no longer rendered. Empty folders in a live project are kept + * (a freshly-created folder must survive). Folders whose projectKey is stale but + * still hold live members are left intact (their members fall back to ungrouped + * until the project reappears). + */ +export function syncThreadGroups( + state: UiState, + args: { liveThreadKeys: ReadonlySet; liveProjectKeys: ReadonlySet }, +): UiState { + const { liveThreadKeys, liveProjectKeys } = args; + let changed = false; + const nextGroups: Record = {}; + for (const [id, group] of Object.entries(state.threadGroupsById)) { + const prunedKeys = group.threadKeys.filter((key) => liveThreadKeys.has(key)); + const projectLive = liveProjectKeys.has(group.projectKey); + if (prunedKeys.length === 0 && !projectLive) { + changed = true; + continue; + } + if (prunedKeys.length !== group.threadKeys.length) { + changed = true; + nextGroups[id] = { ...group, threadKeys: prunedKeys }; + } else { + nextGroups[id] = group; + } + } + + // Also prune manual ungrouped order entries for threads that no longer exist. + const nextThreadOrderByProject: Record = {}; + let orderChanged = false; + for (const [projectKey, threadKeys] of Object.entries(state.threadOrderByProject)) { + const retained = threadKeys.filter((key) => liveThreadKeys.has(key)); + if (retained.length !== threadKeys.length) { + orderChanged = true; + } + if (retained.length > 0) { + nextThreadOrderByProject[projectKey] = retained; + } else if (threadKeys.length > 0) { + orderChanged = true; + } + } + + if (!changed && !orderChanged) { + return state; + } + + const nextOrderByProject: Record = {}; + for (const [projectKey, order] of Object.entries(state.threadGroupOrderByProjectKey)) { + const filtered = order.filter((id) => id in nextGroups); + if (filtered.length > 0) { + nextOrderByProject[projectKey] = filtered; + } + } + const nextExpanded: Record = {}; + for (const [id, expanded] of Object.entries(state.threadGroupExpandedById)) { + if (id in nextGroups) { + nextExpanded[id] = expanded; + } + } + return { + ...state, + threadGroupsById: changed ? nextGroups : state.threadGroupsById, + threadGroupOrderByProjectKey: changed ? nextOrderByProject : state.threadGroupOrderByProjectKey, + threadGroupExpandedById: changed ? nextExpanded : state.threadGroupExpandedById, + groupIdByThreadKey: changed ? rebuildGroupIndex(nextGroups) : state.groupIdByThreadKey, + threadOrderByProject: orderChanged ? nextThreadOrderByProject : state.threadOrderByProject, + }; +} + interface UiStateStore extends UiState { markThreadVisited: (threadId: string, visitedAt: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; + setWorktreeLabel: (worktreePath: string, label: string) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; reorderProjects: ( currentProjectOrder: readonly string[], draggedProjectIds: readonly string[], targetProjectIds: readonly string[], ) => void; + createThreadGroup: (args: { + projectKey: string; + id: string; + name: string; + threadKeys?: readonly string[]; + }) => void; + renameThreadGroup: (groupId: string, name: string) => void; + deleteThreadGroup: (groupId: string) => void; + toggleThreadGroup: (groupId: string) => void; + setThreadGroupExpanded: (groupId: string, expanded: boolean) => void; + moveThreadsToGroup: ( + threadKeys: readonly string[], + targetGroupId: string | null, + beforeThreadKey?: string | null, + ) => void; + reorderThreadGroups: (projectKey: string, draggedGroupId: string, overGroupId: string) => void; + reorderThreads: ( + projectKey: string, + orderedThreadKeys: readonly string[], + draggedThreadKeys: readonly string[], + targetThreadKey: string, + ) => void; + syncThreadGroups: (args: { + liveThreadKeys: ReadonlySet; + liveProjectKeys: ReadonlySet; + }) => void; } export const useUiStateStore = create((set) => ({ @@ -434,14 +1059,41 @@ export const useUiStateStore = create((set) => ({ set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), setDefaultAdvertisedEndpointKey: (key) => set((state) => setDefaultAdvertisedEndpointKey(state, key)), + setWorktreeLabel: (worktreePath, label) => + set((state) => setWorktreeLabel(state, worktreePath, label)), setProjectExpanded: (projectIds, expanded) => set((state) => setProjectExpanded(state, projectIds, expanded)), reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => set((state) => reorderProjects(state, currentProjectOrder, draggedProjectIds, targetProjectIds), ), + createThreadGroup: (args) => set((state) => createThreadGroup(state, args)), + renameThreadGroup: (groupId, name) => set((state) => renameThreadGroup(state, groupId, name)), + deleteThreadGroup: (groupId) => set((state) => deleteThreadGroup(state, groupId)), + toggleThreadGroup: (groupId) => set((state) => toggleThreadGroup(state, groupId)), + setThreadGroupExpanded: (groupId, expanded) => + set((state) => setThreadGroupExpanded(state, groupId, expanded)), + moveThreadsToGroup: (threadKeys, targetGroupId, beforeThreadKey) => + set((state) => moveThreadsToGroup(state, threadKeys, targetGroupId, beforeThreadKey)), + reorderThreadGroups: (projectKey, draggedGroupId, overGroupId) => + set((state) => reorderThreadGroups(state, projectKey, draggedGroupId, overGroupId)), + reorderThreads: (projectKey, orderedThreadKeys, draggedThreadKeys, targetThreadKey) => + set((state) => + reorderThreads(state, projectKey, orderedThreadKeys, draggedThreadKeys, targetThreadKey), + ), + syncThreadGroups: (args) => set((state) => syncThreadGroups(state, args)), })); +/** + * Subscribe to the custom label for a single worktree path. Returns null when + * no custom label is set (callers fall back to the path-derived name). + */ +export function useWorktreeLabel(worktreePath: string | null | undefined): string | null { + return useUiStateStore((state) => + worktreePath ? (state.worktreeLabelByPath[worktreePath] ?? null) : null, + ); +} + useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); if (typeof window !== "undefined" && typeof window.addEventListener === "function") { diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index 109f71ccd9a..77d1c100e44 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -43,3 +43,21 @@ export function formatWorktreePathForDisplay(worktreePath: string): string { const lastPart = parts[parts.length - 1]?.trim() ?? ""; return lastPart.length > 0 ? lastPart : trimmed; } + +/** + * Resolve the name to show for a worktree: the user-assigned label if one exists + * for this path, otherwise the path-derived display name. Labels are keyed by + * worktree PATH (not thread) so threads sharing a worktree show the same name. + * + * Use this everywhere a worktree name renders so the UI stays consistent. + */ +export function worktreeDisplayName( + worktreePath: string, + labelByPath: Readonly>, +): string { + const trimmedLabel = labelByPath[worktreePath]?.trim(); + if (trimmedLabel && trimmedLabel.length > 0) { + return trimmedLabel; + } + return formatWorktreePathForDisplay(worktreePath); +} diff --git a/apps/web/src/worktreeRenameStore.ts b/apps/web/src/worktreeRenameStore.ts new file mode 100644 index 00000000000..9f6f0489ec5 --- /dev/null +++ b/apps/web/src/worktreeRenameStore.ts @@ -0,0 +1,26 @@ +import { create } from "zustand"; + +/** + * Ephemeral UI state for the "Rename worktree" dialog. The dialog is mounted + * once globally (see WorktreeRenameDialog) so any surface — the sidebar thread + * context menu, the bottom-bar workspace label — can open it for a given + * worktree path. Not persisted; the labels themselves live in useUiStateStore. + */ +interface WorktreeRenameStore { + /** Worktree path currently being renamed, or null when the dialog is closed. */ + targetPath: string | null; + openWorktreeRename: (worktreePath: string) => void; + closeWorktreeRename: () => void; +} + +export const useWorktreeRenameStore = create((set) => ({ + targetPath: null, + openWorktreeRename: (worktreePath) => { + // Store the path verbatim — it's the exact key labels are written/read + // under (see setWorktreeLabel). Ignore a blank path. + if (worktreePath.trim().length > 0) { + set({ targetPath: worktreePath }); + } + }, + closeWorktreeRename: () => set({ targetPath: null }), +})); diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index aed63cd442d..46c2bda32ef 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -73,6 +73,11 @@ export function sortThreads threads: readonly T[], sortOrder: SidebarThreadSortOrder, ): T[] { + // Manual sort preserves the incoming order; the caller layers the + // user-defined order on top via `orderItemsByPreferredIds`. + if (sortOrder === "manual") { + return [...threads]; + } return Arr.sort( threads, Order.mapInput( diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 4ef94c2619f..a12db49040e 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -182,6 +182,24 @@ export function createVcsEnvironmentAtoms( scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, }), + fetch: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:fetch", + tag: WS_METHODS.vcsFetch, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + push: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:push", + tag: WS_METHODS.vcsPush, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + sync: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:sync", + tag: WS_METHODS.vcsSync, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), refreshStatus: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:refresh-status", tag: WS_METHODS.vcsRefreshStatus, diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index f2cba39cf9d..9cdce0ef60a 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -29,6 +29,9 @@ export const VcsActionOperation = Schema.Literals([ "refresh_status", "run_change_request", "pull", + "fetch", + "push", + "sync", "switch_ref", "create_ref", "create_worktree", diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index aa5cdf8432b..e3ae84a77d8 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,6 +1,10 @@ import * as Schema from "effect/Schema"; import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; -import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts"; +import { + ChangeRequestChecks, + SourceControlProviderError, + SourceControlProviderInfo, +} from "./sourceControl.ts"; import { VcsDriverKind } from "./vcs.ts"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; @@ -109,6 +113,27 @@ export const VcsPullInput = Schema.Struct({ }); export type VcsPullInput = typeof VcsPullInput.Type; +export const VcsFetchInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, +}); +export type VcsFetchInput = typeof VcsFetchInput.Type; + +export const VcsPushInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, +}); +export type VcsPushInput = typeof VcsPushInput.Type; + +export const VcsSyncMode = Schema.Literals(["ff", "rebase"]); +export type VcsSyncMode = typeof VcsSyncMode.Type; + +export const VcsSyncInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + // "ff" (default) refuses to integrate diverged history; "rebase" replays local + // commits onto the upstream after the user explicitly confirms it. + mode: Schema.optional(VcsSyncMode), +}); +export type VcsSyncInput = typeof VcsSyncInput.Type; + export const GitRunStackedActionInput = Schema.Struct({ actionId: TrimmedNonEmptyStringSchema, cwd: TrimmedNonEmptyStringSchema, @@ -196,6 +221,7 @@ const VcsStatusChangeRequest = Schema.Struct({ baseRef: TrimmedNonEmptyStringSchema, headRef: TrimmedNonEmptyStringSchema, state: VcsStatusChangeRequestState, + checks: Schema.optional(Schema.NullOr(ChangeRequestChecks)), }); const VcsStatusLocalShape = { @@ -319,6 +345,31 @@ export const VcsPullResult = Schema.Struct({ }); export type VcsPullResult = typeof VcsPullResult.Type; +export const VcsFetchResult = Schema.Struct({ + refName: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), + hasUpstream: Schema.Boolean, +}); +export type VcsFetchResult = typeof VcsFetchResult.Type; + +export const VcsPushResult = Schema.Struct({ + status: Schema.Literals(["pushed", "skipped_up_to_date"]), + refName: TrimmedNonEmptyStringSchema, + upstreamRef: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), + // True when this push created the upstream (publish / set -u) rather than + // pushing to an already-configured upstream. + setUpstream: Schema.Boolean, +}); +export type VcsPushResult = typeof VcsPushResult.Type; + +export const VcsSyncResult = Schema.Struct({ + refName: TrimmedNonEmptyStringSchema, + fetched: Schema.Boolean, + pull: Schema.Literals(["pulled", "rebased", "skipped_up_to_date", "skipped"]), + push: Schema.Literals(["pushed", "skipped"]), + setUpstream: Schema.Boolean, +}); +export type VcsSyncResult = typeof VcsSyncResult.Type; + // RPC / domain errors export class GitCommandError extends Schema.TaggedErrorClass()("GitCommandError", { operation: Schema.String, @@ -337,6 +388,23 @@ export class GitCommandError extends Schema.TaggedErrorClass()( } } +// Raised when `vcs.sync` cannot fast-forward because local and upstream history +// have diverged. The client surfaces this distinctly to offer an explicit rebase. +export class GitDivergedError extends Schema.TaggedErrorClass()( + "GitDivergedError", + { + operation: Schema.String, + cwd: Schema.String, + refName: Schema.String, + aheadCount: NonNegativeInt, + behindCount: NonNegativeInt, + }, +) { + override get message(): string { + return `Branch '${this.refName}' has diverged from its upstream (${this.aheadCount} ahead, ${this.behindCount} behind). Rebase or merge before syncing.`; + } +} + export class TextGenerationError extends Schema.TaggedErrorClass()( "TextGenerationError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0356aa1807b..5d379e4de95 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -19,6 +19,13 @@ import { VcsSwitchRefInput, VcsSwitchRefResult, GitCommandError, + GitDivergedError, + VcsFetchInput, + VcsFetchResult, + VcsPushInput, + VcsPushResult, + VcsSyncInput, + VcsSyncResult, VcsCreateRefInput, VcsCreateRefResult, VcsCreateWorktreeInput, @@ -163,6 +170,9 @@ export const WS_METHODS = { // VCS methods vcsPull: "vcs.pull", + vcsFetch: "vcs.fetch", + vcsPush: "vcs.push", + vcsSync: "vcs.sync", vcsRefreshStatus: "vcs.refreshStatus", vcsListRefs: "vcs.listRefs", vcsCreateWorktree: "vcs.createWorktree", @@ -415,6 +425,24 @@ export const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, { error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); +export const WsVcsFetchRpc = Rpc.make(WS_METHODS.vcsFetch, { + payload: VcsFetchInput, + success: VcsFetchResult, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); + +export const WsVcsPushRpc = Rpc.make(WS_METHODS.vcsPush, { + payload: VcsPushInput, + success: VcsPushResult, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); + +export const WsVcsSyncRpc = Rpc.make(WS_METHODS.vcsSync, { + payload: VcsSyncInput, + success: VcsSyncResult, + error: Schema.Union([GitCommandError, GitDivergedError, EnvironmentAuthorizationError]), +}); + export const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, { payload: VcsStatusInput, success: VcsStatusResult, @@ -716,6 +744,9 @@ export const WsRpcGroup = RpcGroup.make( WsAssetsCreateUrlRpc, WsSubscribeVcsStatusRpc, WsVcsPullRpc, + WsVcsFetchRpc, + WsVcsPushRpc, + WsVcsSyncRpc, WsVcsRefreshStatusRpc, WsGitRunStackedActionRpc, WsGitResolvePullRequestRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fe593f49199..440c06d8655 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -17,7 +17,7 @@ export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_a export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; -export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); +export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 104aadd9161..b7e8c32032f 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { VcsDriverKind } from "./vcs.ts"; export const SourceControlProviderKind = Schema.Literals([ @@ -21,6 +21,19 @@ export type SourceControlProviderInfo = typeof SourceControlProviderInfo.Type; export const ChangeRequestState = Schema.Literals(["open", "closed", "merged"]); export type ChangeRequestState = typeof ChangeRequestState.Type; +export const ChangeRequestChecksState = Schema.Literals(["passing", "failing", "pending"]); +export type ChangeRequestChecksState = typeof ChangeRequestChecksState.Type; + +/** + * Rolled-up CI status for a change request, derived from the provider's check + * runs / status contexts. `failingCount` is only meaningful when failing. + */ +export const ChangeRequestChecks = Schema.Struct({ + state: ChangeRequestChecksState, + failingCount: NonNegativeInt, +}); +export type ChangeRequestChecks = typeof ChangeRequestChecks.Type; + export const ChangeRequest = Schema.Struct({ provider: SourceControlProviderKind, number: PositiveInt, @@ -33,6 +46,7 @@ export const ChangeRequest = Schema.Struct({ isCrossRepository: Schema.optional(Schema.Boolean), headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), headRepositoryOwnerLogin: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + checks: Schema.optional(Schema.NullOr(ChangeRequestChecks)), }); export type ChangeRequest = typeof ChangeRequest.Type; From 985513a76bdb28d8f2aeaa7dcee506a622d7b6ee Mon Sep 17 00:00:00 2001 From: TheIcarusWings <10465470+TheIcarusWings@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:17:20 +0100 Subject: [PATCH 2/3] fix(sidebar,git): address review findings from Bugbot and Macroscope - clear click-suppression flags on drag cancel so the next click is not swallowed - close the diverged toast before starting Rebase & sync to prevent stacked retries - make digit-jump hints and ctrl+arrow traversal folder-aware so shortcuts match the rendered order (folders first, then the ungrouped list) - scope multi-select folder moves to threads in the clicked project and hide the entry for cross-project-only selections - persist ungrouped reorder against the full (uncapped) list and include newly ungrouped dragged threads so the drop position applies --- apps/web/src/components/GitSyncControl.tsx | 7 +- apps/web/src/components/Sidebar.logic.test.ts | 28 ++++++ apps/web/src/components/Sidebar.logic.ts | 11 ++- apps/web/src/components/Sidebar.tsx | 88 +++++++++++++++---- 4 files changed, 115 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/GitSyncControl.tsx b/apps/web/src/components/GitSyncControl.tsx index 29138f536bc..c9502a2a6f4 100644 --- a/apps/web/src/components/GitSyncControl.tsx +++ b/apps/web/src/components/GitSyncControl.tsx @@ -179,7 +179,12 @@ export function GitSyncControl({ environmentId, cwd, status, className }: GitSyn data: { secondaryActionProps: { children: "Rebase & sync", - onClick: () => onSync("rebase"), + // Close the diverged toast first, otherwise it stays visible + // with a live button and a second rebase can be started. + onClick: () => { + toastManager.close(toastId); + onSync("rebase"); + }, }, secondaryActionVariant: "outline", }, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 1dd4e340125..52411779a53 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -105,6 +105,34 @@ describe("buildMultiSelectThreadContextMenuItems", () => { buildMultiSelectThreadContextMenuItems({ count: 2, hasRunningThread: true }), ).toContainEqual({ id: "archive", label: "Archive (2)", disabled: true }); }); + + it("labels the folder move with the count of movable threads only", () => { + const items = buildMultiSelectThreadContextMenuItems({ + count: 5, + hasRunningThread: false, + folderMenuChildren: [{ id: "move:__new__", label: "New folder…" }], + folderMoveCount: 3, + }); + expect(items.map((item) => item.id)).toEqual([ + "mark-unread", + "move-submenu", + "archive", + "delete", + ]); + expect(items).toContainEqual( + expect.objectContaining({ id: "move-submenu", label: "Move 3 to folder" }), + ); + }); + + it("hides the folder move when no selected thread belongs to the project", () => { + const items = buildMultiSelectThreadContextMenuItems({ + count: 2, + hasRunningThread: false, + folderMenuChildren: [{ id: "move:__new__", label: "New folder…" }], + folderMoveCount: 0, + }); + expect(items.map((item) => item.id)).toEqual(["mark-unread", "archive", "delete"]); + }); }); describe("resolveSidebarStageBadgeLabel", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 2fdfde1f1e5..ac561fe56ef 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -75,14 +75,21 @@ export function buildMultiSelectThreadContextMenuItems(input: { count: number; hasRunningThread: boolean; folderMenuChildren?: readonly ContextMenuItem[]; + /** + * Threads eligible for the folder move. Folders are project-scoped, so this + * can be smaller than `count` when the selection spans projects. The move + * entry is hidden entirely when nothing in the selection belongs here. + */ + folderMoveCount?: number; }): readonly ContextMenuItem[] { + const folderMoveCount = input.folderMoveCount ?? input.count; return [ { id: "mark-unread", label: `Mark unread (${input.count})` }, - ...(input.folderMenuChildren + ...(input.folderMenuChildren && folderMoveCount > 0 ? [ { id: "move-submenu", - label: `Move ${input.count} to folder`, + label: `Move ${folderMoveCount} to folder`, children: input.folderMenuChildren, }, ] diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4633a25ab57..876089992f1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1554,6 +1554,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const { sections, ungroupedRenderedThreads, + ungroupedThreadKeys, orderedRenderedThreadKeys, hasOverflowingThreads, hiddenThreadStatus, @@ -1619,6 +1620,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return { sections: layout.sections, ungroupedRenderedThreads, + // Full (uncapped) ungrouped order; persisting a capped list would + // truncate the stored manual order for overflow threads. + ungroupedThreadKeys: ungrouped.map(threadKeyOf), orderedRenderedThreadKeys, hasOverflowingThreads, hiddenThreadStatus: resolveProjectStatusIndicator( @@ -2116,12 +2120,19 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const hasRunningThread = selectedThreadEntries.some( ({ thread }) => thread.session?.status === "running" && thread.session.activeTurnId != null, ); + // Folders are scoped to this project; a cross-project selection must not + // move foreign threads into (or out of) this project's folders. + const memberProjectIds = new Set(project.memberProjects.map((member) => member.id)); + const folderMovableThreadKeys = selectedThreadEntries + .filter(({ thread }) => memberProjectIds.has(thread.projectId)) + .map(({ threadKey }) => threadKey); const clicked = await api.contextMenu.show( buildMultiSelectThreadContextMenuItems({ count, hasRunningThread, folderMenuChildren: buildFolderMenuChildren(), + folderMoveCount: folderMovableThreadKeys.length, }), position, ); @@ -2135,7 +2146,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } if (clicked && clicked.startsWith("move:")) { - applyFolderMove(clicked, threadKeys); + applyFolderMove(clicked, folderMovableThreadKeys); clearSelection(); return; } @@ -2223,6 +2234,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec clearSelection, deleteThread, markThreadUnread, + project.memberProjects, removeFromSelection, ], ); @@ -2698,6 +2710,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const handleThreadDragCancel = useCallback((_event: DragCancelEvent) => { threadDragInProgressRef.current = false; + // dnd-kit can cancel a pointer drag without emitting the trailing click these + // guards expect, so clear them here or the next click is swallowed. + suppressThreadClickAfterDragRef.current = false; + suppressGroupClickAfterDragRef.current = false; setActiveDragLabel(null); }, []); @@ -2739,13 +2755,18 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (overGroupId === null) { // Reordering within the ungrouped (main) list. Ensure the dragged rows // are ungrouped, then persist their position relative to the drop target. + // Base the persisted order on the full ungrouped list, and append any + // dragged keys that were still in a folder when this render captured + // it — reorderThreads no-ops on keys missing from the list. moveThreadsToGroup(draggedKeys, null); - reorderThreadsAction( - project.projectKey, - ungroupedRenderedThreads.map(threadKeyOf), - draggedKeys, - overId, - ); + const orderedKeys = [...ungroupedThreadKeys]; + const orderedKeySet = new Set(orderedKeys); + for (const key of draggedKeys) { + if (!orderedKeySet.has(key)) { + orderedKeys.push(key); + } + } + reorderThreadsAction(project.projectKey, orderedKeys, draggedKeys, overId); } else { moveThreadsToGroup(draggedKeys, overGroupId, overId); } @@ -2756,7 +2777,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec reorderThreadGroupsAction, reorderThreadsAction, resolveDraggedThreadKeys, - ungroupedRenderedThreads, + ungroupedThreadKeys, ], ); @@ -4005,6 +4026,14 @@ export default function Sidebar() { visibleThreads, ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; + // Folder/manual-order state, so keyboard traversal below matches the + // per-project rendered order (folders first, then the ungrouped list). + const allThreadGroupsById = useUiStateStore((state) => state.threadGroupsById); + const allThreadGroupExpandedById = useUiStateStore((state) => state.threadGroupExpandedById); + const threadGroupOrderByProjectKey = useUiStateStore( + (state) => state.threadGroupOrderByProjectKey, + ); + const threadOrderByProject = useUiStateStore((state) => state.threadOrderByProject); const visibleSidebarThreadKeys = useMemo( () => sortedProjects.flatMap((project) => { @@ -4031,24 +4060,51 @@ export default function Sidebar() { if (!shouldShowThreadPanel) { return []; } + if (pinnedCollapsedThread) { + return [ + scopedThreadKey( + scopeThreadRef(pinnedCollapsedThread.environmentId, pinnedCollapsedThread.id), + ), + ]; + } + // Mirror the per-project rendered order: expanded folder sections first, + // then the manually-ordered ungrouped list, which alone is preview-capped. + const layout = buildGroupedThreadLayout({ + visibleProjectThreads: projectThreads, + projectKey: project.projectKey, + groups: allThreadGroupsById, + groupOrder: threadGroupOrderByProjectKey[project.projectKey] ?? [], + groupExpandedById: allThreadGroupExpandedById, + }); + const ungrouped = orderItemsByPreferredIds({ + items: layout.ungroupedThreads, + preferredIds: threadOrderByProject[project.projectKey] ?? [], + getId: threadKeyOf, + }); const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); - const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount; - const previewThreads = + const hasOverflowingThreads = ungrouped.length > sidebarThreadPreviewCount; + const ungroupedRendered = isThreadListExpanded || !hasOverflowingThreads - ? projectThreads - : projectThreads.slice(0, sidebarThreadPreviewCount); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - return renderedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); + ? ungrouped + : ungrouped.slice(0, sidebarThreadPreviewCount); + return [ + ...layout.sections.flatMap((section) => + section.expanded ? section.threads.map(threadKeyOf) : [], + ), + ...ungroupedRendered.map(threadKeyOf), + ]; }), [ + allThreadGroupExpandedById, + allThreadGroupsById, sidebarThreadSortOrder, sidebarThreadPreviewCount, expandedThreadListsByProject, projectExpandedById, routeThreadKey, sortedProjects, + threadGroupOrderByProjectKey, + threadOrderByProject, threadsByProjectKey, ], ); From 6f3cfd34f54c7ae20750de53470e94e7cc8c57d9 Mon Sep 17 00:00:00 2001 From: TheIcarusWings <10465470+TheIcarusWings@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:38:54 +0100 Subject: [PATCH 3/3] fix(sidebar,git): address remaining Macroscope review findings - guard the folder GC against empty bootstrap/disconnect snapshots, both at the sidebar effect and inside syncThreadGroups, so a reload can no longer wipe persisted folders and manual order - treat GitHub's STALE check conclusion as failing; a stale required check still blocks merging - keep sortThreads deterministic in manual mode by sorting on recency; the sidebar still layers the user-defined order via orderItemsByPreferredIds, and latest-thread pickers no longer depend on input array order --- apps/server/src/sourceControl/GitHubCli.test.ts | 4 +++- .../src/sourceControl/gitHubPullRequests.ts | 3 +++ apps/web/src/components/Sidebar.tsx | 8 ++++++++ apps/web/src/uiStateStore.test.ts | 15 +++++++++++++++ apps/web/src/uiStateStore.ts | 6 ++++++ packages/client-runtime/src/state/threadSort.ts | 9 ++++----- 6 files changed, 39 insertions(+), 6 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 0ea897679e2..01c793edd97 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -129,6 +129,8 @@ describe("GitHubCli.layer", () => { { __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS" }, { __typename: "CheckRun", status: "COMPLETED", conclusion: "FAILURE" }, { __typename: "StatusContext", state: "ERROR" }, + // A stale required check still blocks merging on GitHub. + { __typename: "CheckRun", status: "COMPLETED", conclusion: "STALE" }, { __typename: "CheckRun", status: "IN_PROGRESS", conclusion: null }, ], }), @@ -139,7 +141,7 @@ describe("GitHubCli.layer", () => { const gh = yield* GitHubCli.GitHubCli; const result = yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" }); - assert.deepStrictEqual(result.checks, { state: "failing", failingCount: 2 }); + assert.deepStrictEqual(result.checks, { state: "failing", failingCount: 3 }); }).pipe(Effect.provide(layer)), ); diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index bb02bfb07b0..6805d946317 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -55,6 +55,9 @@ const FAILING_CHECK_CONCLUSIONS = new Set([ "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE", + // GitHub only treats SUCCESS, SKIPPED, and NEUTRAL as satisfied; a STALE + // required check still blocks merging. + "STALE", ]); const PENDING_CHECK_STATUSES = new Set([ "QUEUED", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 876089992f1..e63beb19583 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4256,6 +4256,14 @@ export default function Sidebar() { // drives the cleanup: drop memberships for deleted threads and prune empty // folders whose project is gone. useEffect(() => { + // Projects and threads start as empty arrays while the shell snapshot + // bootstraps (and go empty again on disconnect). Treating that transient + // state as authoritative would wipe every persisted folder and manual + // order, and the persistence subscriber would write the loss to disk — + // so only GC against a snapshot that actually has content. + if (sidebarProjects.length === 0 || sidebarThreads.length === 0) { + return; + } const liveThreadKeys = new Set( sidebarThreads.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 3abc0a98925..57fef8c933d 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -540,6 +540,21 @@ describe("uiStateStore thread folders", () => { expect(next).toBe(state); }); + it("syncThreadGroups ignores an entirely empty snapshot (bootstrap guard)", () => { + let state = createThreadGroup(makeUiState(), { + projectKey: P, + id: "g1", + name: "A", + threadKeys: ["t1"], + }); + state = reorderThreads(state, P, ["t1", "t2"], ["t2"], "t1"); + const next = syncThreadGroups(state, { + liveThreadKeys: new Set(), + liveProjectKeys: new Set(), + }); + expect(next).toBe(state); + }); + it("syncThreadGroups prunes stale manual ungrouped order entries", () => { const state = reorderThreads(makeUiState(), P, ["t1", "t2", "t3"], ["t3"], "t1"); const next = syncThreadGroups(state, { diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 0553340da1e..ade3a63b5af 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -950,6 +950,12 @@ export function syncThreadGroups( args: { liveThreadKeys: ReadonlySet; liveProjectKeys: ReadonlySet }, ): UiState { const { liveThreadKeys, liveProjectKeys } = args; + // An entirely empty snapshot is indistinguishable from a client that has + // not finished bootstrapping; pruning against it would destroy all + // persisted folder state. Callers should also gate on readiness. + if (liveThreadKeys.size === 0 && liveProjectKeys.size === 0) { + return state; + } let changed = false; const nextGroups: Record = {}; for (const [id, group] of Object.entries(state.threadGroupsById)) { diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index 46c2bda32ef..b130a83e8eb 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -73,11 +73,10 @@ export function sortThreads threads: readonly T[], sortOrder: SidebarThreadSortOrder, ): T[] { - // Manual sort preserves the incoming order; the caller layers the - // user-defined order on top via `orderItemsByPreferredIds`. - if (sortOrder === "manual") { - return [...threads]; - } + // Manual mode still sorts by recency here: the sidebar layers the + // user-defined order on top via `orderItemsByPreferredIds`, while callers + // that pick "latest" or render flat lists need a deterministic order + // rather than whatever the input array happened to contain. return Arr.sort( threads, Order.mapInput(