From 7d9aaf6a7eb22d5e85b0e3b21686cc437fe68576 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:37:37 +0300 Subject: [PATCH 01/22] feat(web): add pull request merge defaults (#8088) --- .../settings/DesktopClientSettings.test.ts | 1 + .../pullRequest/PullRequestDetailPanel.tsx | 85 ++++++++++++++----- .../pullRequestDetail.logic.test.ts | 16 ++++ .../pullRequest/pullRequestDetail.logic.ts | 19 +++++ .../settings/ProjectSettingsPanel.tsx | 51 +++++++++++ apps/web/src/uiStateStore.test.ts | 15 ++++ apps/web/src/uiStateStore.ts | 26 +++++- packages/contracts/src/settings.test.ts | 19 +++++ packages/contracts/src/settings.ts | 8 ++ 9 files changed, 219 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 83ad62ccffdc..ea2a80010124 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -64,6 +64,7 @@ const clientSettings: ClientSettings = { legacySidebarEnabled: false, loadBalancingEnabled: false, loadBalancingWeights: { "environment-1": 75, "environment-2": 0 }, + pullRequestMergeMethodOverrides: {}, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 733a25a35204..de6f32aaa121 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -53,14 +53,21 @@ import { import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; +import { useClientSettings } from "~/hooks/useSettings"; import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + selectProjectGroupingSettings, +} from "~/logicalProject"; import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink"; import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import type { ReviewCommentContext } from "~/reviewCommentContext"; +import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; import { useProjects } from "~/state/entities"; -import { useEnvironments } from "~/state/environments"; +import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; import { @@ -71,6 +78,7 @@ import { import { useAtomCommand } from "~/state/use-atom-command"; import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; +import { useUiStateStore } from "~/uiStateStore"; import { AlertDialog, @@ -124,11 +132,13 @@ import { pullRequestCheckoutCommand, pullRequestFindingKey, pullRequestHandoffLabels, + PULL_REQUEST_MERGE_METHOD_LABELS, readableFailure, readPullRequestDetailSnapshot, resolveDisplayedPullRequestDetail, resolvePullRequestPrimaryControl, resolveBaseFreshness, + resolvePullRequestMergeMethod, type PullRequestFinding, shouldRefreshPullRequestActivity, writePullRequestDetailSnapshot, @@ -168,12 +178,6 @@ const ACTION_SUCCESS_LABELS: Record = { "approve-workflows": "Workflows approved", }; -const MERGE_METHOD_LABELS: Record = { - merge: "Merge", - squash: "Squash", - rebase: "Rebase", -}; - /** Said as the thing that did not happen, rather than as the operation that returned an error. */ const ACTION_FAILURE_LABELS: Record = { merge: "Could not merge this pull request", @@ -553,12 +557,16 @@ export function PullRequestDetailPanel({ compensationRef.current = null; if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta); }, [condensed]); + const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod); + const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod); + const mergeMethodOverrides = useClientSettings( + (settings) => settings.pullRequestMergeMethodOverrides, + ); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const [mergeMethodSelection, setMergeMethodSelection] = useState<{ readonly pullRequestKey: string; readonly method: PullRequestMergeMethod; - }>(() => ({ pullRequestKey, method: "merge" })); - const mergeMethod = - mergeMethodSelection.pullRequestKey === pullRequestKey ? mergeMethodSelection.method : "merge"; + } | null>(null); const setMergeMethod = (method: PullRequestMergeMethod) => { setMergeMethodSelection({ pullRequestKey, method }); }; @@ -763,6 +771,7 @@ export function PullRequestDetailPanel({ const [titleSaving, setTitleSaving] = useState(false); const newThread = useNewThreadHandler(); const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); const projects = useProjects(); const unavailableGitHubUrl = useMemo(() => { const identity = projects.find( @@ -770,6 +779,30 @@ export function PullRequestDetailPanel({ )?.repositoryIdentity; return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number); }, [environmentId, projects, reference.number, reference.projectId, reference.repository]); + // Project settings store the override under the sidebar group's key, which a duplicate row + // borrows from its siblings, so the project alone does not always name the same key. + const projectDefaultMergeMethod = useMemo(() => { + const project = projects.find( + (candidate) => + candidate.environmentId === environmentId && candidate.id === reference.projectId, + ); + if (!project) return undefined; + const projectKey = + buildPhysicalToLogicalProjectKeyMap({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }).get(derivePhysicalProjectKey(project)) ?? + deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings); + return mergeMethodOverrides[projectKey]; + }, [ + environmentId, + mergeMethodOverrides, + primaryEnvironmentId, + projectGroupingSettings, + projects, + reference.projectId, + ]); // Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and // the thread is already on one server's copy of the branch. const pickableEnvironments = useMemo( @@ -1234,10 +1267,15 @@ export function PullRequestDetailPanel({ const allowedMergeMethods = detail ? detail.capabilities.mergeMethods.filter((method) => detail.mergeCapabilities[method]) : []; - const selectedMergeMethod = allowedMergeMethods.includes(mergeMethod) - ? mergeMethod - : (allowedMergeMethods[0] ?? "merge"); - const selectedMergeMethodLabel = MERGE_METHOD_LABELS[selectedMergeMethod]; + const currentMergeMethod = + mergeMethodSelection?.pullRequestKey === pullRequestKey ? mergeMethodSelection.method : null; + const selectedMergeMethod = resolvePullRequestMergeMethod( + allowedMergeMethods, + currentMergeMethod, + projectDefaultMergeMethod, + lastSelectedMergeMethod, + ); + const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod]; const pendingAutoMergeLabel = `Auto-merge (${selectedMergeMethodLabel.toLowerCase()})`; const conflicting = detail?.state === "open" && detail.mergeability === "conflicting"; // Only an outright yes arms it. A host that reports nothing has not said the merge is already @@ -1245,7 +1283,7 @@ export function PullRequestDetailPanel({ const autoMergeArmed = detail?.state === "open" && detail.autoMergeEnabled === true; const armedMergeMethod = detail?.autoMergeMethod; const armedAutoMergeLabel = armedMergeMethod - ? `Auto-merge (${MERGE_METHOD_LABELS[armedMergeMethod].toLowerCase()})` + ? `Auto-merge (${PULL_REQUEST_MERGE_METHOD_LABELS[armedMergeMethod].toLowerCase()})` : "Auto-merge"; const workflowApprovalsRequired = detail?.state === "open" ? (detail.workflowApprovalsRequired ?? 0) : 0; @@ -1783,17 +1821,24 @@ export function PullRequestDetailPanel({ ) : null} - setMergeMethod(method as PullRequestMergeMethod) - } + onValueChange={(method) => { + const selectedMethod = method as PullRequestMergeMethod; + setMergeMethod(selectedMethod); + setLastSelectedMergeMethod(selectedMethod); + }} > {allowedMergeMethods.map((method) => ( - + {/* The radio item lays its children out as one block, so the icon and the label need their own row to share a line. */} - {MERGE_METHOD_LABELS[method]} + {PULL_REQUEST_MERGE_METHOD_LABELS[method]} ))} diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 61c630c815e4..000c594ec6ec 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -37,6 +37,7 @@ import { resolvePullRequestPrimaryControl, shouldRefreshPullRequestActivity, resolveBaseFreshness, + resolvePullRequestMergeMethod, buildPullRequestTimeline, editPullRequestThreadComment, writePullRequestDetailSnapshot, @@ -85,6 +86,21 @@ const TIMELINE_SOURCE: Pick< closedAt: null, }; +describe("pull request merge method", () => { + it("uses the current choice, then the project default, then the last choice", () => { + expect( + resolvePullRequestMergeMethod(["merge", "squash", "rebase"], null, "squash", "rebase"), + ).toBe("squash"); + expect( + resolvePullRequestMergeMethod(["merge", "squash", "rebase"], "rebase", "squash", "merge"), + ).toBe("rebase"); + expect(resolvePullRequestMergeMethod(["merge", "rebase"], null, "squash", "rebase")).toBe( + "rebase", + ); + expect(resolvePullRequestMergeMethod(["squash"], null, "merge", "rebase")).toBe("squash"); + }); +}); + describe("pull request activity refresh", () => { const first = { key: "project:acme/web#7", diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index d00215f02d41..24b37aceafc2 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -11,6 +11,7 @@ import { type PullRequestCommit, type PullRequestDetailView, type PullRequestMergeability, + type PullRequestMergeMethod, type PullRequestReaction, type PullRequestReviewThread, type PullRequestState, @@ -21,6 +22,24 @@ import { import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; +export const PULL_REQUEST_MERGE_METHOD_LABELS: Record = { + merge: "Merge", + squash: "Squash and merge", + rebase: "Rebase and merge", +}; + +export function resolvePullRequestMergeMethod( + allowed: ReadonlyArray, + current: PullRequestMergeMethod | null, + projectDefault: PullRequestMergeMethod | undefined, + lastSelected: PullRequestMergeMethod, +): PullRequestMergeMethod { + for (const method of [current, projectDefault, lastSelected]) { + if (method && allowed.includes(method)) return method; + } + return allowed[0] ?? "merge"; +} + const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 0401fd733d01..e9cd1bbed43a 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -21,6 +21,7 @@ import { type ResolvedKeybindingsConfig, type ServerSettings, type ProviderDriverKind, + type PullRequestMergeMethod, type SidebarProjectGroupingMode, type T3ProjectFileScript, type ThreadEnvMode, @@ -79,6 +80,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { ProjectFavicon } from "../ProjectFavicon"; +import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic"; import { EMPTY_PROJECT_SCRIPT_INPUT, editorRequestForScript, @@ -480,6 +482,19 @@ function ProjectDetail({ setBooleanOverride("projectAgentBrowserAccessOverrides", enabled); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); const projectNameEditedRef = useRef(false); + const mergeMethodOverrides = useClientSettings( + (settings) => settings.pullRequestMergeMethodOverrides, + ); + const projectMergeMethod = mergeMethodOverrides[group.projectKey]; + const setProjectMergeMethod = (method: PullRequestMergeMethod | null) => { + const nextOverrides = { ...mergeMethodOverrides }; + if (method === null) { + delete nextOverrides[group.projectKey]; + } else { + nextOverrides[group.projectKey] = method; + } + updateClientSettings({ pullRequestMergeMethodOverrides: nextOverrides }); + }; const faviconPath = representative.faviconPath ?? null; const projectIcon = representative.projectIcon ?? null; @@ -983,6 +998,42 @@ function ProjectDetail({ } /> + setProjectMergeMethod(null)} + /> + ) : null + } + control={ + + } + /> = {}): UiState { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, + pullRequestMergeMethod: "merge", ...overrides, }; } @@ -158,6 +159,18 @@ describe("uiStateStore pure functions", () => { }); describe("parsePersistedState", () => { + it("hydrates the last selected pull request merge method", () => { + const parsed = parsePersistedState({ + pullRequestMergeMethod: "squash", + }); + const invalid = parsePersistedState({ + pullRequestMergeMethod: "fast-forward", + }); + + expect(parsed.pullRequestMergeMethod).toBe("squash"); + expect(invalid.pullRequestMergeMethod).toBe("merge"); + }); + it("hydrates raw UI-owned state without server entities", () => { const parsed = parsePersistedState({ projectExpandedById: { @@ -189,6 +202,7 @@ describe("parsePersistedState", () => { }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", sidebarProjectScopeKey: null, + pullRequestMergeMethod: "merge", threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, @@ -317,6 +331,7 @@ describe("uiStateStore persistence", () => { "turn-2": true, }, }, + pullRequestMergeMethod: "merge", }); expect(parsePersistedState(persisted)).toEqual({ ...state, diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index b14ce917c861..e82c86f26404 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,4 +1,5 @@ import { Debouncer } from "@tanstack/react-pacer"; +import type { PullRequestMergeMethod } from "@t3tools/contracts"; import { create } from "zustand"; import { normalizeProjectPathForComparison } from "./lib/projectPaths"; @@ -29,6 +30,7 @@ export interface PersistedUiState { sidebarProjectScopeKey?: string | null; threadChangedFilesExpansionVersion?: number; threadChangedFilesExpandedById?: Record>; + pullRequestMergeMethod?: string; } export interface UiProjectState { @@ -49,7 +51,12 @@ export interface UiEndpointState { defaultAdvertisedEndpointKey: string | null; } -export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {} +export interface UiPullRequestState { + pullRequestMergeMethod: PullRequestMergeMethod; +} + +export interface UiState + extends UiProjectState, UiThreadState, UiEndpointState, UiPullRequestState {} const initialState: UiState = { projectExpandedById: {}, @@ -58,6 +65,7 @@ const initialState: UiState = { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, + pullRequestMergeMethod: "merge", }; const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:"; @@ -109,6 +117,10 @@ function sanitizeTimestampRecord(value: unknown): Record { ); } +function isPullRequestMergeMethod(value: unknown): value is PullRequestMergeMethod { + return value === "merge" || value === "squash" || value === "rebase"; +} + export function parsePersistedState(parsed: PersistedUiState): UiState { const projectExpandedById = parsed.projectExpandedById === undefined @@ -143,6 +155,9 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { : {}, defaultAdvertisedEndpointKey: sanitizeOptionalKey(parsed.defaultAdvertisedEndpointKey), sidebarProjectScopeKey: sanitizeOptionalKey(parsed.sidebarProjectScopeKey), + pullRequestMergeMethod: isPullRequestMergeMethod(parsed.pullRequestMergeMethod) + ? parsed.pullRequestMergeMethod + : initialState.pullRequestMergeMethod, }; } @@ -216,6 +231,7 @@ export function persistState(state: UiState): void { sidebarProjectScopeKey: state.sidebarProjectScopeKey, threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, threadChangedFilesExpandedById: state.threadChangedFilesExpandedById, + pullRequestMergeMethod: state.pullRequestMergeMethod, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -324,6 +340,12 @@ export function setSidebarProjectScopeKey(state: UiState, projectKey: string | n }; } +function setPullRequestMergeMethod(state: UiState, method: PullRequestMergeMethod): UiState { + return state.pullRequestMergeMethod === method + ? state + : { ...state, pullRequestMergeMethod: method }; +} + export function resolveProjectExpanded( projectExpandedById: Readonly>, preferenceKeys: readonly string[], @@ -407,6 +429,7 @@ interface UiStateStore extends UiState { setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; setSidebarProjectScopeKey: (projectKey: string | null) => void; + setPullRequestMergeMethod: (method: PullRequestMergeMethod) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; reorderProjects: ( currentProjectOrder: readonly string[], @@ -427,6 +450,7 @@ export const useUiStateStore = create((set) => ({ set((state) => setDefaultAdvertisedEndpointKey(state, key)), setSidebarProjectScopeKey: (projectKey) => set((state) => setSidebarProjectScopeKey(state, projectKey)), + setPullRequestMergeMethod: (method) => set((state) => setPullRequestMergeMethod(state, method)), setProjectExpanded: (projectIds, expanded) => set((state) => setProjectExpanded(state, projectIds, expanded)), reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index b0fc4031d6dc..7d3cd2ceafe7 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -455,6 +455,25 @@ describe("ServerSettings thread settlement", () => { }); }); +describe("ClientSettings pull request merge methods", () => { + it("defaults to no project overrides and accepts supported methods", () => { + expect(decodeClientSettings({}).pullRequestMergeMethodOverrides).toEqual({}); + expect( + decodeClientSettingsPatch({ + pullRequestMergeMethodOverrides: { project: "squash" }, + }).pullRequestMergeMethodOverrides, + ).toEqual({ project: "squash" }); + }); + + it("rejects unsupported project merge methods", () => { + expect(() => + decodeClientSettingsPatch({ + pullRequestMergeMethodOverrides: { project: "fast-forward" }, + }), + ).toThrow(); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults text generation to Luna at low reasoning effort", () => { expect(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection).toEqual({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3d24e488ce7a..3491103da94f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -32,6 +32,7 @@ import { ProviderInstanceId, type ProviderDriverKind, } from "./providerInstance.ts"; +import { PullRequestMergeMethod } from "./pullRequest.ts"; // ── Client Settings (local-only) ─────────────────────────────── @@ -391,6 +392,10 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + pullRequestMergeMethodOverrides: Schema.Record( + TrimmedNonEmptyString, + PullRequestMergeMethod, + ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // Legacy plan mode. The composer's Build/Plan toggle was removed from the // default UI; this beta flag restores it (plus the /plan and /default slash // commands) for users who still rely on the old workflow. @@ -1341,6 +1346,9 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + pullRequestMergeMethodOverrides: Schema.optionalKey( + Schema.Record(TrimmedNonEmptyString, PullRequestMergeMethod), + ), planModeEnabled: Schema.optionalKey(Schema.Boolean), contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), From 1f14d6d10afbcc99ec255b23544ee25c08dea321 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 00:40:35 -0700 Subject: [PATCH 02/22] fix(usage): keep account columns aligned across limit rows (#10690) --- .../src/features/usage/UsageLimitsPooled.tsx | 48 +++++----- .../components/usage/UsageLimitsPooled.tsx | 31 +++--- docs/user/usage.md | 6 +- packages/shared/src/usageLimits.test.ts | 94 ++++++++++++++++++- packages/shared/src/usageLimits.ts | 38 +++++--- 5 files changed, 165 insertions(+), 52 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index dd62f4952d1f..7c20f802477d 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -116,30 +116,34 @@ function PoolWindowCard({ ) : null} - {pool.members.map(({ account, window }, index) => ( - openAccount(account)} - className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle" - > - - - - {index + 1} - - - - ))} + {pool.columns.map(({ account, window }, index) => { + if (!window) return ; + return ( + openAccount(account)} + className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle" + > + + + + {index + 1} + + + + ); + })} - {pool.members.map(({ account, window }, index) => { + {pool.columns.map(({ account, window }, index) => { + if (!window) return null; const credits = account.limits.resetCredits?.availableCount ?? 0; const resetsIn = formatResetsIn(window, now); return ( diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 0c17779daa3c..37654140c282 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -449,28 +449,29 @@ function PoolBar({
- {pool.members.map(({ account, window }, position) => ( - - ))} + {pool.columns.map((member, position) => + member.window ? ( + + ) : null, + )}
); } /** - * Big pooled number and the segment bar. The bar is sorted by reset, so who - * refills next is its left edge; the exact time and share restored live in - * each segment's popover rather than a list restating the bar. + * Big pooled number and the segment bar. Accounts keep the same column across + * windows; each segment's popover shows its own reset time and share restored. */ function PoolWindowCard({ pool, diff --git a/docs/user/usage.md b/docs/user/usage.md index 4be084ea299e..fba493156dc2 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -42,8 +42,10 @@ the dialog. **Usage → Limits** pools every subscription account it can see per provider, so with several Codex or Claude accounts across your environments and hubs you read one number per window rather than a list. Each window card shows how much of the pool is left and a bar with one segment per account, -ordered by which resets soonest; when the provider reports reset times, the card also says when -the next reset lands and how much it hands back. The hatched +kept in the same column across windows. Accounts are ordered by their 5-hour reset, soonest +first, or by the first available window when no account reports a 5-hour limit. A gap means the +account does not report that window. When the provider reports reset times, the card also says +when the next reset lands and how much it hands back. The hatched part of a segment is what that reset restores. Tap a segment or account row for the account's plan, where it is signed in, and its reset time. On web, you can hover too. Codex accounts with banked reset credits show a ticket count and the **Use reset** action in the account details. On narrow screens, numbered rows below diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 48eb87ce0c2d..b814e66da459 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -9,6 +9,7 @@ import { import { describe, expect, it } from "vite-plus/test"; import { + type LimitAccount, isUsageLimitsCommand, collectProviderUsageLimits, sameUsageLimitCommandCoverage, @@ -777,12 +778,103 @@ describe("pools", () => { ["weekly", 1], ["monthly", 1], ]); - // Segments read left to right as "who refills next", matching the reset list. + // Session resets determine the account order for every row. expect(session?.members.map((member) => member.account.key)).toEqual(["hub:a", "hub:b"]); expect(pools[0]?.accounts.map((account) => account.key)).toEqual(["hub:a", "hub:b"]); }); }); +describe("pooled account columns", () => { + const weekly = { + ...window, + id: "seven_day", + kind: "weekly", + label: "Weekly", + windowDurationMins: 7 * 24 * 60, + } as const; + const account = (key: string, windows: LimitAccount["limits"]["windows"]): LimitAccount => ({ + key, + driver: ProviderDriverKind.make("claudeAgent"), + displayName: key, + email: undefined, + plan: undefined, + accentColor: undefined, + environments: [], + sourceLabel: "Hub", + redeem: null, + limits: { checkedAt: "2026-09-03T11:00:00.000Z", windows }, + }); + const keys = (pool: ReturnType[number]) => + pool.windows.map((row) => + row.columns.map((member) => (member.window ? member.account.key : null)), + ); + + it("keeps session columns across rows with opposite reset and usage orders", () => { + const accounts = [ + account("a", [ + { ...weekly, usedPercent: 80, resetsAt: "2026-09-05T12:00:00.000Z" }, + { ...window, usedPercent: 10, resetsAt: "2026-09-03T15:00:00.000Z" }, + ]), + account("b", [ + { ...weekly, usedPercent: 20, resetsAt: "2026-09-06T12:00:00.000Z" }, + { ...window, usedPercent: 90, resetsAt: "2026-09-03T13:00:00.000Z" }, + ]), + ]; + const [pool] = collectLimitPools(accounts, now); + expect(pool!.accounts.map((account) => account.key)).toEqual(["b", "a"]); + expect(keys(pool!)).toEqual([ + ["b", "a"], + ["b", "a"], + ]); + expect(pool!.windows[1]!.resets.map((reset) => reset.member.account.key)).toEqual(["a", "b"]); + expect(pool!.windows[1]!.remainingPercent).toBe(50); + expect(keys(collectLimitPools(accounts.toReversed(), now)[0]!)).toEqual(keys(pool!)); + }); + + it("preserves gaps without counting missing windows toward pooled quota", () => { + const [pool] = collectLimitPools( + [ + account("a", [window]), + account("b", [ + { ...window, resetsAt: "2026-09-03T15:00:00.000Z" }, + { ...weekly, usedPercent: 80 }, + ]), + account("c", [weekly]), + ], + now, + ); + expect(keys(pool!)).toEqual([ + ["a", "b", null], + [null, "b", "c"], + ]); + expect(pool!.windows[1]!.members.map((member) => member.account.key)).toEqual(["b", "c"]); + expect(pool!.windows[1]!.remainingPercent).toBe(40); + expect(pool!.windows[1]!.resets.map((reset) => reset.restoresPercent)).toEqual([40, 20]); + }); + + it("falls back to weekly resets when no account reports a session", () => { + const [pool] = collectLimitPools( + [ + account("a", [{ ...weekly, resetsAt: "2026-09-06T12:00:00.000Z" }]), + account("b", [{ ...weekly, resetsAt: "2026-09-05T12:00:00.000Z" }]), + ], + now, + ); + expect(keys(pool!)).toEqual([["b", "a"]]); + }); + + it("sorts unknown resets last and breaks ties consistently", () => { + const accounts = [ + account("z", [{ ...window, resetsAt: undefined }]), + account("b", [window]), + account("a", [window]), + account("y", [{ ...window, resetsAt: "invalid" }]), + ]; + expect(keys(collectLimitPools(accounts, now)[0]!)).toEqual([["a", "b", "y", "z"]]); + expect(keys(collectLimitPools(accounts.toReversed(), now)[0]!)).toEqual([["a", "b", "y", "z"]]); + }); +}); + describe("collectLimitNotices", () => { const checkedAt = "2026-09-03T11:00:00.000Z"; const claude = ProviderDriverKind.make("claudeAgent"); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 784b1ada3e31..5c32cc0343b7 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -357,6 +357,11 @@ export interface LimitPoolWindow { readonly kind: ServerProviderUsageWindow["kind"]; readonly label: string; readonly members: readonly LimitPoolMember[]; + /** Fixed account positions across rows; a null window leaves a gap. */ + readonly columns: ReadonlyArray<{ + readonly account: LimitAccount; + readonly window: ServerProviderUsageWindow | null; + }>; readonly remainingPercent: number; readonly usedPercent: number; readonly pace: LimitPace | null; @@ -389,10 +394,10 @@ const WINDOW_KIND_ORDER: Record = { * a month on Free/Go), and a monthly allowance must not average into a * five-hour pool. Pools order by kind, then first appearance. * - * `accounts` is the table order: instances the user can act on (native, - * named) before hub-only accounts, each group alphabetical. Each window's - * `members` sort by reset instead, soonest first, so a bar reads left to - * right as "who refills next" and matches the reset list under it. + * Accounts and columns share the session reset order, soonest first. When + * no account reports a session window, use the first window by kind instead. + * Missing reset times sort last, with account names and keys breaking ties. + * Each window's reset list still follows its own clock. */ export function collectLimitPools( accounts: readonly LimitAccount[], @@ -405,10 +410,20 @@ export function collectLimitPools( else byDriver.set(account.driver, [account]); } return [...byDriver].map(([driver, members]) => { + const orderWindow = members + .flatMap((account) => account.limits.windows) + .sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind])[0]; + const orderReset = (account: LimitAccount) => { + const window = account.limits.windows.find( + (window) => window.kind === orderWindow?.kind && window.id === orderWindow.id, + ); + return (window ? resetMillis(window) : null) ?? Number.POSITIVE_INFINITY; + }; const sorted = [...members].sort( (left, right) => - Number(left.redeem === null) - Number(right.redeem === null) || - accountSortName(left).localeCompare(accountSortName(right)), + orderReset(left) - orderReset(right) || + accountSortName(left).localeCompare(accountSortName(right)) || + left.key.localeCompare(right.key), ); return { driver, accounts: sorted, windows: poolWindows(sorted, now) }; }); @@ -428,12 +443,8 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L else byKey.set(key, [{ account, window }]); } } - const pools = [...byKey.values()].map((unordered): LimitPoolWindow => { - const members = [...unordered].sort( - (left, right) => - (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) - - (resetMillis(right.window) ?? Number.POSITIVE_INFINITY), - ); + const pools = [...byKey.values()].map((members): LimitPoolWindow => { + const memberByAccount = new Map(members.map((member) => [member.account.key, member])); const first = members[0]!.window; const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; // Pace compares spend against the clock, so it is judged only over the @@ -465,6 +476,9 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L kind: first.kind, label: first.label, members, + columns: accounts.map( + (account) => memberByAccount.get(account.key) ?? { account, window: null }, + ), usedPercent: Math.round(usedPercent), remainingPercent: Math.round(100 - usedPercent), pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed), From 11601da846e7d82db63a6f4773e3a02421619b60 Mon Sep 17 00:00:00 2001 From: Vitaly Iegorov Date: Tue, 8 Sep 2026 09:46:09 +0200 Subject: [PATCH 03/22] fix(web): chat text no longer shows through a 1px gap under composer banners (#10635) Co-authored-by: Claude Fable 5 --- apps/web/src/components/chat/ComposerBanner.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ComposerBanner.tsx b/apps/web/src/components/chat/ComposerBanner.tsx index 092baa0ec8af..fee3ff75a6c2 100644 --- a/apps/web/src/components/chat/ComposerBanner.tsx +++ b/apps/web/src/components/chat/ComposerBanner.tsx @@ -56,7 +56,11 @@ function Surface({ : "[--chat-composer-attachment-overlap:0px] before:rounded-[1rem]", "before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:border before:border-(--chat-composer-attached-outline)", "before:bg-[color-mix(in_srgb,var(--chat-composer-attached-surface)_var(--glass-opacity),transparent)] before:bg-[linear-gradient(var(--chat-composer-attached-tint),var(--chat-composer-attached-tint))] before:backdrop-blur-(--glass-blur) before:backdrop-saturate-(--glass-saturation)", - "before:mask-[linear-gradient(to_top,transparent_0_var(--chat-composer-attachment-overlap),black_var(--chat-composer-attachment-overlap))] before:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)] dark:before:shadow-[0_14px_32px_-18px_rgb(0_0_0/75%)]", + // The mask cut-off bleeds one pixel past the seam: Chromium drops the last + // device-pixel row of a filtered backdrop when the cut-off lands off the + // device-pixel grid, and the composer's surface starts exactly there. The + // composer's own glass covers the extra row, so the overlap never shows. + "before:mask-[linear-gradient(to_top,transparent_0_calc(var(--chat-composer-attachment-overlap)-1px),black_calc(var(--chat-composer-attachment-overlap)-1px))] before:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)] dark:before:shadow-[0_14px_32px_-18px_rgb(0_0_0/75%)]", "dark:supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-[linear-gradient(var(--chat-composer-attached-tint),var(--chat-composer-attached-tint)),linear-gradient(to_top,transparent_0_var(--chat-composer-attachment-overlap),rgb(0_0_0/18%)_var(--chat-composer-attachment-overlap),transparent_calc(var(--chat-composer-attachment-overlap)+10px))]", "not-supports-[((backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px)))]:before:bg-(--chat-composer-attached-surface)", className, From 6f4cd07b900299629b9f2f9a9336f5c090556a51 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 01:11:08 -0700 Subject: [PATCH 04/22] refactor(server): classify runtime exports (#10274) --- apps/server/scripts/t3-sqlite-state.ts | 2 +- apps/server/src/attachmentStore.ts | 2 +- apps/server/src/auth/EnvironmentAuth.ts | 5 +++-- apps/server/src/auth/EnvironmentAuthPolicy.ts | 1 + apps/server/src/auth/PairingGrantStore.ts | 2 -- apps/server/src/auth/ServerSecretStore.ts | 1 + apps/server/src/auth/SessionStore.ts | 2 -- apps/server/src/auth/http.ts | 2 +- apps/server/src/background/BackgroundPolicy.ts | 3 ++- apps/server/src/background/HostPowerMonitor.ts | 2 +- apps/server/src/checkpointing/CheckpointDiffQuery.ts | 1 + apps/server/src/checkpointing/CheckpointStore.ts | 1 + apps/server/src/checkpointing/Utils.ts | 2 +- apps/server/src/cli/app.ts | 2 +- apps/server/src/cli/config.ts | 4 ++-- apps/server/src/cli/pair.ts | 2 +- apps/server/src/cloud/CliTokenManager.ts | 1 + apps/server/src/cloud/ManagedEndpointRuntime.ts | 1 + apps/server/src/cloud/publicConfig.ts | 8 ++++---- apps/server/src/cloud/serviceProtocol.ts | 2 +- apps/server/src/environment/RemoteOpenTargets.ts | 1 + apps/server/src/environment/ServerEnvironment.ts | 1 + apps/server/src/imageMime.ts | 2 +- apps/server/src/persistence/AuthPairingLinks.ts | 1 + apps/server/src/persistence/AuthSessions.ts | 1 + apps/server/src/persistence/Migrations.ts | 4 ++-- apps/server/src/persistence/ProviderSessionRuntime.ts | 1 + apps/server/src/process/externalLauncher.ts | 1 + apps/server/src/processRunner.ts | 1 + apps/server/src/server.ts | 2 +- apps/server/src/serverRuntimeStartup.ts | 5 +++-- apps/server/src/serverSettings.ts | 2 +- 32 files changed, 40 insertions(+), 28 deletions(-) diff --git a/apps/server/scripts/t3-sqlite-state.ts b/apps/server/scripts/t3-sqlite-state.ts index b114b0e10d49..aae040470f50 100644 --- a/apps/server/scripts/t3-sqlite-state.ts +++ b/apps/server/scripts/t3-sqlite-state.ts @@ -245,7 +245,7 @@ export const runSqliteState = Effect.fn("runSqliteState")(function* ( ); }); -export const t3SqliteStateCommand = Command.make( +const t3SqliteStateCommand = Command.make( "t3-sqlite-state", { operation: Argument.choice("operation", SqliteStateOperation.literals).pipe( diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 261b094645b9..4f098e1e6298 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -22,7 +22,7 @@ const ATTACHMENT_ID_PATTERN = new RegExp( ); export const PENDING_ATTACHMENT_THREAD_SEGMENT = "pending"; -export const PENDING_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const PENDING_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000; const PARTIAL_UPLOAD_MAX_AGE_MS = 60 * 60 * 1000; export function toSafeThreadAttachmentSegment(threadId: string): string | null { diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index bbc4a724c1d5..964fe6220d6b 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -38,7 +38,7 @@ import * as SessionStore from "./SessionStore.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts"; -export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; +const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap"; export interface IssuedPairingLink { @@ -591,6 +591,7 @@ export function selectRequestCredential( return undefined; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; @@ -1033,7 +1034,7 @@ export const layer = Layer.effect(EnvironmentAuth, make).pipe( Layer.provideMerge(EnvironmentAuthPolicy.layer), ); -export const storageLayer = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistenceLayer); +const storageLayer = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistenceLayer); export const runtimeLayer = layer.pipe( Layer.provideMerge(storageLayer), diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 446b8a8bba95..0c3327d95f07 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -14,6 +14,7 @@ export class EnvironmentAuthPolicy extends Context.Service< } >()("t3/auth/EnvironmentAuthPolicy") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 14426d0ba039..ec27c0a4e147 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -72,7 +72,6 @@ export const BootstrapCredentialInvalidError = Schema.Union([ UnavailableBootstrapCredentialError, ]); export type BootstrapCredentialInvalidError = typeof BootstrapCredentialInvalidError.Type; -export const isBootstrapCredentialInvalidError = Schema.is(BootstrapCredentialInvalidError); export class ActivePairingLinksLoadError extends Schema.TaggedError()( "ActivePairingLinksLoadError", @@ -173,7 +172,6 @@ export const BootstrapCredentialError = Schema.Union([ BootstrapCredentialInternalError, ]); export type BootstrapCredentialError = typeof BootstrapCredentialError.Type; -export const isBootstrapCredentialError = Schema.is(BootstrapCredentialError); export interface IssuedBootstrapCredential { readonly id: string; diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index dbeb5a7d07dd..e936a1f85c99 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -149,6 +149,7 @@ export class ServerSecretStore extends Context.Service< } >()("t3/auth/ServerSecretStore") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 743b669bfdd8..be8e7627bb20 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -349,14 +349,12 @@ export const SessionCredentialInternalError = Schema.Union([ OtherSessionsRevocationError, ]); export type SessionCredentialInternalError = typeof SessionCredentialInternalError.Type; -export const isSessionCredentialInternalError = Schema.is(SessionCredentialInternalError); export const SessionCredentialError = Schema.Union([ SessionCredentialInvalidError, SessionCredentialInternalError, ]); export type SessionCredentialError = typeof SessionCredentialError.Type; -export const isSessionCredentialError = Schema.is(SessionCredentialError); export class SessionStore extends Context.Service< SessionStore, diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index cc74966c41e2..b50d6eae9a18 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -65,7 +65,7 @@ const appendDpopChallengeOnUnauthorized = (error: EnvironmentAuthInvalidError) = return yield* error; }); -export const currentEnvironmentTraceId = Effect.currentParentSpan.pipe( +const currentEnvironmentTraceId = Effect.currentParentSpan.pipe( Effect.map((span) => span.traceId), Effect.orElseSucceed(() => "unavailable"), ); diff --git a/apps/server/src/background/BackgroundPolicy.ts b/apps/server/src/background/BackgroundPolicy.ts index a2a8e99d33ce..ad74bf134a89 100644 --- a/apps/server/src/background/BackgroundPolicy.ts +++ b/apps/server/src/background/BackgroundPolicy.ts @@ -82,7 +82,7 @@ function leaseKey(lease: Pick, lease: ClientActivityLease, now: DateTime.Utc, @@ -208,6 +208,7 @@ function computeSnapshot(input: { }; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("background.policy.make")(function* () { const hostPowerMonitor = yield* HostPowerMonitor.HostPowerMonitor; const serverSettings = yield* ServerSettingsService; diff --git a/apps/server/src/background/HostPowerMonitor.ts b/apps/server/src/background/HostPowerMonitor.ts index 273548faa47f..3d8c8b35998a 100644 --- a/apps/server/src/background/HostPowerMonitor.ts +++ b/apps/server/src/background/HostPowerMonitor.ts @@ -19,7 +19,7 @@ export class HostPowerMonitor extends Context.Service< } >()("t3/background/HostPowerMonitor") {} -export const makeUnknownSnapshot = ( +const makeUnknownSnapshot = ( source: HostPowerSnapshot["source"], updatedAt: HostPowerSnapshot["updatedAt"], ): HostPowerSnapshot => ({ diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts index 077506ff3a84..5d6aa7c9d20a 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -75,6 +75,7 @@ function buildTurnDiffResult( }; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const checkpointStore = yield* CheckpointStore.CheckpointStore; diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts index f1cc596b209d..0c9e60d76a65 100644 --- a/apps/server/src/checkpointing/CheckpointStore.ts +++ b/apps/server/src/checkpointing/CheckpointStore.ts @@ -98,6 +98,7 @@ export class CheckpointStore extends Context.Service< } >()("t3/checkpointing/CheckpointStore") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; diff --git a/apps/server/src/checkpointing/Utils.ts b/apps/server/src/checkpointing/Utils.ts index 50d0163af5a6..adc089f624a8 100644 --- a/apps/server/src/checkpointing/Utils.ts +++ b/apps/server/src/checkpointing/Utils.ts @@ -1,7 +1,7 @@ import * as Encoding from "effect/Encoding"; import { CheckpointRef, ProjectId, type ThreadId } from "@t3tools/contracts"; -export const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; +const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; export function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): CheckpointRef { return CheckpointRef.make( diff --git a/apps/server/src/cli/app.ts b/apps/server/src/cli/app.ts index 6342d4319a57..3fc6deedb0ce 100644 --- a/apps/server/src/cli/app.ts +++ b/apps/server/src/cli/app.ts @@ -81,7 +81,7 @@ function isDesktopPlatform(platform: NodeJS.Platform): platform is DesktopAppAct return platform === "darwin" || platform === "linux" || platform === "win32"; } -export function sendDesktopAppActivationRequest(input: { +function sendDesktopAppActivationRequest(input: { readonly address: string; readonly fallbackAddress?: string; readonly request: DesktopAppActivationRequest; diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index f739a4e2f22c..847edbbc4fe0 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -17,7 +17,7 @@ import { readBootstrapEnvelope } from "../bootstrap.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; -export const modeFlag = Flag.choice("mode", ServerConfig.RuntimeMode.literals).pipe( +const modeFlag = Flag.choice("mode", ServerConfig.RuntimeMode.literals).pipe( Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), Flag.optional, ); @@ -69,7 +69,7 @@ const tailscaleServeFlag = Flag.boolean("tailscale-serve").pipe( ), Flag.optional, ); -export const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( +const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( Flag.withSchema(PortSchema), Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), Flag.optional, diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 5e62f285be46..7fd376c6f881 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -173,7 +173,7 @@ export const resolveTailscaleLocalTarget = ( return { localPort: state.port }; }; -export const formatPairOutput = (input: { +const formatPairOutput = (input: { readonly serverLabel: string; readonly origin: string; readonly pairingUrl: string; diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index a5829d90481a..8f7ac7bfc8d4 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -324,6 +324,7 @@ export const outOfBandOAuthLogin = Effect.fn("cloud.cli_token.out_of_band_oauth_ }); }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { // Capture exactly the services the login/refresh flows need at build time // (matching the behavior before the out-of-band flow captured the instances), not diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 564d7346d036..cc657bdebf1b 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -111,6 +111,7 @@ const stopConnector = (connector: ActiveConnector | null) => ) : Effect.void; +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const relayClient = yield* RelayClient.RelayClient; diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index e977d7cfdf0d..d60a09b6decb 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -49,21 +49,21 @@ function normalizeSecureUrl(value: string): string | null { } } -export const buildTimeRelayUrl = +const buildTimeRelayUrl = typeof __T3CODE_BUILD_RELAY_URL__ === "undefined" ? "" : (normalizeSecureRelayUrl(__T3CODE_BUILD_RELAY_URL__) ?? ""); -export const buildTimeClerkPublishableKey = readBuildTimeValue( +const buildTimeClerkPublishableKey = readBuildTimeValue( typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined" ? undefined : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, ); -export const buildTimeClerkCliOAuthClientId = readBuildTimeValue( +const buildTimeClerkCliOAuthClientId = readBuildTimeValue( typeof __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__ === "undefined" ? undefined : __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__, ); -export const buildTimeRelayClientTracing = { +const buildTimeRelayClientTracing = { tracesUrl: readBuildTimeValue( typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__ === "undefined" ? undefined diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts index af94c9b4b5c0..bb61866a93ff 100644 --- a/apps/server/src/cloud/serviceProtocol.ts +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -71,7 +71,7 @@ export const isExactServiceVersion = (version: string): boolean => const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -export function decodeServiceUpdate(value: unknown): ServiceUpdateRecord | undefined { +function decodeServiceUpdate(value: unknown): ServiceUpdateRecord | undefined { if (!isRecord(value)) return undefined; const { id, fromVersion, targetVersion, status } = value; if ( diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts index f70dfa68aaab..c0cd658a28c7 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -26,6 +26,7 @@ export class RemoteOpenTargets extends Context.Service< } >()("t3/environment/RemoteOpenTargets") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const net = yield* NetService.NetService; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index a3840ca0caaa..bf02cd90fbdf 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -179,6 +179,7 @@ const makeIdentity = Effect.gen(function* () { }); }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index 66ce6096e853..1129edfd3ef1 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -1,6 +1,6 @@ import Mime from "@effect/platform-node/Mime"; -export const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { +const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { "image/avif": ".avif", "image/bmp": ".bmp", "image/gif": ".gif", diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index e54c977e7ab7..aae55dd2fe06 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -119,6 +119,7 @@ function toPersistenceSqlOrDecodeError( }); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index aebbb839e5ce..37db33556e23 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -200,6 +200,7 @@ function toPersistenceSqlOrDecodeError( }); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index c95f746d3648..bc176f62cc3c 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -72,7 +72,7 @@ import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts"; * Uses Migrator.fromRecord which parses the key format and * returns migrations sorted by ID. */ -export const migrationEntries = [ +const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], [2, "OrchestrationCommandReceipts", Migration0002], [3, "CheckpointDiffBlobs", Migration0003], @@ -126,7 +126,7 @@ export const migrationEntries = [ export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); -export const makeMigrationLoader = (throughId?: number) => +const makeMigrationLoader = (throughId?: number) => Migrator.fromRecord( Object.fromEntries( migrationEntries diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index d73f56aab9e0..2673512edf10 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -166,6 +166,7 @@ function toPersistenceSqlOrDecodeError( }); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 184c8b519a96..2b7310051b89 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -752,6 +752,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu ); }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 4aefc3ba43f5..25ed54e44fc6 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -399,6 +399,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( } satisfies ProcessRunOutput; }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("ProcessRunner.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 349644966f26..fd8ee4a4f699 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -564,7 +564,7 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(httpCompressionLayer), ); -export const makeServerLayer = Layer.unwrap( +const makeServerLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const activation = yield* Deferred.make(); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 1751a130814e..3d04abaa1914 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -147,7 +147,7 @@ export const makeCommandGate = Effect.gen(function* () { } satisfies CommandGate; }); -export const recordStartupHeartbeat = Effect.gen(function* () { +const recordStartupHeartbeat = Effect.gen(function* () { const analytics = yield* AnalyticsService.AnalyticsService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; @@ -466,7 +466,7 @@ const clearContinuationMarkers = ( { concurrency: "unbounded", discard: true }, ); -export const clearProviderSessionContinuationMarkers = (threadIds: ReadonlyArray) => +const clearProviderSessionContinuationMarkers = (threadIds: ReadonlyArray) => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; yield* clearContinuationMarkers(directory, threadIds); @@ -803,6 +803,7 @@ export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( ); }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = (options?: StartupOptions) => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index fe72147dfa0c..0b64d445adf8 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -142,7 +142,7 @@ function providerEnvironmentSecretName(input: { */ const USAGE_LIMIT_SOURCE_KEY_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; -export function usageLimitSourceSecretName(sourceId: string): string { +function usageLimitSourceSecretName(sourceId: string): string { return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`; } From 0af04f1801ea75119c74fd406ced7da6c9c71721 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 01:11:09 -0700 Subject: [PATCH 05/22] refactor(server): classify orchestration exports (#10275) --- apps/server/src/orchestration/Errors.ts | 23 ------------------- .../src/orchestration/LiveStreamBudget.ts | 4 ++-- .../orchestration/ThreadPullRequestReactor.ts | 1 + .../orchestration/ThreadSettlementPolicy.ts | 2 +- .../orchestration/ThreadSettlementReactor.ts | 1 + .../src/orchestration/commandInvariants.ts | 2 +- apps/server/src/orchestration/runtimeLayer.ts | 6 ++--- 7 files changed, 9 insertions(+), 30 deletions(-) diff --git a/apps/server/src/orchestration/Errors.ts b/apps/server/src/orchestration/Errors.ts index a0dd1d18eab3..dccb8b4be0f7 100644 --- a/apps/server/src/orchestration/Errors.ts +++ b/apps/server/src/orchestration/Errors.ts @@ -126,13 +126,6 @@ export type OrchestrationEngineError = | OrchestrationCommandJsonParseError | OrchestrationCommandDecodeError; -export function toOrchestrationCommandDecodeError(error: Schema.SchemaError) { - return new OrchestrationCommandDecodeError({ - issue: SchemaIssue.makeFormatterDefault()(error.issue), - cause: error, - }); -} - export function toProjectorDecodeError(eventType: string) { return (error: Schema.SchemaError): OrchestrationProjectorDecodeError => new OrchestrationProjectorDecodeError({ @@ -141,19 +134,3 @@ export function toProjectorDecodeError(eventType: string) { cause: error, }); } - -export function toOrchestrationJsonParseError(cause: unknown) { - return new OrchestrationCommandJsonParseError({ - detail: `Failed to parse orchestration command JSON`, - cause, - }); -} - -export function toListenerCallbackError(listener: "read-model" | "domain-event") { - return (cause: unknown): OrchestrationListenerCallbackError => - new OrchestrationListenerCallbackError({ - listener, - detail: `Failed to invoke orchestration ${listener} listener`, - cause, - }); -} diff --git a/apps/server/src/orchestration/LiveStreamBudget.ts b/apps/server/src/orchestration/LiveStreamBudget.ts index e86127b2b2df..5a3a08e7e4b3 100644 --- a/apps/server/src/orchestration/LiveStreamBudget.ts +++ b/apps/server/src/orchestration/LiveStreamBudget.ts @@ -6,8 +6,8 @@ import * as Exit from "effect/Exit"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -export const LIVE_STREAM_MAX_ITEMS = 1_000; -export const LIVE_STREAM_MAX_SERIALIZED_BYTES = 8 * 1024 * 1024; +const LIVE_STREAM_MAX_ITEMS = 1_000; +const LIVE_STREAM_MAX_SERIALIZED_BYTES = 8 * 1024 * 1024; export interface RetainedLiveItem { readonly value: A; diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts index 6e2aa0b3e054..b694bacbbb33 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -77,6 +77,7 @@ export function pullRequestMatchesProject( ); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts index 7c55fa37d1f3..1df7855d1e04 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -8,7 +8,7 @@ export interface SettlementPullRequest { } const DAY_MS = 24 * 60 * 60 * 1_000; -export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; +const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; function latestTimestamp(values: ReadonlyArray): string | null { let latest: string | null = null; diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 4830ec9d80e5..d925df5d98f1 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -32,6 +32,7 @@ export class ThreadSettlementReactor extends Context.Service< } >()("t3/orchestration/ThreadSettlementReactor") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index 110a499d37c9..873eab007bea 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -25,7 +25,7 @@ function findThreadById( return readModel.threads.find((thread) => thread.id === threadId); } -export function findProjectById( +function findProjectById( readModel: OrchestrationReadModel, projectId: ProjectId, ): OrchestrationProject | undefined { diff --git a/apps/server/src/orchestration/runtimeLayer.ts b/apps/server/src/orchestration/runtimeLayer.ts index 779042e2f685..ea02e5e2ebf9 100644 --- a/apps/server/src/orchestration/runtimeLayer.ts +++ b/apps/server/src/orchestration/runtimeLayer.ts @@ -8,16 +8,16 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSna import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "./ThreadPlanProgress.ts"; -export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( +const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( OrchestrationEventStoreLive, OrchestrationCommandReceiptRepositoryLive, ); -export const OrchestrationProjectionPipelineLayerLive = OrchestrationProjectionPipelineLive.pipe( +const OrchestrationProjectionPipelineLayerLive = OrchestrationProjectionPipelineLive.pipe( Layer.provide(OrchestrationEventStoreLive), ); -export const OrchestrationInfrastructureLayerLive = Layer.mergeAll( +const OrchestrationInfrastructureLayerLive = Layer.mergeAll( OrchestrationProjectionSnapshotQueryLive, OrchestrationEventInfrastructureLayerLive, OrchestrationProjectionPipelineLayerLive, From 161715b1e4fa6d20fe3eb1cb9c91904bf83ca4c1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 01:11:09 -0700 Subject: [PATCH 06/22] refactor(server): classify service exports (#10276) --- apps/server/src/assets/NativeAppIconResolver.ts | 1 + apps/server/src/desktopUpdate/DesktopAppUpdate.ts | 2 +- apps/server/src/git/GitWorkflowService.ts | 1 + apps/server/src/observability/BrowserTraceCollector.ts | 1 + apps/server/src/preview/Manager.ts | 1 + apps/server/src/preview/PortScanner.ts | 1 + apps/server/src/project/AgentSessionScanner.ts | 1 + apps/server/src/project/ProjectSetupScriptRunner.ts | 1 + apps/server/src/project/T3ProjectFileLoader.ts | 1 + apps/server/src/relay/AgentAwarenessRelay.ts | 3 ++- apps/server/src/review/ReviewService.ts | 1 + apps/server/src/terminal/Manager.ts | 1 + apps/server/src/textGeneration/TextGeneration.ts | 1 + apps/server/src/textGeneration/TextGenerationPresets.ts | 5 ----- apps/server/src/vcs/VcsProjectConfig.ts | 1 + apps/server/src/vcs/VcsProvisioningService.ts | 1 + apps/server/src/vcs/VcsStatusBroadcaster.ts | 1 + apps/server/src/workspace/WorkspaceEntries.ts | 1 + apps/server/src/workspace/WorkspaceFileSystem.ts | 1 + apps/server/src/workspace/WorkspaceSearchIndex.ts | 2 ++ 20 files changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/server/src/assets/NativeAppIconResolver.ts b/apps/server/src/assets/NativeAppIconResolver.ts index ba572650d408..89a6d0636012 100644 --- a/apps/server/src/assets/NativeAppIconResolver.ts +++ b/apps/server/src/assets/NativeAppIconResolver.ts @@ -206,6 +206,7 @@ const resolveNativeAppIconUncached = Effect.fn("NativeAppIconResolver.resolveUnc return yield* existingFile(cachePath); }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcessPlatform; diff --git a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts index 1e7b54b2474b..9c5d88bd7910 100644 --- a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts +++ b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts @@ -25,7 +25,7 @@ const DESKTOP_INSTALL_TIMEOUT = Duration.minutes(2); /** Progress stage a desktop update state maps to, or null when the state carries no progress worth streaming. */ -export function desktopUpdateProgressStage( +function desktopUpdateProgressStage( state: DesktopUpdateState, ): ServerSelfUpdateProgressStage | null { switch (state.status) { diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index c9b4a4cca365..e90a59fae8f1 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -142,6 +142,7 @@ function nonRepositoryListRefs(): VcsListRefsResult { }; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; const git = yield* GitVcsDriver.GitVcsDriver; diff --git a/apps/server/src/observability/BrowserTraceCollector.ts b/apps/server/src/observability/BrowserTraceCollector.ts index 300a50fe3308..ee197dd0d7d3 100644 --- a/apps/server/src/observability/BrowserTraceCollector.ts +++ b/apps/server/src/observability/BrowserTraceCollector.ts @@ -10,6 +10,7 @@ export class BrowserTraceCollector extends Context.Service< } >()("t3/observability/BrowserTraceCollector") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = (sink: TraceSink): BrowserTraceCollector["Service"] => BrowserTraceCollector.of({ record: (records) => diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 3c0169eba40a..a5f5cba9ccd0 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -153,6 +153,7 @@ const buildIdleSnapshot = (input: { updatedAt: input.updatedAt, }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* PreviewManagerMake() { const serverEpoch = NodeCrypto.randomUUID(); const stateRef = yield* SynchronizedRef.make(initialState); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 4571aeef4c6b..f4d73d62320d 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -289,6 +289,7 @@ const serversEqual = ( return true; }; +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* PortDiscoveryMake() { const net = yield* Net.NetService; const processRunner = yield* ProcessRunner.ProcessRunner; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 1dfd1d5df8af..975192e70033 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -615,6 +615,7 @@ function sameTranscriptIdentity( ); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; // Different project imports can arrive concurrently from multiple clients. diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 9c222c969ad3..3bbb0daa7994 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -83,6 +83,7 @@ export class ProjectSetupScriptRunner extends Context.Service< } >()("t3/project/ProjectSetupScriptRunner") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const terminalManager = yield* TerminalManager.TerminalManager; diff --git a/apps/server/src/project/T3ProjectFileLoader.ts b/apps/server/src/project/T3ProjectFileLoader.ts index 473dce695046..105e6b09a317 100644 --- a/apps/server/src/project/T3ProjectFileLoader.ts +++ b/apps/server/src/project/T3ProjectFileLoader.ts @@ -59,6 +59,7 @@ const logT3ProjectFileLoadError = (error: T3ProjectFileLoadError) => }), ); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 8d7b9e98361e..052a67959ad1 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -211,7 +211,7 @@ const makePublishProof = Effect.fn("makePublishProof")(function* (input: { }); // Compact, log-safe view of the fields the awareness phase ladder reads. -export function describeThreadShellForAwareness( +function describeThreadShellForAwareness( thread: Option.Option, ): Record { if (Option.isNone(thread)) { @@ -290,6 +290,7 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { .map((thread) => thread.id); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index 275dcb416610..dc13e5650c3c 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -31,6 +31,7 @@ export class ReviewService extends Context.Service< } >()("t3/review/ReviewService") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 82664f666f53..174ed4206afc 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1385,6 +1385,7 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( ); }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("TerminalManager.make")(function* () { const { terminalLogsDir } = yield* ServerConfig.ServerConfig; const ptyAdapter = yield* PtyAdapter.PtyAdapter; diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index cc9b2e3926f3..84730d639ac6 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -153,6 +153,7 @@ export const makeTextGenerationFromRegistry = ( ), }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; return makeTextGenerationFromRegistry(registry); diff --git a/apps/server/src/textGeneration/TextGenerationPresets.ts b/apps/server/src/textGeneration/TextGenerationPresets.ts index 0f5d03480f49..5fab9219080f 100644 --- a/apps/server/src/textGeneration/TextGenerationPresets.ts +++ b/apps/server/src/textGeneration/TextGenerationPresets.ts @@ -1,10 +1,5 @@ import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export const defaultTextGenerationPolicy: TextGenerationPolicy = { - kind: "default", - inferRepositoryConventions: false, -}; - export const conventionalCommitsTextGenerationPolicy: TextGenerationPolicy = { kind: "conventional_commits", commitInstructions: diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts index 98e65751185f..74d3801c0686 100644 --- a/apps/server/src/vcs/VcsProjectConfig.ts +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -64,6 +64,7 @@ const logVcsProjectConfigError = (error: VcsProjectConfigError) => }), ); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/vcs/VcsProvisioningService.ts b/apps/server/src/vcs/VcsProvisioningService.ts index 9febacf2256a..e58099b615db 100644 --- a/apps/server/src/vcs/VcsProvisioningService.ts +++ b/apps/server/src/vcs/VcsProvisioningService.ts @@ -35,6 +35,7 @@ function resolveRequestedKind( return Effect.succeed(kind); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index c00a07f2a7a9..b9fc9e7ee3ab 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -217,6 +217,7 @@ const normalizeCwd = (cwd: string) => Effect.orElseSucceed(() => cwd), ); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const autoPullPolicy = yield* VcsAutoPullPolicy; const workflow = yield* GitWorkflowService.GitWorkflowService; diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 608d3092b6a9..4bdf4d45a8c9 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -128,6 +128,7 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu return path.resolve(expandHomePathWith(input.cwd, path), input.partialPath); }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 5198f5e84f88..9ef3d4546d1d 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -132,6 +132,7 @@ export class WorkspaceFileSystem extends Context.Service< } >()("t3/workspace/WorkspaceFileSystem") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 8a2548eb2f29..44a9c3397c6f 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -551,6 +551,8 @@ function parseWorkspaceSearchIndexKey(key: string): { * workspace root and variant. WorkspaceSearchIndexMap owns memoization and * idle cleanup; using a default cwd here would mix resources from different * workspaces. + * + * @public Service construction is part of the canonical Effect module API. */ export const layer = (key: string) => { const { cwd, variant } = parseWorkspaceSearchIndexKey(key); From 77d9ffc822ac4c1f3600dc6c97e92fc94e9e12a3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 01:11:09 -0700 Subject: [PATCH 07/22] refactor(server): classify telemetry exports (#10277) --- .../integration/NetworkTransferMeasurement.integration.ts | 6 +++--- apps/server/integration/TransferBudgetReport.integration.ts | 2 +- .../integration/TransferBudgetScenario.integration.ts | 2 +- apps/server/src/diagnostics/ProcessDiagnostics.ts | 1 + apps/server/src/diagnostics/ProcessResourceMonitor.ts | 1 + apps/server/src/diagnostics/TraceDiagnostics.ts | 1 + .../src/resourceTelemetry/DesktopTelemetryReceiver.ts | 1 + apps/server/src/resourceTelemetry/Model.ts | 2 +- apps/server/src/resourceTelemetry/NativeTelemetryClient.ts | 1 + apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts | 4 ++-- apps/server/src/resourceTelemetry/ResourceTelemetry.ts | 1 + apps/server/src/telemetry/AnalyticsService.ts | 3 ++- apps/server/src/usage/UsageLimitSources.ts | 1 + apps/server/src/usage/usageAggregation.ts | 2 +- apps/server/src/usage/usageScanCache.ts | 2 +- apps/server/src/usage/usageTranscripts.ts | 2 +- 16 files changed, 20 insertions(+), 12 deletions(-) diff --git a/apps/server/integration/NetworkTransferMeasurement.integration.ts b/apps/server/integration/NetworkTransferMeasurement.integration.ts index 4da20cac9e05..fbbdd8fb156b 100644 --- a/apps/server/integration/NetworkTransferMeasurement.integration.ts +++ b/apps/server/integration/NetworkTransferMeasurement.integration.ts @@ -120,7 +120,7 @@ function rawDataBytes(data: NodeSocket.NodeWS.RawData): number { return data.byteLength; } -export function makeWebSocketTransferRecorder(): WebSocketTransferRecorder { +function makeWebSocketTransferRecorder(): WebSocketTransferRecorder { let socket: NodeWebSocketWithTransport | null = null; // Held separately from the WebSocket so wire totals survive a close, which // is when a reconnect measurement reads them. @@ -176,7 +176,7 @@ export function transferDelta( }; } -export function countingWsRpcProtocolLayer(input: { +function countingWsRpcProtocolLayer(input: { readonly url: string; readonly cookie: string; readonly recorder: WebSocketTransferRecorder; @@ -194,7 +194,7 @@ export function countingWsRpcProtocolLayer(input: { ); } -export const makeCountingWsRpcClient = RpcClient.make(WsRpcGroup); +const makeCountingWsRpcClient = RpcClient.make(WsRpcGroup); export type CountingWsRpcClient = Effect.Success; export interface MeasuredWsClient { diff --git a/apps/server/integration/TransferBudgetReport.integration.ts b/apps/server/integration/TransferBudgetReport.integration.ts index d82688d2863f..fe0b3ed8e648 100644 --- a/apps/server/integration/TransferBudgetReport.integration.ts +++ b/apps/server/integration/TransferBudgetReport.integration.ts @@ -56,7 +56,7 @@ const TRANSFER_BUDGET = { measuredTurnWebSocketMessages: 21, } satisfies ProviderTransferBudget; -export const TRANSFER_BUDGETS: Readonly> = { +const TRANSFER_BUDGETS: Readonly> = { codex: TRANSFER_BUDGET, claudeAgent: TRANSFER_BUDGET, }; diff --git a/apps/server/integration/TransferBudgetScenario.integration.ts b/apps/server/integration/TransferBudgetScenario.integration.ts index 33e149dd2484..ccede4204f66 100644 --- a/apps/server/integration/TransferBudgetScenario.integration.ts +++ b/apps/server/integration/TransferBudgetScenario.integration.ts @@ -26,7 +26,7 @@ import { TRANSFER_HISTORY_TURN_COUNT, } from "./fixtures/transferBudget.ts"; -export const TRANSFER_PROJECT_ID = ProjectId.make("transfer-budget-project"); +const TRANSFER_PROJECT_ID = ProjectId.make("transfer-budget-project"); export const TRANSFER_THREAD_ID = ThreadId.make("transfer-budget-thread"); export const TRANSFER_MEASURED_TURN_INDEX = TRANSFER_HISTORY_TURN_COUNT; diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index 2f740aa37b1a..4fa60160e507 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -54,6 +54,7 @@ function canSignalCategory(category: ResourceTelemetryProcessCategory): boolean ); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("makeProcessDiagnostics")(function* () { const telemetry = yield* ResourceTelemetry.ResourceTelemetry; const refreshedTelemetry = telemetry.refresh.pipe(Effect.catch(() => telemetry.latest)); diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.ts index 5f5e32dd28da..aa11e5f42d39 100644 --- a/apps/server/src/diagnostics/ProcessResourceMonitor.ts +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.ts @@ -28,6 +28,7 @@ function isLegacyBackendCategory(category: ResourceTelemetryProcessCategory): bo ); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("makeProcessResourceMonitor")(function* () { const telemetry = yield* ResourceTelemetry.ResourceTelemetry; const readHistory: ProcessResourceMonitor["Service"]["readHistory"] = (input) => diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts index 85900e6915cb..58b08ea8b572 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -411,6 +411,7 @@ function readTraceFile( ); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index 528c9cf2b229..67842addd7df 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -327,6 +327,7 @@ export function requireDesktopTelemetryWriteProgress( : Effect.fail(new DesktopTelemetryControlStalled({ fd, remainingBytes })); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")(function* () { const config = yield* ServerConfig; const serverSettings = yield* ServerSettingsService; diff --git a/apps/server/src/resourceTelemetry/Model.ts b/apps/server/src/resourceTelemetry/Model.ts index a198c4b311f7..2582d7e9c17f 100644 --- a/apps/server/src/resourceTelemetry/Model.ts +++ b/apps/server/src/resourceTelemetry/Model.ts @@ -68,7 +68,7 @@ export interface MergeProcessesResult { readonly deltas: ReadonlyArray; } -export const emptyGroupCounters = (): GroupCounters => ({ +const emptyGroupCounters = (): GroupCounters => ({ cpuTimeMs: 0, ioReadBytes: 0, ioWriteBytes: 0, diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index aafa88cc9225..40254bfe80aa 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -360,6 +360,7 @@ export function canCommandNativeTelemetrySidecar( return hasHandle && (status === "healthy" || status === "degraded"); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(function* () { const binary = yield* ResourceMonitorBinary.ResourceMonitorBinary; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts index d53fb4791b93..52c123fe6625 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts @@ -90,7 +90,7 @@ export const ResourceMonitorHostLinuxLibc = Context.Reference string { +function makeDayFormatter(timeZone: string): (timestampMs: number) => string { let format: Intl.DateTimeFormat; try { format = new Intl.DateTimeFormat("en-CA", { diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 102058a07d35..224f109147e4 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -26,7 +26,7 @@ import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; // entries would keep serving double-counted records forever. // v3: entries carry the parse position and reducer state so a grown file // re-parses only its appended bytes instead of starting over. -export const USAGE_SCAN_CACHE_VERSION = 3 as const; +const USAGE_SCAN_CACHE_VERSION = 3 as const; export interface CachedFile { readonly size: number; diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 2aea60709666..5d909379eb10 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -79,7 +79,7 @@ export function mightCarryUsage(line: string, provider: UsageProviderKind): bool */ export const GROK_COST_USD_TICKS_PER_DOLLAR = 10_000_000_000; -export function grokCostTicksToUsd(ticks: unknown): number | null { +function grokCostTicksToUsd(ticks: unknown): number | null { if (typeof ticks !== "number" || !Number.isFinite(ticks) || ticks < 0) return null; return ticks / GROK_COST_USD_TICKS_PER_DOLLAR; } From 060c576f8922703725f0dbdd01443ed88ecc9baa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 01:11:10 -0700 Subject: [PATCH 08/22] refactor(server): classify provider exports (#10278) --- .../server/src/provider/ClaudeModelCatalog.ts | 4 +-- .../src/provider/Drivers/ClaudeSkills.ts | 2 +- .../Layers/ProviderAdapterRegistry.ts | 7 ---- .../provider/Layers/ProviderEventLoggers.ts | 2 ++ .../Layers/ProviderInstanceRegistryLive.ts | 3 -- .../src/provider/Layers/codexLaunchArgs.ts | 5 ++- .../src/provider/Layers/codexResetCredit.ts | 1 + .../src/provider/Layers/codexUsageLimits.ts | 2 +- apps/server/src/provider/ModelManifest.ts | 6 ++-- .../src/provider/OpenCodeServerOwner.ts | 3 +- .../src/provider/acp/AntigravityAcpSupport.ts | 2 +- .../src/provider/acp/AntigravityProtocol.ts | 4 +-- .../server/src/provider/antigravityRelease.ts | 2 +- apps/server/src/provider/opencodeRuntime.ts | 4 +-- .../src/provider/providerMaintenance.ts | 2 +- .../src/provider/providerMaintenanceRunner.ts | 1 + apps/server/src/provider/providerSnapshot.ts | 36 ------------------- .../src/provider/providerUpdateSettings.ts | 2 +- .../testUtils/providerRegistryMock.ts | 2 +- 19 files changed, 23 insertions(+), 67 deletions(-) diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index b1fcd6a92bbf..e601e9dc9392 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -118,7 +118,7 @@ export function scopeClaudeModelCatalog( return { models: [...builtInModels, ...customCatalogModels] }; } -export function resolveClaudeCatalogModel( +function resolveClaudeCatalogModel( catalog: ClaudeModelCatalog, slugOrAlias: string | null | undefined, ): ClaudeCatalogModel | undefined { @@ -215,7 +215,7 @@ export function isClaudeCatalogUltracodeEffort(effort: string | null | undefined return effort === "ultracode"; } -export function resolveClaudeCatalogContextWindow( +function resolveClaudeCatalogContextWindow( catalog: ClaudeModelCatalog, modelSelection: ModelSelection | undefined, ): string | undefined { diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 236fe79f518c..259ceeb4b775 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -104,7 +104,7 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { * user and project one. Absent on almost every machine, which is why a missing * file is the normal case rather than an error. */ -export function claudeManagedSettingsPath( +function claudeManagedSettingsPath( path: Path.Path, platform: NodeJS.Platform, environment: NodeJS.ProcessEnv, diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 919a345aa988..9e8e3c5d1f90 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -80,13 +80,6 @@ export const ProviderAdapterRegistryLive = Layer.effect( makeProviderAdapterRegistry(), ); -// Exposed for tests that want to build a facade over a pre-assembled -// `ProviderInstanceRegistry` without pulling in the whole boot graph. -export { makeProviderAdapterRegistry }; - -// Re-export for consumers that need the accessor shape. The service tag -// itself lives in `Services/ProviderAdapterRegistry.ts`. -export { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; // Re-export for consumers (including tests) that construct a // `ProviderInstanceId` before calling `getByInstance`. export { ProviderInstanceId }; diff --git a/apps/server/src/provider/Layers/ProviderEventLoggers.ts b/apps/server/src/provider/Layers/ProviderEventLoggers.ts index a4f0b01e8193..517a1c8195c0 100644 --- a/apps/server/src/provider/Layers/ProviderEventLoggers.ts +++ b/apps/server/src/provider/Layers/ProviderEventLoggers.ts @@ -61,6 +61,8 @@ export const NoOpProviderEventLoggers: ProviderEventLoggers["Service"] = { /** * Builds both stream views over one shared store. Setup failures are logged * and downgraded to the no-op service so diagnostics never block startup. + * + * @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const { providerEventLogPath } = yield* ServerConfig; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts index 0cc4b6c93cb8..67ccbd167c30 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts @@ -33,7 +33,6 @@ * @module provider/Layers/ProviderInstanceRegistryLive */ import { - defaultInstanceIdForDriver, providerInstanceConfigEnabledFlag, ProviderInstanceId, type ProviderInstanceConfig, @@ -430,5 +429,3 @@ export const ProviderInstanceRegistryMutableLayer = (input: { ), ), ) as Layer.Layer; - -export { defaultInstanceIdForDriver }; diff --git a/apps/server/src/provider/Layers/codexLaunchArgs.ts b/apps/server/src/provider/Layers/codexLaunchArgs.ts index 771a4f0b6ed6..f64ff263e0e3 100644 --- a/apps/server/src/provider/Layers/codexLaunchArgs.ts +++ b/apps/server/src/provider/Layers/codexLaunchArgs.ts @@ -1,14 +1,13 @@ import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; -export const T3CODE_CODEX_LAUNCH_ARGS_ENV = "T3CODE_CODEX_LAUNCH_ARGS"; +const T3CODE_CODEX_LAUNCH_ARGS_ENV = "T3CODE_CODEX_LAUNCH_ARGS"; export const resolveCodexLaunchArgs = ( launchArgs?: string, environment: NodeJS.ProcessEnv = process.env, ) => environment[T3CODE_CODEX_LAUNCH_ARGS_ENV]?.trim() || launchArgs?.trim() || ""; -export const codexLaunchArgv = (launchArgs?: string): ReadonlyArray => - tokenizeCliArgs(launchArgs); +const codexLaunchArgv = (launchArgs?: string): ReadonlyArray => tokenizeCliArgs(launchArgs); export const codexAppServerArgs = (launchArgs?: string) => [ "app-server", diff --git a/apps/server/src/provider/Layers/codexResetCredit.ts b/apps/server/src/provider/Layers/codexResetCredit.ts index d8310c7c3ee7..34bd0a77fe27 100644 --- a/apps/server/src/provider/Layers/codexResetCredit.ts +++ b/apps/server/src/provider/Layers/codexResetCredit.ts @@ -45,6 +45,7 @@ export class CodexResetCreditCoordinator extends Context.Service< } >()("t3/provider/Layers/codexResetCredit/CodexResetCreditCoordinator") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const statesRef = yield* Ref.make>(new Map()); diff --git a/apps/server/src/provider/Layers/codexUsageLimits.ts b/apps/server/src/provider/Layers/codexUsageLimits.ts index 66b9511b1e44..74ce04a7895f 100644 --- a/apps/server/src/provider/Layers/codexUsageLimits.ts +++ b/apps/server/src/provider/Layers/codexUsageLimits.ts @@ -67,7 +67,7 @@ function labelForKind(kind: ServerProviderUsageWindow["kind"]): string { * `windowDurationMins`; when it does not, paid plans expose the 5-hour and * weekly pair and Free/Go expose one monthly allowance. */ -export function codexRateLimitsToWindows( +function codexRateLimitsToWindows( snapshot: CodexRateLimitSnapshot, ): ReadonlyArray { // Show the main allowance only. Model-specific notifications (such as Spark) diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index 67a7334f613d..15000630b359 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -207,7 +207,7 @@ export const encodeManifestCache = Schema.encodeEffect( ); /** True when the manifest classifies `slug` as legacy for `driverKind`. */ -export function isLegacyModel( +function isLegacyModel( manifest: ModelManifestData, driverKind: ProviderDriverKind, slug: string, @@ -310,8 +310,8 @@ export class ModelManifest extends Context.Service< } >()("t3/provider/ModelManifest") {} -/** Constant service for tests and callers that only need the bundled data. */ -export const BundledOnlyModelManifest: ModelManifest["Service"] = { +/** Constant service backing the bundled-data test layer. */ +const BundledOnlyModelManifest: ModelManifest["Service"] = { current: Effect.succeed(BUNDLED_MODEL_MANIFEST), refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), refreshInBackground: Effect.void, diff --git a/apps/server/src/provider/OpenCodeServerOwner.ts b/apps/server/src/provider/OpenCodeServerOwner.ts index cccfcaccd6ef..2f51137de24c 100644 --- a/apps/server/src/provider/OpenCodeServerOwner.ts +++ b/apps/server/src/provider/OpenCodeServerOwner.ts @@ -8,7 +8,7 @@ import * as Semaphore from "effect/Semaphore"; import * as OpenCodeRuntime from "./opencodeRuntime.ts"; -export const OPENCODE_SERVER_IDLE_TTL = "30 seconds"; +const OPENCODE_SERVER_IDLE_TTL = "30 seconds"; interface OpenCodeServerOwnerState { server: OpenCodeRuntime.OpenCodeServerProcess | null; @@ -176,6 +176,7 @@ export const make = Effect.fn("OpenCodeServerOwner.make")(function* (input: { }); }); +/** @public Service construction is part of the canonical Effect module API. */ export const layer = (input: { readonly binaryPath: string; readonly directory: string; diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index cb37df006587..6e625127cb18 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -181,7 +181,7 @@ const AUDIO_MIME_TYPES = new Set([ "audio/x-wav", "audio/webm", ]); -export const ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES = 20 * 1024 * 1024; +const ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES = 20 * 1024 * 1024; const TEXT_MIME_TYPES = new Set([ "application/json", "application/ld+json", diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts index 81ec91d265b0..1993c9457af5 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -72,9 +72,7 @@ const decodeSecurityWarning = Schema.decodeUnknownOption( * The agent marks "Allow Always" on shell and web tools with a prompt injection * warning in `_meta`. Surface it as option text so both clients can show it. */ -export function antigravitySecurityWarning( - option: EffectAcpSchema.PermissionOption, -): string | undefined { +function antigravitySecurityWarning(option: EffectAcpSchema.PermissionOption): string | undefined { const meta = option._meta; if (!Predicate.isObject(meta)) return undefined; const warning = Option.getOrUndefined(decodeSecurityWarning(meta[SECURITY_WARNING_META_KEY])); diff --git a/apps/server/src/provider/antigravityRelease.ts b/apps/server/src/provider/antigravityRelease.ts index c33f09cef465..90ba309c10c6 100644 --- a/apps/server/src/provider/antigravityRelease.ts +++ b/apps/server/src/provider/antigravityRelease.ts @@ -1,4 +1,4 @@ -export const ANTIGRAVITY_RELEASE_VERSION = "agy_acp_server_1.1.1"; +const ANTIGRAVITY_RELEASE_VERSION = "agy_acp_server_1.1.1"; export interface AntigravityReleaseAsset { readonly version: string; diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index ce2840f6c13d..6d7187792a99 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -438,9 +438,9 @@ export function openCodeQuestionId( * puts in the prompt. */ const OPENCODE_NATIVE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); -export const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024; +const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024; -export function isOpenCodeNativeFilePart(input: { +function isOpenCodeNativeFilePart(input: { readonly mimeType: string; readonly sizeBytes: number; }): boolean { diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index e8ff090a4ec9..74167a808840 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -33,7 +33,7 @@ const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review p * move on their own, so this mostly bounds how stale a Homebrew "latest" can * get; the npm registry check keeps its own cache. */ -export const MAINTENANCE_CAPABILITIES_CACHE_TTL = Duration.hours(1); +const MAINTENANCE_CAPABILITIES_CACHE_TTL = Duration.hours(1); const compactEnv = (input: Record>): NodeJS.ProcessEnv => Object.fromEntries( diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index e91560312566..121e5aeab7d1 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -211,6 +211,7 @@ function makeUpdateState(input: { }; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const providerRegistry = yield* ProviderRegistry; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 4b5d4ae3ee47..8c94b8bb977d 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -105,42 +105,6 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman return result; }).pipe(Effect.scoped); -export function detailFromResult( - result: CommandResult & { readonly timedOut?: boolean }, -): string | undefined { - if (result.timedOut) return "Timed out while running command."; - const stderr = nonEmptyTrimmed(result.stderr); - if (stderr) return stderr; - const stdout = nonEmptyTrimmed(result.stdout); - if (stdout) return stdout; - if (result.code !== 0) { - return `Command exited with code ${result.code}.`; - } - return undefined; -} - -export function extractAuthBoolean(value: unknown): boolean | undefined { - if (globalThis.Array.isArray(value)) { - for (const entry of value) { - const nested = extractAuthBoolean(entry); - if (nested !== undefined) return nested; - } - return undefined; - } - - if (!value || typeof value !== "object") return undefined; - - const record = value as Record; - for (const key of ["authenticated", "isAuthenticated", "loggedIn", "isLoggedIn"] as const) { - if (typeof record[key] === "boolean") return record[key]; - } - for (const key of ["auth", "status", "session", "account"] as const) { - const nested = extractAuthBoolean(record[key]); - if (nested !== undefined) return nested; - } - return undefined; -} - export function parseGenericCliVersion(output: string): string | null { const match = output.match(/\b(\d+\.\d+\.\d+)\b/); return match?.[1] ?? null; diff --git a/apps/server/src/provider/providerUpdateSettings.ts b/apps/server/src/provider/providerUpdateSettings.ts index 308d84a14467..30ad2bf8a286 100644 --- a/apps/server/src/provider/providerUpdateSettings.ts +++ b/apps/server/src/provider/providerUpdateSettings.ts @@ -10,7 +10,7 @@ export interface ProviderSnapshotSettings { readonly enableProviderUpdateChecks: boolean; } -export function makeProviderSnapshotSettings( +function makeProviderSnapshotSettings( provider: Settings, settings: ServerSettings, ): ProviderSnapshotSettings { diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index d9a04de6f607..b2195b1d890c 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; -export const makeProviderRegistryMock = ( +const makeProviderRegistryMock = ( providers: ReadonlyArray = [], ): ProviderRegistryShape => ({ getProviders: Effect.succeed(providers), From 3b6ce931cc225b4b743daaa063e0bc9875555582 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 01:11:10 -0700 Subject: [PATCH 09/22] refactor(server): classify source control exports (#10279) --- apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts | 1 + apps/server/src/pullRequest/BitbucketPullRequestApi.ts | 1 + apps/server/src/pullRequest/GitHubPullRequestCli.ts | 3 ++- apps/server/src/pullRequest/GitLabPullRequestCli.ts | 1 + apps/server/src/pullRequest/PullRequestProviderRegistry.ts | 2 ++ apps/server/src/sourceControl/AzureDevOpsCli.ts | 3 +-- .../src/sourceControl/AzureDevOpsSourceControlProvider.ts | 3 --- apps/server/src/sourceControl/BitbucketApi.ts | 3 ++- .../src/sourceControl/BitbucketSourceControlProvider.ts | 3 --- apps/server/src/sourceControl/GitHubCli.ts | 1 + apps/server/src/sourceControl/GitHubSourceControlProvider.ts | 3 --- apps/server/src/sourceControl/GitLabCli.ts | 2 +- apps/server/src/sourceControl/GitLabSourceControlProvider.ts | 3 --- apps/server/src/sourceControl/SourceControlDiscovery.ts | 1 + apps/server/src/sourceControl/SourceControlProvider.ts | 2 +- .../src/sourceControl/SourceControlProviderDiscovery.ts | 2 +- apps/server/src/sourceControl/SourceControlRateLimit.ts | 1 + .../src/sourceControl/SourceControlRepositoryService.ts | 1 + apps/server/src/sourceControl/azureDevOpsPullRequests.ts | 4 +--- apps/server/src/sourceControl/gitHubPullRequests.ts | 4 +--- apps/server/src/sourceControl/gitLabMergeRequests.ts | 4 +--- apps/server/src/sourceControl/githubGraphQlBudget.ts | 1 + 22 files changed, 21 insertions(+), 28 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index 85702bb1d061..91a39e8de3ec 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -261,6 +261,7 @@ function isReviewerName(value: string): boolean { return name.length > 0 && !name.startsWith("-"); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const azure = yield* AzureDevOpsCli.AzureDevOpsCli; diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index 9b8db662f7e0..632f0d9a550f 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -378,6 +378,7 @@ function bitbucketReviewPosition( } } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 22989a94f04a..6152df75fb73 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -714,7 +714,7 @@ export class GitHubPullRequestCli extends Context.Service< * The host is not read off the identity: it travels alongside it, because the identity a * project records is the path below its host and never names the host itself. */ -export function parseRepositorySelector(value: string): { +function parseRepositorySelector(value: string): { readonly owner: string; readonly name: string; } { @@ -980,6 +980,7 @@ function actionArgs( } } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const github = yield* GitHubCli.GitHubCli; const graphQlBudget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts index a1510c4f0a3d..60bf2c55742e 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -511,6 +511,7 @@ function actionArgs( } } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const gitlab = yield* GitLabCli.GitLabCli; diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index d2caf3ff35bd..f5b4251f7d9a 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -41,6 +41,8 @@ export function fromProviders( /** * The hosts this build can read change requests from. A host with no entry here still shows up * in the provider list as unimplemented, so its projects are explained rather than missing. + * + * @public Service construction is part of the canonical Effect module API. */ export const make = Effect.map( Effect.all([ diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index b0018c1fc4c1..f5e436ef6c56 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -203,8 +203,6 @@ export const AzureDevOpsCliError = Schema.Union([ ]); export type AzureDevOpsCliError = typeof AzureDevOpsCliError.Type; -export const isAzureDevOpsCliError = Schema.is(AzureDevOpsCliError); - export interface AzureDevOpsRepositoryCloneUrls { readonly nameWithOwner: string; readonly url: string; @@ -352,6 +350,7 @@ function decodeAzureDevOpsJson( ); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 20a74cc8a5d7..c2f6a5efc65a 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -1,5 +1,4 @@ import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; @@ -235,5 +234,3 @@ export const make = Effect.gen(function* () { ), }); }); - -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index b30e934f8ce6..1223f95f68fd 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -262,7 +262,7 @@ export const BitbucketApiError = Schema.Union([ BitbucketCheckoutError, ]); export type BitbucketApiError = typeof BitbucketApiError.Type; -export const isBitbucketApiError = Schema.is(BitbucketApiError); +const isBitbucketApiError = Schema.is(BitbucketApiError); const RawBitbucketRepositorySchema = Schema.Struct({ full_name: TrimmedNonEmptyString, @@ -606,6 +606,7 @@ function responseError( }); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* BitbucketApiEnvConfig; const httpClient = yield* HttpClient.HttpClient; diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index ffa3fb1301c9..e27aa4c7dc88 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -1,5 +1,4 @@ import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; @@ -186,8 +185,6 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); - export const makeDiscovery = Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 57b9ee6e7a63..cd8782c87d9e 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -338,6 +338,7 @@ function deriveRepositoryCloneUrlsFromCreateOutput( }; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 74f08a9a9127..d82832564a24 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -1,6 +1,5 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import { @@ -322,5 +321,3 @@ export const make = Effect.gen(function* () { ), }); }); - -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 9c9b1d4bedd0..9d03e1ab8aa5 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -237,7 +237,6 @@ export const GitLabCliError = Schema.Union([ GitLabNamespaceDecodeError, ]); export type GitLabCliError = typeof GitLabCliError.Type; -export const isGitLabCliError = Schema.is(GitLabCliError); export interface GitLabMergeRequestSummary { readonly number: number; @@ -409,6 +408,7 @@ function parseRepositoryPath(repository: string): { return { namespacePath, projectPath }; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 28211c6b8509..5eb9b326423f 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -1,5 +1,4 @@ import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; @@ -250,5 +249,3 @@ export const make = Effect.gen(function* () { ), }); }); - -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index 660f32283e0f..2628360780e1 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -64,6 +64,7 @@ export class SourceControlDiscovery extends Context.Service< } >()("t3/sourceControl/SourceControlDiscovery") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* ServerConfig; const process = yield* VcsProcess.VcsProcess; diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index 5f93dbcaa425..1844e1bfa7cb 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -61,7 +61,7 @@ export function parseSourceControlOwnerRef( return owner && refName ? { owner, refName } : undefined; } -export function normalizeSourceBranch(headSelector: string): string { +function normalizeSourceBranch(headSelector: string): string { return parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim(); } diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index b2b9e4513378..69ac90edbfb3 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -115,7 +115,7 @@ export function providerAuth(input: { }; } -export function unknownAuth(detail?: string): SourceControlProviderAuth { +function unknownAuth(detail?: string): SourceControlProviderAuth { return providerAuth({ status: "unknown", detail }); } diff --git a/apps/server/src/sourceControl/SourceControlRateLimit.ts b/apps/server/src/sourceControl/SourceControlRateLimit.ts index e71516946e7c..b936c456079b 100644 --- a/apps/server/src/sourceControl/SourceControlRateLimit.ts +++ b/apps/server/src/sourceControl/SourceControlRateLimit.ts @@ -82,6 +82,7 @@ export function retryAtFromHeader(value: string | undefined, now: number): numbe return Number.isFinite(retryAt) && retryAt > now ? retryAt : undefined; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const entries = yield* Ref.make>(new Map()); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index b38fe3d5c302..0addeca9e785 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -77,6 +77,7 @@ function selectRemoteUrl( } } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index 24c0e49fd8f4..d11d1a87077a 100644 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -5,7 +5,7 @@ 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 { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; export interface NormalizedAzureDevOpsPullRequestRecord { readonly number: number; @@ -190,8 +190,6 @@ const decodeAzureDevOpsPullRequestList = decodeJsonResult(Schema.Array(Schema.Un const decodeAzureDevOpsPullRequest = decodeJsonResult(AzureDevOpsPullRequestSchema); const decodeAzureDevOpsPullRequestEntry = Schema.decodeUnknownExit(AzureDevOpsPullRequestSchema); -export const formatAzureDevOpsJsonDecodeError = formatSchemaError; - export function decodeAzureDevOpsPullRequestListJson( raw: string, ): Result.Result< diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index 822de1e02797..b233d900adf1 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -5,7 +5,7 @@ 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 { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; export interface NormalizedGitHubPullRequestRecord { readonly number: number; @@ -114,8 +114,6 @@ const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema); const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); -export const formatGitHubJsonDecodeError = formatSchemaError; - export function decodeGitHubPullRequestListJson( raw: string, ): Result.Result< diff --git a/apps/server/src/sourceControl/gitLabMergeRequests.ts b/apps/server/src/sourceControl/gitLabMergeRequests.ts index 0525260df51b..d03d43411224 100644 --- a/apps/server/src/sourceControl/gitLabMergeRequests.ts +++ b/apps/server/src/sourceControl/gitLabMergeRequests.ts @@ -5,7 +5,7 @@ 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 { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; export interface NormalizedGitLabMergeRequestRecord { readonly number: number; @@ -129,8 +129,6 @@ const decodeGitLabMergeRequestList = decodeJsonResult(Schema.Array(Schema.Unknow const decodeGitLabMergeRequest = decodeJsonResult(GitLabMergeRequestSchema); const decodeGitLabMergeRequestEntry = Schema.decodeUnknownExit(GitLabMergeRequestSchema); -export const formatGitLabJsonDecodeError = formatSchemaError; - export function decodeGitLabMergeRequestListJson( raw: string, ): Result.Result< diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.ts b/apps/server/src/sourceControl/githubGraphQlBudget.ts index 9ece27547f5e..05e4b3a5ce24 100644 --- a/apps/server/src/sourceControl/githubGraphQlBudget.ts +++ b/apps/server/src/sourceControl/githubGraphQlBudget.ts @@ -77,6 +77,7 @@ function withRateLimit(document: string): string { return `${document.slice(0, end)}\n ${RATE_LIMIT_SELECTION}\n${document.slice(end)}`; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const snapshots = yield* Ref.make>(new Map()); From 7cdeb696e699309b827c1d1b613397f9bcd73b3d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 01:17:24 -0700 Subject: [PATCH 10/22] refactor(server): classify source control registry API (#10280) --- apps/server/src/sourceControl/SourceControlProviderRegistry.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 9fe089a4184c..e9b61c17a4f4 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -194,6 +194,7 @@ function bindProviderContext( }); } +/** @public Service construction is part of the canonical Effect module API. */ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( function* (registrations: ReadonlyArray) { const config = yield* ServerConfig; From 1f0a14cf7f1bb1b6167fca475e9b7183bc8ec8af Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 01:17:24 -0700 Subject: [PATCH 11/22] refactor(server): classify preview toolkit exports (#10281) --- .../src/mcp/toolkits/preview/handlers.ts | 2 -- apps/server/src/mcp/toolkits/preview/tools.ts | 26 +++++++++---------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index ac4e124ea312..3cf3322cc62d 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -200,5 +200,3 @@ export const PreviewStandardToolkitHandlersLive = PreviewStandardToolkit.toLayer export const PreviewSnapshotToolkitHandlersLive = PreviewSnapshotToolkit.toLayer({ preview_snapshot, }); - -export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index cc2e572bc373..0429f441ff0d 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -47,7 +47,7 @@ const safeBrowserTool = (tool: T): T => const readonlyBrowserTool = (tool: T): T => safeBrowserTool(tool).annotate(Tool.Readonly, true).annotate(Tool.Idempotent, true) as T; -export const PreviewStatusTool = Tool.make("preview_status", { +const PreviewStatusTool = Tool.make("preview_status", { description: "Report whether a collaborative browser tab is automation-capable, including its URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab.", parameters: PreviewAutomationTabTargetInput, @@ -60,7 +60,7 @@ export const PreviewStatusTool = Tool.make("preview_status", { .annotate(Tool.Destructive, false) .annotate(Tool.Idempotent, true); -export const PreviewOpenTool = browserTool( +const PreviewOpenTool = browserTool( Tool.make("preview_open", { description: "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab.", @@ -73,7 +73,7 @@ export const PreviewOpenTool = browserTool( .annotate(Tool.Destructive, false), ); -export const PreviewNavigateTool = safeBrowserTool( +const PreviewNavigateTool = safeBrowserTool( Tool.make("preview_navigate", { description: "Navigate a collaborative browser tab. Pass tabId to target a specific tab, plus {url:'https://t3.chat'} for a website or {target:{kind:'environment-port',port:5173}} for a dev server. Exactly one of url or target is required.", @@ -84,7 +84,7 @@ export const PreviewNavigateTool = safeBrowserTool( }).annotate(Tool.Title, "Navigate browser preview"), ); -export const PreviewResizeTool = safeBrowserTool( +const PreviewResizeTool = safeBrowserTool( Tool.make("preview_resize", { description: "Resize a collaborative browser tab, optionally selected by tabId. Use {mode:'fill'}, {mode:'freeform',width:1024,height:768}, or {mode:'preset',preset:'iphone-12-pro',orientation:'portrait'}. This changes CSS layout breakpoints without changing the desktop browser user agent.", @@ -97,7 +97,7 @@ export const PreviewResizeTool = safeBrowserTool( .annotate(Tool.Idempotent, true), ); -export const PreviewSetAppearanceTool = safeBrowserTool( +const PreviewSetAppearanceTool = safeBrowserTool( Tool.make("preview_set_appearance", { description: "Emulate prefers-color-scheme in a collaborative browser tab, optionally selected by tabId. Use {colorScheme:'dark'} or {colorScheme:'light'} to preview the page in that appearance, and {colorScheme:'system'} to clear the override and follow the OS appearance.", @@ -129,7 +129,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool( }).annotate(Tool.Title, "Inspect browser page"), ); -export const PreviewClickTool = browserTool( +const PreviewClickTool = browserTool( Tool.make("preview_click", { description: "Click exactly one target in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; selector accepts legacy CSS; x and y must be supplied together.", @@ -140,7 +140,7 @@ export const PreviewClickTool = browserTool( }).annotate(Tool.Title, "Click preview page"), ); -export const PreviewTypeTool = browserTool( +const PreviewTypeTool = browserTool( Tool.make("preview_type", { description: "Insert literal text into one input in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; set clear=true to replace existing text.", @@ -151,7 +151,7 @@ export const PreviewTypeTool = browserTool( }).annotate(Tool.Title, "Type into preview page"), ); -export const PreviewPressTool = browserTool( +const PreviewPressTool = browserTool( Tool.make("preview_press", { description: "Press one keyboard key in the tab selected by tabId, or this agent session's current tab when omitted. Examples: {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}.", @@ -162,7 +162,7 @@ export const PreviewPressTool = browserTool( }).annotate(Tool.Title, "Press key in preview page"), ); -export const PreviewScrollTool = safeBrowserTool( +const PreviewScrollTool = safeBrowserTool( Tool.make("preview_scroll", { description: "Scroll the tab selected by tabId, or this agent session's current tab when omitted. Positive deltaY scrolls down and positive deltaX scrolls right; a locator/selector targets a container.", @@ -173,7 +173,7 @@ export const PreviewScrollTool = safeBrowserTool( }).annotate(Tool.Title, "Scroll preview page"), ); -export const PreviewEvaluateTool = browserTool( +const PreviewEvaluateTool = browserTool( Tool.make("preview_evaluate", { description: "Evaluate JavaScript in the tab selected by tabId, or this agent session's current tab when omitted. Returns a serializable result up to 64 KB; the expression may mutate page state.", @@ -184,7 +184,7 @@ export const PreviewEvaluateTool = browserTool( }).annotate(Tool.Title, "Evaluate JavaScript in preview"), ); -export const PreviewWaitForTool = readonlyBrowserTool( +const PreviewWaitForTool = readonlyBrowserTool( Tool.make("preview_wait_for", { description: "Wait in the tab selected by tabId, or this agent session's current tab when omitted, until all supplied locator, selector, text, and URL conditions match.", @@ -195,7 +195,7 @@ export const PreviewWaitForTool = readonlyBrowserTool( }).annotate(Tool.Title, "Wait for preview page condition"), ); -export const PreviewRecordingStartTool = safeBrowserTool( +const PreviewRecordingStartTool = safeBrowserTool( Tool.make("preview_recording_start", { description: "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.", @@ -206,7 +206,7 @@ export const PreviewRecordingStartTool = safeBrowserTool( }).annotate(Tool.Title, "Start browser recording"), ); -export const PreviewRecordingStopTool = safeBrowserTool( +const PreviewRecordingStopTool = safeBrowserTool( Tool.make("preview_recording_stop", { description: "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and transfer the compressed recording once (up to 50 MiB) to an evidence file readable in this agent's environment. Returns its environment-local path after transfer succeeds.", From b28471567cebe20347205059bbbdb3efe1213528 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:19:39 +0000 Subject: [PATCH 12/22] chore(mobile): bump app version to 1.1.1 Co-authored-by: codex --- apps/mobile/app.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 1637a46f7b5d..217334fd754a 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -175,7 +175,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.1.0", + version: "1.1.1", runtimeVersion: { // Development manifests resolve on every launch, so avoid fingerprint's // expensive native-project calculation there. Preview and production stay From 7d620506a1fecf07aa2d623e72059d551709ae3a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 01:21:08 -0700 Subject: [PATCH 13/22] ci(knip): enforce server exports (#10282) --- docs/operations/development.md | 5 +++-- package.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/operations/development.md b/docs/operations/development.md index 94bab97ddedb..f913a8100e75 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -74,11 +74,12 @@ Windows investigation while that suite is not a required gate. ### Unused code `vp run knip:check` checks unused files and dependencies across the repo, then -unused runtime exports in `apps/desktop`, `apps/web`, and every internal package under +unused runtime exports in `apps/server`, `apps/desktop`, `apps/web`, and every internal package under `packages/`. CI enforces both checks. Exported types and Effect schemas are allowed without consumers. The schema preprocessor recognizes schema types, including aliases and schema classes; functions that create or decode -schemas remain checked. Completely unused files remain checked too. +schemas remain checked. Canonical Effect service construction APIs stay exported with an explicit +`@public` annotation, which Knip recognizes. Completely unused files remain checked too. Named exports in web UI component modules are kept as complete component sets. Knip ignores unused exports in `apps/web/src/components/ui/*.tsx`, while still reporting an entire unused file. Use `vp run knip --workspace apps/web` to audit one workspace, including exports, diff --git a/package.json b/package.json index cfa0684359f1..dd1a106a0409 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", From 134b7194b125dd4881ff6040f66997b23489b72e Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:26:42 +0530 Subject: [PATCH 14/22] feat(web): add previous/next turn navigation in minimap (#8531) --- .../components/chat/MessagesTimeline.logic.ts | 28 ++ .../components/chat/MessagesTimeline.test.tsx | 49 +++ .../src/components/chat/MessagesTimeline.tsx | 304 +++++++++++------- 3 files changed, 270 insertions(+), 111 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 08e4f8d26037..a87531673506 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -197,6 +197,34 @@ export function resolveTimelineMinimapIndexFromPointer(input: { return Math.max(0, Math.min(input.itemCount - 1, Math.round(progress * (input.itemCount - 1)))); } +export function resolveTimelineMinimapCurrentIndex(input: { + readonly scrollTop: number; + readonly scrollBottom: number; + readonly itemBounds: ReadonlyArray<{ + readonly top: number | null; + readonly height: number | null; + }>; +}): number | null { + let precedingIndex: number | null = null; + + for (const [index, item] of input.itemBounds.entries()) { + if (item.top === null) { + continue; + } + const inView = + item.top < input.scrollBottom && item.top + Math.max(1, item.height ?? 1) > input.scrollTop; + if (inView) { + // The first visible marker is the turn at the reader's current position. + return index; + } + if (item.top <= input.scrollTop) { + precedingIndex = index; + } + } + + return precedingIndex; +} + export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number): boolean { if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) { return false; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 67a3c78ed887..655156048c1d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -272,6 +272,25 @@ function buildSnapShotTimelineEntry(previewUrl?: string) { } describe("MessagesTimeline", () => { + it("renders previous and next controls with the minimap", () => { + const first = buildUserTimelineEntry("First turn"); + const secondBase = buildUserTimelineEntry("Second turn"); + const second = { + ...secondBase, + id: "entry-2", + message: { + ...secondBase.message, + id: MessageId.make("message-2"), + }, + }; + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('aria-label="Previous turn"'); + expect(markup).toContain('aria-label="Next turn"'); + }); + // Expanding history uses this suite's existing test renderer, deprecated in // React 19. Migrate these interaction tests together when a DOM test setup is added. it.each([{}, { text: "Text-only answer", file: "Answer with a file" }])( @@ -524,6 +543,7 @@ describe("MessagesTimeline", () => { const { resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, + resolveTimelineMinimapCurrentIndex, resolveTimelineMinimapHeightStyle, resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, @@ -583,6 +603,35 @@ describe("MessagesTimeline", () => { pointerY: 999, }), ).toBe(100); + expect( + resolveTimelineMinimapCurrentIndex({ + scrollTop: 100, + scrollBottom: 500, + itemBounds: [ + { top: 80, height: 20 }, + { top: 120, height: 20 }, + { top: 220, height: 20 }, + ], + }), + ).toBe(1); + expect( + resolveTimelineMinimapCurrentIndex({ + scrollTop: 150, + scrollBottom: 200, + itemBounds: [ + { top: 80, height: 20 }, + { top: 120, height: 20 }, + { top: 220, height: 20 }, + ], + }), + ).toBe(1); + expect( + resolveTimelineMinimapCurrentIndex({ + scrollTop: 0, + scrollBottom: 50, + itemBounds: [{ top: 80, height: 20 }], + }), + ).toBeNull(); expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false); expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false); expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 379617c7c0c2..1d4abe39bf39 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -85,6 +85,7 @@ import { CheckIcon, ChevronDownIcon, ChevronRightIcon, + ChevronUpIcon, CircleAlertIcon, DownloadIcon, EyeIcon, @@ -139,6 +140,7 @@ import { resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, + resolveTimelineMinimapCurrentIndex, resolveTimelineMinimapHeightStyle, resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, @@ -593,6 +595,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); + const [minimapCurrentIndex, setMinimapCurrentIndex] = useState(null); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -665,21 +668,33 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const scrollTop = state.scroll ?? 0; const scrollBottom = scrollTop + (state.scrollLength ?? 0); - for (const item of minimapItems) { - const strip = minimapStripMap.get(item.id); - if (!strip) { - continue; - } + const itemBounds = minimapItems.map((item) => ({ + top: resolveTimelineRowTop(state, item.rowIndex), + height: resolveTimelineRowHeight(state, item.rowIndex), + })); - const rowTop = resolveTimelineRowTop(state, item.rowIndex); - const rowHeight = resolveTimelineRowHeight(state, item.rowIndex); + for (const [index, item] of minimapItems.entries()) { + const strip = minimapStripMap.get(item.id); + const bounds = itemBounds[index]; + const rowTop = bounds?.top ?? null; + const rowHeight = bounds?.height ?? null; const inView = rowTop !== null && rowTop < scrollBottom && rowTop + Math.max(1, rowHeight ?? 1) > scrollTop; - strip.dataset.inView = inView ? "true" : "false"; + if (strip) { + strip.dataset.inView = inView ? "true" : "false"; + } } + const nextCurrentIndex = resolveTimelineMinimapCurrentIndex({ + scrollTop, + scrollBottom, + itemBounds, + }); + setMinimapCurrentIndex((current) => + current === nextCurrentIndex ? current : nextCurrentIndex, + ); }, [ citationPositioning, listRef, @@ -871,6 +886,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ items={minimapItems} hasPersistentGutter={minimapHasPersistentGutter} hitStripWidth={minimapHitStripWidth} + currentIndex={minimapCurrentIndex} stripMap={minimapStripMap} onSelect={(item) => { onManualNavigation(); @@ -972,12 +988,14 @@ function timelineMinimapEventTargetsPreview(target: EventTarget): boolean { function TimelineMinimap({ hasPersistentGutter, hitStripWidth, + currentIndex, items, stripMap, onSelect, }: { hasPersistentGutter: boolean; hitStripWidth: number; + currentIndex: number | null; items: ReadonlyArray; stripMap: Map; onSelect: (item: TimelineMinimapItem) => void; @@ -999,6 +1017,11 @@ function TimelineMinimap({ : resolvedActiveIndex === items.length - 1 ? "-100%" : "-50%"; + const resolvedCurrentIndex = + currentIndex !== null && currentIndex >= 0 && currentIndex < items.length ? currentIndex : null; + const previousItem = + resolvedCurrentIndex === null ? null : (items[resolvedCurrentIndex - 1] ?? null); + const nextItem = resolvedCurrentIndex === null ? null : (items[resolvedCurrentIndex + 1] ?? null); const resolveActiveIndexFromPointer = useCallback( (event: MouseEvent) => { @@ -1047,128 +1070,187 @@ function TimelineMinimap({ data-persistent-gutter={hasPersistentGutter ? "true" : "false"} >
- + ) : null} + + { + if (nextItem) onSelect(nextItem); + }} + /> +
); } +function TimelineMinimapNavigationButton({ + direction, + disabled, + onClick, +}: { + direction: "previous" | "next"; + disabled: boolean; + onClick: () => void; +}) { + const previous = direction === "previous"; + const label = previous ? "Previous turn" : "Next turn"; + const Icon = previous ? ChevronUpIcon : ChevronDownIcon; + + return ( + + + } + > + + + {label} + + ); +} + // --------------------------------------------------------------------------- // TimelineRowContent — the actual row component // --------------------------------------------------------------------------- From d6dbe8dd67facf4a43030993bebb58cc7e7ac141 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 8 Sep 2026 02:30:21 -0700 Subject: [PATCH 15/22] fix(web): stop the settings sidebar shifting when switching pages (#10705) Co-authored-by: Claude Fable 5.1 --- .../settings/SettingsSidebarNav.tsx | 137 +--------- .../settingsSectionVisibility.test.ts | 245 ------------------ .../settings/settingsSectionVisibility.ts | 186 ------------- apps/web/src/routes/settings.tsx | 5 +- 4 files changed, 3 insertions(+), 570 deletions(-) delete mode 100644 apps/web/src/components/settings/settingsSectionVisibility.test.ts delete mode 100644 apps/web/src/components/settings/settingsSectionVisibility.ts diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index e1993eb6125e..75624792c5d7 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -8,7 +8,6 @@ import { useState, type ComponentType, type KeyboardEvent, - type ReactNode, } from "react"; import { ArchiveIcon, @@ -24,13 +23,11 @@ import { Settings2Icon, XIcon, } from "lucide-react"; -import { useLocation, useNavigate, useRouterState } from "@tanstack/react-router"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { Button } from "../ui/button"; -import { Collapsible, CollapsiblePanel } from "../ui/collapsible"; import { Input } from "../ui/input"; import { Kbd } from "../ui/kbd"; -import { cn } from "../../lib/utils"; import { SidebarContent, SidebarFooter, @@ -38,18 +35,10 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, useSidebar, } from "../ui/sidebar"; import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; import { scrollToSettingsTarget } from "./settingsLayout"; -import { - getVisibleSettingsSectionIds, - observeSettingsSectionVisibility, - type SettingsSectionVisibilityState, -} from "./settingsSectionVisibility"; import { searchSettings, SETTINGS_SECTION_LABELS, @@ -106,102 +95,22 @@ const SETTINGS_NAV_ITEMS: ReadonlyArray<{ icon: SETTINGS_SECTION_ICONS[to], })); -const SETTINGS_PAGE_SECTIONS: Partial< - Readonly>> -> = { - "/settings/general": [ - { label: "Organization", targetId: "organization" }, - { label: "Behavior", targetId: "behavior" }, - { label: "Projects & threads", targetId: "projects-and-threads" }, - { label: "Confirmations", targetId: "confirmations" }, - { label: "Text generation", targetId: "text-generation" }, - { label: "About", targetId: "about" }, - { label: "Legacy features", targetId: "legacy-features" }, - ], - "/settings/appearance": [ - { label: "Colors & themes", targetId: "appearance" }, - { label: "Interface", targetId: "appearance-interface" }, - { label: "Motion", targetId: "motion" }, - { label: "Typography", targetId: "typography" }, - ], - "/settings/source-control": [ - { label: "Version control", targetId: "source-control" }, - { label: "Text generation", targetId: "source-control-text-generation" }, - ], - "/settings/connections": [ - { label: "This environment", targetId: "connections-environment" }, - { label: "Remote environments", targetId: "remote-environments" }, - ], -}; - function SettingsSectionIcon({ to }: { to: SettingsPath }) { const Icon = SETTINGS_SECTION_ICONS[to]; return ; } -function SettingsSubmenuCollapse({ - open, - children, -}: { - readonly open: boolean; - readonly children: ReactNode; -}) { - return ( - - - {children} - - - ); -} - export function SettingsSidebarNav({ pathname }: { pathname: string }) { const navigate = useNavigate(); const currentHash = useLocation({ select: (location) => location.hash }); - const resolvedPathname = useRouterState({ - select: (state) => state.resolvedLocation?.pathname, - }); const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); const searchInputRef = useRef(null); const [query, setQuery] = useState(""); const [activeResultIndex, setActiveResultIndex] = useState(0); - const [sectionVisibility, setSectionVisibility] = useState( - null, - ); const searchableItems = useAvailableSettingsSearchItems(); const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]); const isSearching = query.trim().length > 0; const hasResults = results.length > 0; - const activeSettingsPath = SETTINGS_NAV_ITEMS.find( - (item) => pathname === item.to || pathname.startsWith(`${item.to}/`), - )?.to; - const observedVisibilityScope = useMemo(() => { - const path = SETTINGS_NAV_ITEMS.find( - (item) => - resolvedPathname === item.to || resolvedPathname?.startsWith(`${item.to}/`) === true, - )?.to; - const pageSections = path ? SETTINGS_PAGE_SECTIONS[path] : undefined; - return path && pageSections ? { path, pageSections } : null; - }, [resolvedPathname]); - const visiblePageSectionIds = getVisibleSettingsSectionIds({ - activePath: activeSettingsPath, - scope: observedVisibilityScope, - visibility: sectionVisibility, - }); - - useEffect(() => { - if (!observedVisibilityScope) return; - const container = document.querySelector("[data-settings-page-layout]"); - if (!container) return; - - return observeSettingsSectionVisibility({ - container, - targetIds: observedVisibilityScope.pageSections.map((section) => section.targetId), - onChange(targetIds) { - setSectionVisibility({ scope: observedVisibilityScope, targetIds: new Set(targetIds) }); - }, - }); - }, [observedVisibilityScope]); useEffect(() => { setActiveResultIndex((index) => Math.min(index, Math.max(results.length - 1, 0))); @@ -261,24 +170,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { }, [isMobile, navigate, setOpenMobile], ); - const handlePageSectionClick = useCallback( - (to: SettingsPath, targetId: string) => { - if (isMobile) { - setOpenMobile(false); - } - if (pathname === to && scrollToSettingsTarget(targetId, { highlight: false })) { - return; - } - void navigate({ - to, - hash: targetId, - replace: true, - hashScrollIntoView: false, - state: { settingsTargetHighlight: false }, - }); - }, - [isMobile, navigate, pathname, setOpenMobile], - ); const clearSearch = useCallback(() => { setQuery(""); setActiveResultIndex(0); @@ -430,8 +321,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { {SETTINGS_NAV_ITEMS.map((item) => { const Icon = item.icon; - const pageSections = SETTINGS_PAGE_SECTIONS[item.to]; - const isActive = activeSettingsPath === item.to; + const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`); return ( {item.label} - {pageSections ? ( - - - {pageSections.map((section) => ( - - } - size="sm" - data-visible={visiblePageSectionIds.has(section.targetId)} - className={cn( - "w-full text-sidebar-muted-foreground/65", - visiblePageSectionIds.has(section.targetId) && - "font-medium text-sidebar-foreground", - )} - onClick={() => handlePageSectionClick(item.to, section.targetId)} - > - {section.label} - - - ))} - - - ) : null} ); })} diff --git a/apps/web/src/components/settings/settingsSectionVisibility.test.ts b/apps/web/src/components/settings/settingsSectionVisibility.test.ts deleted file mode 100644 index 90656ad7073a..000000000000 --- a/apps/web/src/components/settings/settingsSectionVisibility.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -import { - getVisibleSettingsSectionIds, - observeSettingsSectionVisibility, - type SettingsSectionVisibilityEnvironment, -} from "./settingsSectionVisibility"; - -type VisibilityEntry = Pick< - IntersectionObserverEntry, - "intersectionRatio" | "isIntersecting" | "target" ->; - -function createHarness( - targetIds: ReadonlyArray, - initialRoot: Element | null = { name: "initial-root" } as unknown as Element, -) { - const targets = new Map( - targetIds.map((targetId) => [targetId, { targetId } as unknown as Element]), - ); - const observed = new Set(); - const unobserve = vi.fn((target: Element) => observed.delete(target)); - const disconnectIntersections = vi.fn(); - const disconnectMutations = vi.fn(); - const intersectionCallbacks: Array<(entries: ReadonlyArray) => void> = []; - const intersectionRoots: Element[] = []; - let root = initialRoot; - let onMutation = () => {}; - - const environment: SettingsSectionVisibilityEnvironment = { - findRoot: () => root, - findTarget: (_root, targetId) => targets.get(targetId) ?? null, - createIntersectionObserver(callback, observedRoot) { - intersectionCallbacks.push(callback); - intersectionRoots.push(observedRoot); - return { - observe: (target) => observed.add(target), - unobserve, - disconnect: disconnectIntersections, - }; - }, - createMutationObserver(callback) { - onMutation = callback; - return { disconnect: disconnectMutations }; - }, - }; - - return { - environment, - targets, - observed, - unobserve, - disconnectIntersections, - disconnectMutations, - intersectionCallbacks, - intersectionRoots, - intersect(entries: ReadonlyArray) { - intersectionCallbacks.at(-1)?.(entries); - }, - mutate() { - onMutation(); - }, - replaceRoot(nextRoot: Element | null) { - root = nextRoot; - onMutation(); - }, - }; -} - -function visibleEntry( - target: Element, - { isIntersecting = true, intersectionRatio = 1 } = {}, -): VisibilityEntry { - return { target, isIntersecting, intersectionRatio }; -} - -describe("settings section visibility", () => { - it("does not reuse visibility when returning to the same sectioned route", () => { - const firstGeneralVisit = { path: "/settings/general" }; - const firstVisibility = { - scope: firstGeneralVisit, - targetIds: new Set(["text-generation"]), - }; - - expect( - getVisibleSettingsSectionIds({ - activePath: "/settings/general", - scope: firstGeneralVisit, - visibility: firstVisibility, - }), - ).toEqual(new Set(["text-generation"])); - expect( - getVisibleSettingsSectionIds({ - activePath: "/settings/providers", - scope: null, - visibility: firstVisibility, - }), - ).toEqual(new Set()); - - const secondGeneralVisit = { path: "/settings/general" }; - expect( - getVisibleSettingsSectionIds({ - activePath: "/settings/general", - scope: secondGeneralVisit, - visibility: firstVisibility, - }), - ).toEqual(new Set()); - }); - - it("accumulates visible sections and emits them in sidebar order", () => { - const harness = createHarness(["one", "two", "three"]); - const emissions: ReadonlyArray[] = []; - observeSettingsSectionVisibility({ - container: {} as Element, - targetIds: ["one", "two", "three"], - onChange: (visible) => emissions.push(visible), - environment: harness.environment, - }); - - harness.intersect([visibleEntry(harness.targets.get("two")!)]); - harness.intersect([visibleEntry(harness.targets.get("one")!)]); - - expect(emissions).toEqual([[], ["two"], ["one", "two"]]); - }); - - it("treats zero-ratio and non-intersecting entries as hidden", () => { - const harness = createHarness(["one", "two"]); - const emissions: ReadonlyArray[] = []; - observeSettingsSectionVisibility({ - container: {} as Element, - targetIds: ["one", "two"], - onChange: (visible) => emissions.push(visible), - environment: harness.environment, - }); - - const one = harness.targets.get("one")!; - const two = harness.targets.get("two")!; - harness.intersect([visibleEntry(one), visibleEntry(two)]); - harness.intersect([visibleEntry(one, { intersectionRatio: 0 })]); - harness.intersect([visibleEntry(two, { isIntersecting: false })]); - - expect(emissions).toEqual([[], ["one", "two"], ["two"], []]); - }); - - it("resyncs replaced and removed targets without retaining stale visibility", () => { - const harness = createHarness(["dynamic"]); - const emissions: ReadonlyArray[] = []; - observeSettingsSectionVisibility({ - container: {} as Element, - targetIds: ["dynamic"], - onChange: (visible) => emissions.push(visible), - environment: harness.environment, - }); - - const firstTarget = harness.targets.get("dynamic")!; - harness.intersect([visibleEntry(firstTarget)]); - const replacementTarget = { targetId: "dynamic-replacement" } as unknown as Element; - harness.targets.set("dynamic", replacementTarget); - harness.mutate(); - - expect(harness.unobserve).toHaveBeenCalledWith(firstTarget); - expect(harness.observed.has(replacementTarget)).toBe(true); - expect(emissions.at(-1)).toEqual([]); - - harness.intersect([visibleEntry(firstTarget)]); - expect(emissions.at(-1)).toEqual([]); - harness.intersect([visibleEntry(replacementTarget)]); - expect(emissions.at(-1)).toEqual(["dynamic"]); - - harness.targets.delete("dynamic"); - harness.mutate(); - expect(harness.unobserve).toHaveBeenCalledWith(replacementTarget); - expect(emissions.at(-1)).toEqual([]); - }); - - it("rebinds when navigation replaces the settings scroll root", () => { - const harness = createHarness(["section"]); - const emissions: ReadonlyArray[] = []; - observeSettingsSectionVisibility({ - container: {} as Element, - targetIds: ["section"], - onChange: (visible) => emissions.push(visible), - environment: harness.environment, - }); - - const firstTarget = harness.targets.get("section")!; - harness.intersect([visibleEntry(firstTarget)]); - const firstObserverCallback = harness.intersectionCallbacks[0]!; - const nextRoot = { name: "next-root" } as unknown as Element; - const nextTarget = { targetId: "next-section" } as unknown as Element; - harness.targets.set("section", nextTarget); - harness.replaceRoot(nextRoot); - - expect(harness.disconnectIntersections).toHaveBeenCalledOnce(); - expect(harness.intersectionRoots.at(-1)).toBe(nextRoot); - expect(harness.observed.has(nextTarget)).toBe(true); - expect(emissions.at(-1)).toEqual([]); - - firstObserverCallback([visibleEntry(firstTarget)]); - expect(emissions.at(-1)).toEqual([]); - harness.intersect([visibleEntry(nextTarget)]); - expect(emissions.at(-1)).toEqual(["section"]); - }); - - it("starts observing when the settings scroll root mounts later", () => { - const harness = createHarness(["section"], null); - const emissions: ReadonlyArray[] = []; - observeSettingsSectionVisibility({ - container: {} as Element, - targetIds: ["section"], - onChange: (visible) => emissions.push(visible), - environment: harness.environment, - }); - - expect(emissions).toEqual([[]]); - expect(harness.intersectionRoots).toEqual([]); - - const root = { name: "mounted-root" } as unknown as Element; - harness.replaceRoot(root); - harness.intersect([visibleEntry(harness.targets.get("section")!)]); - - expect(harness.intersectionRoots).toEqual([root]); - expect(emissions.at(-1)).toEqual(["section"]); - }); - - it("disconnects both observers and ignores callbacks after cleanup", () => { - const harness = createHarness(["one"]); - const onChange = vi.fn(); - const cleanup = observeSettingsSectionVisibility({ - container: {} as Element, - targetIds: ["one"], - onChange, - environment: harness.environment, - }); - - cleanup(); - harness.intersect([visibleEntry(harness.targets.get("one")!)]); - harness.mutate(); - - expect(harness.disconnectIntersections).toHaveBeenCalledOnce(); - expect(harness.disconnectMutations).toHaveBeenCalledOnce(); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenLastCalledWith([]); - }); -}); diff --git a/apps/web/src/components/settings/settingsSectionVisibility.ts b/apps/web/src/components/settings/settingsSectionVisibility.ts deleted file mode 100644 index 76b1fc943256..000000000000 --- a/apps/web/src/components/settings/settingsSectionVisibility.ts +++ /dev/null @@ -1,186 +0,0 @@ -type VisibilityEntry = Pick< - IntersectionObserverEntry, - "intersectionRatio" | "isIntersecting" | "target" ->; - -export type SettingsSectionVisibilityScope = { - readonly path: string; -}; - -export type SettingsSectionVisibilityState = { - readonly scope: SettingsSectionVisibilityScope; - readonly targetIds: ReadonlySet; -}; - -const EMPTY_VISIBLE_SETTINGS_SECTION_IDS: ReadonlySet = new Set(); - -export function getVisibleSettingsSectionIds({ - activePath, - scope, - visibility, -}: { - readonly activePath: string | undefined; - readonly scope: SettingsSectionVisibilityScope | null; - readonly visibility: SettingsSectionVisibilityState | null; -}): ReadonlySet { - if (!scope || activePath !== scope.path || visibility?.scope !== scope) { - return EMPTY_VISIBLE_SETTINGS_SECTION_IDS; - } - return visibility.targetIds; -} - -type ElementObserver = { - observe(target: Element): void; - unobserve(target: Element): void; - disconnect(): void; -}; - -type MutationSubscription = { - disconnect(): void; -}; - -export type SettingsSectionVisibilityEnvironment = { - findRoot(container: Element): Element | null; - findTarget(root: Element, targetId: string): Element | null; - createIntersectionObserver( - onEntries: (entries: ReadonlyArray) => void, - root: Element, - ): ElementObserver; - createMutationObserver(onMutation: () => void, container: Element): MutationSubscription; -}; - -function createBrowserEnvironment(): SettingsSectionVisibilityEnvironment { - return { - findRoot(container) { - return container.querySelector("[data-settings-page-scroll]"); - }, - findTarget(root, targetId) { - const target = root.ownerDocument.getElementById(targetId); - return target && root.contains(target) ? target : null; - }, - createIntersectionObserver(onEntries, scrollRoot) { - const observer = new IntersectionObserver(onEntries, { - root: scrollRoot, - threshold: 0, - }); - return observer; - }, - createMutationObserver(onMutation, container) { - const observer = new MutationObserver(onMutation); - observer.observe(container, { childList: true, subtree: true }); - return observer; - }, - }; -} - -export function observeSettingsSectionVisibility({ - container, - targetIds, - onChange, - environment = createBrowserEnvironment(), -}: { - readonly container: Element; - readonly targetIds: ReadonlyArray; - readonly onChange: (visibleTargetIds: ReadonlyArray) => void; - readonly environment?: SettingsSectionVisibilityEnvironment; -}): () => void { - const orderedTargetIds = [...new Set(targetIds)]; - const targetsById = new Map(); - const targetIdsByElement = new Map(); - const visibleTargetIds = new Set(); - let lastEmission: string | null = null; - let stopped = false; - let root: Element | null = null; - let intersectionObserver: ElementObserver | null = null; - let observerGeneration = 0; - - const emit = () => { - const visibleInOrder = orderedTargetIds.filter((targetId) => visibleTargetIds.has(targetId)); - const emissionKey = visibleInOrder.join("\0"); - if (emissionKey === lastEmission) return; - lastEmission = emissionKey; - onChange(visibleInOrder); - }; - - const handleEntries = (entries: ReadonlyArray, generation: number) => { - if (stopped || generation !== observerGeneration) return; - let changed = false; - for (const entry of entries) { - const targetId = targetIdsByElement.get(entry.target); - if (!targetId || targetsById.get(targetId) !== entry.target) continue; - const visible = entry.isIntersecting && entry.intersectionRatio > 0; - if (visible === visibleTargetIds.has(targetId)) continue; - changed = true; - if (visible) { - visibleTargetIds.add(targetId); - } else { - visibleTargetIds.delete(targetId); - } - } - if (changed) emit(); - }; - - const syncTargets = () => { - if (stopped) return; - let changed = false; - const nextRoot = environment.findRoot(container); - - if (nextRoot !== root) { - observerGeneration += 1; - intersectionObserver?.disconnect(); - intersectionObserver = null; - root = nextRoot; - targetsById.clear(); - targetIdsByElement.clear(); - changed = visibleTargetIds.size > 0; - visibleTargetIds.clear(); - - if (root) { - const generation = observerGeneration; - intersectionObserver = environment.createIntersectionObserver( - (entries) => handleEntries(entries, generation), - root, - ); - } - } - - if (!root || !intersectionObserver) { - if (changed) emit(); - return; - } - - for (const targetId of orderedTargetIds) { - const previousTarget = targetsById.get(targetId) ?? null; - const nextTarget = environment.findTarget(root, targetId); - if (previousTarget === nextTarget) continue; - - if (previousTarget) { - intersectionObserver.unobserve(previousTarget); - targetsById.delete(targetId); - targetIdsByElement.delete(previousTarget); - changed = visibleTargetIds.delete(targetId) || changed; - } - if (nextTarget) { - targetsById.set(targetId, nextTarget); - targetIdsByElement.set(nextTarget, targetId); - intersectionObserver.observe(nextTarget); - } - } - - if (changed) emit(); - }; - - const mutationObserver = environment.createMutationObserver(syncTargets, container); - syncTargets(); - emit(); - - return () => { - stopped = true; - observerGeneration += 1; - intersectionObserver?.disconnect(); - mutationObserver.disconnect(); - targetsById.clear(); - targetIdsByElement.clear(); - visibleTargetIds.clear(); - }; -} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 696ad200f3b7..5e921fca5c2c 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -69,10 +69,7 @@ function SettingsContentLayout() { }, [navigateBackWithinApp]); return ( - +
From 83b865fec704b177804aa6b9b695aefea4ccd04a Mon Sep 17 00:00:00 2001 From: Shadman Taqi <61359614+iamshadmantaqi@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:48:54 +0600 Subject: [PATCH 16/22] fix(web): copy terminal selection with Ctrl+Insert (#8541) --- apps/web/src/terminal/ghostty/surface.test.ts | 14 ++++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 12 ++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index ee3240b41b08..2ef7fe26a115 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -556,6 +556,20 @@ describe("isTerminalCopyShortcut", () => { expect(isTerminalCopyShortcut(event({ key: "C", metaKey: true }), "MacIntel")).toBe(true); expect(isTerminalCopyShortcut(event({ key: "j", metaKey: true }), "MacIntel")).toBe(false); }); + + it("supports the conventional Ctrl+Insert copy shortcut", () => { + expect(isTerminalCopyShortcut(event({ key: "Insert", ctrlKey: true }), "Linux x86_64")).toBe( + true, + ); + expect(isTerminalCopyShortcut(event({ key: "Insert" }), "Linux x86_64")).toBe(false); + expect( + isTerminalCopyShortcut( + event({ key: "Insert", ctrlKey: true, shiftKey: true }), + "Linux x86_64", + ), + ).toBe(false); + expect(isTerminalCopyShortcut(event({ key: "Insert", ctrlKey: true }), "MacIntel")).toBe(false); + }); }); describe("applyTerminalCopyEvent", () => { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 29aaac6f6abd..ca1cd5a18eb2 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -321,7 +321,11 @@ export function isTerminalCopyShortcut( event: Pick, platform = navigator.platform, ) { - if (event.key.toLowerCase() !== "c") return false; + const key = event.key.toLowerCase(); + if (key === "insert" && !isMacPlatform(platform)) { + return event.ctrlKey && !event.shiftKey && !event.metaKey; + } + if (key !== "c") return false; return isMacPlatform(platform) ? event.metaKey : event.ctrlKey; } @@ -1027,12 +1031,12 @@ export class GhosttyTerminalSurface { // A plain Ctrl+C/Cmd+C fires the browser's native copy event, caught in // onCopyEvent; not preventing the default keeps that path alive. WebKit // omits the keyboard copy event without a DOM selection, so race the - // clipboard write against it the same way paste races its read. The - // Shift variant has no native event (Chrome binds Ctrl+Shift+C to + // clipboard write against it the same way paste races its read. Ctrl+Shift+C + // and Ctrl+Insert have no native copy event (Chrome binds the former to // inspect), so synthesize one with execCommand("copy"). const selection = this.getSelection(); this.primeCopy(selection); - if (event.shiftKey) { + if (event.shiftKey || event.key.toLowerCase() === "insert") { event.preventDefault(); document.execCommand("copy"); } else { From 82451eeb7277b0b7fb5a7cb53156c83f948f32c1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 8 Sep 2026 03:08:53 -0700 Subject: [PATCH 17/22] fix(web): show the same project icon in the command palette as everywhere else (#10712) Co-authored-by: Claude Fable 5.1 --- .../components/CommandPalette.logic.test.ts | 39 ++++++++++++++++++- .../src/components/CommandPalette.logic.ts | 25 ++++++++---- apps/web/src/components/CommandPalette.tsx | 2 +- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index b11d88b764b1..5ec9872597ef 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import type { Thread } from "../types"; +import type { Project, Thread } from "../types"; import { buildBrowseGroups, + buildProjectActionItems, buildThreadActionItems, enumerateCommandPaletteItems, filterPinnedBrowseEntries, @@ -112,6 +113,20 @@ describe("enumerateCommandPaletteItems", () => { const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const PROJECT_ID = ProjectId.make("project-1"); +function makeProject(overrides: Partial = {}): Project { + return { + id: PROJECT_ID, + environmentId: LOCAL_ENVIRONMENT_ID, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + ...overrides, + }; +} + function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.make("thread-1"), @@ -139,6 +154,28 @@ function makeThread(overrides: Partial = {}): Thread { }; } +describe("buildProjectActionItems", () => { + it("shows the grouped display name but keeps the real title for icons", () => { + const project = makeProject({ title: "fleet", workspaceRoot: "/Users/theo/Code/p/fleet" }); + const iconTitles: string[] = []; + const [item] = buildProjectActionItems({ + projects: [{ ...project, displayName: "t3dotgg/fleet" }], + valuePrefix: "project", + icon: (candidate) => { + iconTitles.push(candidate.title); + return null; + }, + runProject: async () => undefined, + }); + + expect(item?.title).toBe("t3dotgg/fleet"); + expect(item?.searchTerms).toEqual( + expect.arrayContaining(["t3dotgg/fleet", "fleet", "/Users/theo/Code/p/fleet"]), + ); + expect(iconTitles).toEqual(["fleet"]); + }); +}); + describe("buildThreadActionItems", () => { it("orders threads by most recent activity and formats timestamps from updatedAt", () => { vi.useFakeTimers(); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 2492ca0cf986..e91de65d5ca9 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -139,20 +139,31 @@ export function enumerateCommandPaletteItems( export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; +// A project as the palette shows it. `displayName` is the grouped label (for +// example "owner/repo" when projects are merged across machines). Keep `title` +// as the real project title: the automatic project icon is derived from it, and +// every other surface uses the real title, so overriding it desyncs the icon. +export type CommandPaletteProject = Project & { readonly displayName: string }; + export function buildProjectActionItems(input: { - projects: ReadonlyArray; + projects: ReadonlyArray; valuePrefix: string; - icon: (project: Project) => ReactNode; - runProject: (project: Project) => Promise; - searchTerms?: (project: Project) => ReadonlyArray; - renderDescription?: (project: Project) => ReactNode; + icon: (project: CommandPaletteProject) => ReactNode; + runProject: (project: CommandPaletteProject) => Promise; + searchTerms?: (project: CommandPaletteProject) => ReadonlyArray; + renderDescription?: (project: CommandPaletteProject) => ReactNode; shortcutCommand?: KeybindingCommand; }): CommandPaletteActionItem[] { return input.projects.map((project) => ({ kind: "action", value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, - searchTerms: [project.title, project.workspaceRoot, ...(input.searchTerms?.(project) ?? [])], - title: project.title, + searchTerms: [ + project.displayName, + project.title, + project.workspaceRoot, + ...(input.searchTerms?.(project) ?? []), + ], + title: project.displayName, description: input.renderDescription?.(project) ?? project.workspaceRoot, icon: input.icon(project), ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index fcf86362707e..dedc84832e0a 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -794,7 +794,7 @@ function OpenCommandPaletteDialog(props: { () => projectPickerEntries.map(({ group, targetProject }) => ({ ...targetProject, - title: group.displayName, + displayName: group.displayName, })), [projectPickerEntries], ); From d7a59c63c6a227ac295041660228a4aaa427ead0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 8 Sep 2026 03:10:54 -0700 Subject: [PATCH 18/22] fix(web): stop sidebar rows flashing and shifting on click (#10713) Co-authored-by: Claude Fable 5.1 --- apps/web/src/components/Sidebar.tsx | 42 ++++++++++++++++------ apps/web/src/components/ui/scroll-area.tsx | 7 +++- apps/web/src/components/ui/sidebar.tsx | 9 ++++- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 674a8c3ef8f5..f790969baf7b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -285,6 +285,9 @@ function WorkingDuration(props: { startedAt: string | null }) { } const EMPTY_PROVIDER_ENTRIES: ReadonlyMap = new Map(); +// Collapsed shelves share one empty list so a route change alone does not +// give the sidebar list a new identity. +const EMPTY_THREADS: readonly EnvironmentThreadShell[] = []; function terminalProcessLabel(count: number): string { return `${count} terminal ${count === 1 ? "process" : "processes"} running`; @@ -1535,7 +1538,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { data-thread-item {...sortableRootProps} className={cn( - "list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]", + // Matches the h-9 row so unrendered rows never shift the list when they paint. + "list-none [content-visibility:auto] [contain-intrinsic-size:auto_36px]", sortable?.isDragging && "relative z-20", )} > @@ -1693,7 +1697,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { data-thread-item {...sortableRootProps} className={cn( - "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]", + // Matches the h-[4.875rem] content box; the py-0.5 padding is added on top. + "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_78px]", sortable?.isDragging && "relative z-20", )} > @@ -2664,12 +2669,12 @@ export default function Sidebar() { ); const renderedSettledThreads = useMemo(() => { if (settledShelfExpanded) return visibleSettledThreads; - if (routeThreadKey === null) return []; + if (routeThreadKey === null) return EMPTY_THREADS; const routeThread = visibleSettledThreads.find( (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); - return routeThread === undefined ? [] : [routeThread]; + return routeThread === undefined ? EMPTY_THREADS : [routeThread]; }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); // The snoozed shelf is collapsed by default: out of the way, never gone. @@ -2690,12 +2695,12 @@ export default function Sidebar() { // snoozed thread reached by route (deep link, open before snoozing // elsewhere) keeps its row — with highlight and wake affordance — same // exception the settled tail's "Show more" makes. - if (routeThreadKey === null) return []; + if (routeThreadKey === null) return EMPTY_THREADS; const routeThread = snoozedThreads.find( (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); - return routeThread === undefined ? [] : [routeThread]; + return routeThread === undefined ? EMPTY_THREADS : [routeThread]; }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); const orderedThreads = useMemo( @@ -3282,15 +3287,32 @@ export default function Sidebar() { } }, [cancelThreadDrag, dragState, sidebarListItems]); const listMotionPaused = dragState !== null; + // Every shell event rebuilds sidebarListItems, but rows only move when the + // rendered order or a row's section changes. Keying the motion pass on that + // keeps ordinary updates from forcing a layout read and animating rows + // whose position drifted for other reasons. + const sidebarListOrderKey = useMemo( + () => + sidebarListItems + .map((item) => (item.kind === "thread" ? `${item.key}:${item.section}` : item.marker)) + .join("\0"), + [sidebarListItems], + ); + const sidebarListHasRows = sidebarListItems.length + visibleDraftSessionCount > 0; useLayoutEffect(() => { // Drag release clears the baseline, so its commit cannot replay the // sortable preview; rows glide from their released positions instead. // Later thread actions can animate while writes settle. // Draft navigation can reveal a frozen row without changing the draft count. - listMotionRef.current?.update( - !listMotionPaused && sidebarListItems.length + visibleDraftSessionCount > 0, - ); - }, [listMotionPaused, routeDraftIdForRows, sidebarListItems, visibleDraftSessionCount]); + void sidebarListOrderKey; + listMotionRef.current?.update(!listMotionPaused && sidebarListHasRows); + }, [ + listMotionPaused, + routeDraftIdForRows, + sidebarListHasRows, + sidebarListOrderKey, + visibleDraftSessionCount, + ]); const handleThreadDragOver = useCallback( (event: DragOverEvent) => { const target = event.over diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index bfc10825b460..852a3ed10053 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -25,12 +25,16 @@ function ScrollArea({ className, children, scrollFade = false, + scrollFadePadding = true, scrollbarGutter = false, hideScrollbars = false, chainVerticalScroll = false, ...props }: ScrollAreaPrimitive.Root.Props & { scrollFade?: boolean; + /** Keep focused and highlighted items clear of the fade. Off for lists + * whose rows take focus on click, where the scroll would nudge the list. */ + scrollFadePadding?: boolean; scrollbarGutter?: boolean; hideScrollbars?: boolean; chainVerticalScroll?: boolean; @@ -45,7 +49,8 @@ function ScrollArea({ "h-full max-h-[inherit] overflow-auto overscroll-contain rounded-[inherit] outline-none transition-shadows focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain", chainVerticalScroll && "overscroll-y-auto", scrollFade && - "scroll-p-[var(--fade-size)] mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]", + "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]", + scrollFade && scrollFadePadding && "scroll-p-[var(--fade-size)]", scrollbarGutter && "scrollbar-gutter-stable", hideScrollbars && "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 22cb4808fadd..425db645736a 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -699,7 +699,14 @@ function SidebarContent({ return ( <> {fixedHeader ?
{fixedHeader}
: null} - + {/* Rows take focus on click. Scroll padding would make the browser nudge + the list whenever a focused row sits under the fade. */} +
Date: Tue, 8 Sep 2026 03:29:46 -0700 Subject: [PATCH 19/22] refactor(web): pass the project record to ProjectFavicon so icons cannot drift (#10714) Co-authored-by: Claude Fable 5.1 --- apps/web/src/components/ChatView.tsx | 5 +- apps/web/src/components/CommandPalette.tsx | 36 +--- apps/web/src/components/LegacySidebar.tsx | 8 +- .../src/components/ProjectFavicon.test.tsx | 52 ++--- apps/web/src/components/ProjectFavicon.tsx | 45 ++-- apps/web/src/components/Sidebar.tsx | 197 ++++-------------- .../src/components/ThreadCommandSubtitle.tsx | 20 +- apps/web/src/components/chat/ChatHeader.tsx | 24 +-- .../pullRequest/PullRequestListFilters.tsx | 36 +--- .../settings/ProjectSettingsPanel.tsx | 9 +- .../components/settings/ProjectsSettings.tsx | 11 +- .../components/settings/SettingsPanels.tsx | 25 +-- 12 files changed, 126 insertions(+), 342 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 37038e241154..63ce538e7c28 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8093,10 +8093,7 @@ export default function ChatView(props: ChatViewProps) { {...(routeKind === "draft" && draftId ? { draftId } : {})} activeThreadTitle={activeThread.title} isServerThread={isServerThread} - activeProjectName={activeProject?.title} - activeProjectCwd={activeProject?.workspaceRoot ?? null} - activeProjectFaviconPath={activeProject?.faviconPath ?? null} - activeProjectIcon={activeProject?.projectIcon ?? null} + activeProject={activeProject} openInCwd={gitCwd} activeProjectScripts={activeProjectScripts} preferredScriptId={ diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index dedc84832e0a..d146120f719d 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -178,16 +178,7 @@ import type { Project } from "../types"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; function projectFavicon(project: Project) { - return ( - - ); + return ; } function getEnvironmentBrowsePlatform(os: string | null | undefined): string { @@ -927,18 +918,8 @@ function OpenCommandPaletteDialog(props: { new Map(projects.map((project) => [project.id, project.workspaceRoot])), [projects], ); - const projectFaviconPathById = useMemo( - () => new Map(projects.map((project) => [project.id, project.faviconPath ?? null] as const)), - [projects], - ); - const projectIconByKey = useMemo( - () => - new Map( - projects.map( - (project) => - [`${project.environmentId}:${project.id}`, project.projectIcon ?? null] as const, - ), - ), + const projectByKey = useMemo( + () => new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project])), [projects], ); const projectTitleById = useMemo( @@ -1174,12 +1155,7 @@ function OpenCommandPaletteDialog(props: { ) ?? null; return ( )} - + diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index 98458cfa2dcd..854301bee7bc 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -66,7 +66,14 @@ vi.mock("../state/assets", () => ({ }, })); -import { ProjectFavicon } from "./ProjectFavicon"; +import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; + +function makeProject( + overrides: Partial & + Pick, +): ProjectFaviconProject { + return { environmentId: "environment-test" as EnvironmentId, ...overrides }; +} type ProjectFaviconImageProps = { readonly cacheKey: string; @@ -91,9 +98,7 @@ function resolveImageComponent(): { } { hooks.beginRender(); const element = ProjectFavicon({ - environmentId: "environment-test" as EnvironmentId, - cwd: "/workspace-test", - projectName: "workspace-test", + project: makeProject({ workspaceRoot: "/workspace-test", title: "workspace-test" }), }) as ReactElement; hooks.reset(); @@ -121,9 +126,7 @@ describe("ProjectFavicon", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ - environmentId: "environment-test" as EnvironmentId, - cwd: "/workspace/analytics-db", - projectName: "analytics-db", + project: makeProject({ workspaceRoot: "/workspace/analytics-db", title: "analytics-db" }), }) as ReactElement<{ readonly colorClassName?: string; readonly emoji?: string; @@ -139,9 +142,7 @@ describe("ProjectFavicon", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ - environmentId: "environment-test" as EnvironmentId, - cwd: "/workspace/agent-runtime", - projectName: "agent-runtime", + project: makeProject({ workspaceRoot: "/workspace/agent-runtime", title: "agent-runtime" }), }) as ReactElement<{ readonly colorClassName?: string; readonly emoji?: string; @@ -155,11 +156,12 @@ describe("ProjectFavicon", () => { it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => { const element = ProjectFavicon({ - environmentId: "environment-test" as EnvironmentId, - cwd: "/workspace/test", - projectName: "test", - faviconPath: "brand/icon.svg", - projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" }, + project: makeProject({ + workspaceRoot: "/workspace/test", + title: "test", + faviconPath: "brand/icon.svg", + projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" }, + }), }) as ReactElement<{ readonly children: ReactElement<{ readonly children: ReactElement<{ readonly name: string; readonly className: string }>; @@ -174,11 +176,12 @@ describe("ProjectFavicon", () => { it("renders a saved emoji ahead of an uploaded favicon", () => { const element = ProjectFavicon({ - environmentId: "environment-test" as EnvironmentId, - cwd: "/workspace/test", - projectName: "test", - faviconPath: "brand/icon.svg", - projectIcon: { kind: "emoji", emoji: "🦄" }, + project: makeProject({ + workspaceRoot: "/workspace/test", + title: "test", + faviconPath: "brand/icon.svg", + projectIcon: { kind: "emoji", emoji: "🦄" }, + }), }) as ReactElement<{ readonly emoji: string }>; expect(element.props.emoji).toBe("🦄"); @@ -208,10 +211,11 @@ describe("ProjectFavicon", () => { it("requests a saved favicon path when one is set", () => { ProjectFavicon({ - environmentId: "environment-test" as EnvironmentId, - cwd: "/workspace-test", - projectName: "workspace-test", - faviconPath: "brand/icon.svg", + project: makeProject({ + workspaceRoot: "/workspace-test", + title: "workspace-test", + faviconPath: "brand/icon.svg", + }), }); expect(testState.lastTarget).toMatchObject({ diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index c121f8b44940..15597e786c31 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,4 +1,5 @@ -import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; +import type { ProjectIconColor } from "@t3tools/contracts"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, @@ -94,21 +95,33 @@ const PROJECT_ICON_COLOR_BY_NAME: Record = { web: "sky", }; +// The slice of a project that decides its icon. Every surface must pass the +// project record itself (or a snapshot spread from it) so the saved title, favicon +// and icon override always travel together. Passing a display label as the title +// changes the automatic icon, which is how the command palette drifted once. +export type ProjectFaviconProject = Pick< + EnvironmentProject, + "environmentId" | "workspaceRoot" | "title" | "faviconPath" | "projectIcon" +>; + export function ProjectFavicon(input: { - environmentId: EnvironmentId; - cwd: string; - projectName: string; - faviconPath?: string | null | undefined; - projectIcon?: ProjectIconOverride | null | undefined; + project: ProjectFaviconProject; className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { - const src = useAtomValue(projectFaviconUrlAtom(input)); - if (input.projectIcon?.kind === "emoji") { - return ; + const { project } = input; + const src = useAtomValue( + projectFaviconUrlAtom({ + environmentId: project.environmentId, + cwd: project.workspaceRoot, + faviconPath: project.faviconPath, + }), + ); + if (project.projectIcon?.kind === "emoji") { + return ; } - if (input.projectIcon?.kind === "lucide") { - const colorClassName = projectIconColorClassName(input.projectIcon.color); + if (project.projectIcon?.kind === "lucide") { + const colorClassName = projectIconColorClassName(project.projectIcon.color); const iconClassName = cn( "inline-flex size-3.5 shrink-0 items-center justify-center", colorClassName, @@ -118,7 +131,7 @@ export function ProjectFavicon(input: {
- + {props.project ? ( + + ) : null} {props.projectDisplayName} @@ -798,11 +778,8 @@ interface SidebarDraftRowData { // subscription + closing divider) so per-keystroke composer updates // re-render only this block, never the whole sidebar. Vanishes at count 0. const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { - projectTitleByKey: ReadonlyMap; + projectByKey: ReadonlyMap; projectDisplayNameByKey: ReadonlyMap; - projectCwdByKey: ReadonlyMap; - projectFaviconPathByKey: ReadonlyMap; - projectIconByKey: ReadonlyMap; scopedProjectKeys: ReadonlySet | null; routeDraftId: string | null; onNavigateToDraft: (draftId: DraftId) => void; @@ -896,11 +873,8 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { draftId={draftId} session={session} composer={composer} - projectTitle={props.projectTitleByKey.get(projectKey) ?? null} + project={props.projectByKey.get(projectKey) ?? null} projectDisplayName={props.projectDisplayNameByKey.get(projectKey) ?? null} - projectCwd={props.projectCwdByKey.get(projectKey) ?? null} - projectFaviconPath={props.projectFaviconPathByKey.get(projectKey) ?? null} - projectIcon={props.projectIconByKey.get(projectKey) ?? null} isActive={draftId === props.routeDraftId} onNavigate={props.onNavigateToDraft} onDiscard={handleDiscard} @@ -987,10 +961,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { currentEnvironmentId: string | null; environmentLabel: string | null; environmentMachine: EnvironmentMachineKind; - projectCwd: string | null; - projectFaviconPath: string | null; - projectIcon: ProjectIconOverride | null; - projectTitle: string | null; + project: EnvironmentProject | null; projectDisplayName: string | null; providerEntryByInstanceId: ReadonlyMap; timestampFormat: TimestampFormat; @@ -1061,7 +1032,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { [clearComposerContent, threadRef], ); - const gitCwd = thread.worktreePath ?? props.projectCwd; + const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; const linkedPullRequestStatus = useLinkedThreadPullRequest( thread.environmentId, thread.linkedPullRequest ?? thread.branchPullRequest, @@ -1197,11 +1168,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const detailsTooltip = ( - + {props.project ? : null} {draftIndicator} {title} @@ -1722,14 +1683,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
{draftIndicator} - + {props.project ? ( + + ) : null} {props.projectDisplayName ? ( } > - + {props.project ? ( + + ) : null} {thread.title} {threadTimeLabel(thread)} @@ -2054,11 +2002,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { - new Map( - projects.map((project) => [ - `${project.environmentId}:${project.id}`, - project.workspaceRoot, - ]), - ), - [projects], - ); - const projectFaviconPathByKey = useMemo( - () => - new Map( - projects.map((project) => [`${project.environmentId}:${project.id}`, project.faviconPath]), - ), - [projects], - ); - const projectIconByKey = useMemo( - () => - new Map( - projects.map((project) => [`${project.environmentId}:${project.id}`, project.projectIcon]), - ), - [projects], - ); - // Icons use saved titles. Group labels can include a repository owner or a different title. - const projectTitleByKey = useMemo( - () => - new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])), + // Rows read the project record for its icon and cwd. Group labels can include + // a repository owner or a different title, so they travel separately. + const projectByKey = useMemo( + () => new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project])), [projects], ); const projectDisplayNameByKey = useMemo( @@ -3912,7 +3833,7 @@ export default function Sidebar() { if (!thread) return; const threadWorkspacePath = thread.worktreePath ?? - projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? + projectByKey.get(`${thread.environmentId}:${thread.projectId}`)?.workspaceRoot ?? null; // Un-settle pins the thread active until real activity clears the pin. // Environments without @@ -4135,7 +4056,7 @@ export default function Sidebar() { handleMultiSelectContextMenu, markThreadUnread, openProjectSettings, - projectCwdByKey, + projectByKey, serverConfigs, startThreadRename, updateThreadMetadata, @@ -4392,14 +4313,7 @@ export default function Sidebar() { > {scopedProjectGroup ? ( - + ) : ( @@ -4457,14 +4371,7 @@ export default function Sidebar() { }} > {project ? ( - + ) : ( )} @@ -4539,21 +4446,8 @@ export default function Sidebar() { {projectLabel ? ( - {props.projectCwd ? ( - + {props.project ? ( + ) : null} {projectLabel} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 98b661bf5ff6..fbebc323a950 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -6,6 +6,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -54,10 +55,7 @@ interface ChatHeaderProps { activeThreadTitle: string; /** Drafts have no server thread yet, so the title carries no action menu. */ isServerThread: boolean; - activeProjectName: string | undefined; - activeProjectCwd: string | null; - activeProjectFaviconPath: string | null; - activeProjectIcon: import("@t3tools/contracts").ProjectIconOverride | null; + activeProject: EnvironmentProject | null; openInCwd: string | null; activeProjectScripts: ReadonlyArray | undefined; preferredScriptId: string | null; @@ -126,10 +124,7 @@ export const ChatHeader = memo(function ChatHeader({ draftId, activeThreadTitle, isServerThread, - activeProjectName, - activeProjectCwd, - activeProjectFaviconPath, - activeProjectIcon, + activeProject, openInCwd, activeProjectScripts, preferredScriptId, @@ -161,6 +156,8 @@ export const ChatHeader = memo(function ChatHeader({ }); }, [panelAnimationDurationMs, panelAnimationsActive]); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const activeProjectName = activeProject?.title; + const activeProjectCwd = activeProject?.workspaceRoot ?? null; const fileScripts = useT3ProjectFileScripts( activeThreadEnvironmentId, activeProjectScripts ? activeProjectCwd : null, @@ -327,7 +324,7 @@ export const ChatHeader = memo(function ChatHeader({ {/* The project always leads the header: knowing which project a thread lives in is priority zero, and the thread title alone doesn't answer it. */} - {activeProjectName ? ( + {activeProject ? ( <> @@ -341,14 +338,7 @@ export const ChatHeader = memo(function ChatHeader({ /> } > - + {activeProjectName} New thread in {activeProjectName} diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index 1c703bce3e2b..153beb8a0dd5 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -25,7 +25,7 @@ import { import { type ElementType, useState } from "react"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; -import { ProjectFavicon } from "../ProjectFavicon"; +import { ProjectFavicon, type ProjectFaviconProject } from "../ProjectFavicon"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; import { Button } from "../ui/button"; @@ -57,12 +57,7 @@ export interface PullRequestFilterOption { readonly label: string; /** Uses the option's native icon tone. */ readonly Icon: ElementType<{ className?: string }>; - readonly favicon?: { - readonly environmentId: EnvironmentId; - readonly cwd: string; - readonly faviconPath?: string | null; - readonly projectIcon?: ProjectIconOverride | null; - }; + readonly project?: ProjectFaviconProject; /** Why it cannot be chosen, carried onto the item as its title. */ readonly unavailable?: string | undefined; } @@ -72,15 +67,8 @@ export function PullRequestFilterOptionIcon({ }: { option: PullRequestFilterOption; }) { - return option.favicon ? ( - + return option.project ? ( + ) : ( ); @@ -452,14 +440,7 @@ export function PullRequestFiltersMenu({ serverOptions: ReadonlyArray>; onServer: (server: EnvironmentId | undefined) => void; /** The projects of every connected environment, each carrying the one its favicon is read from. */ - projects: ReadonlyArray<{ - readonly id: ProjectId; - readonly environmentId: EnvironmentId; - readonly title: string; - readonly workspaceRoot: string; - readonly faviconPath?: string | null | undefined; - readonly projectIcon?: ProjectIconOverride | null | undefined; - }>; + projects: ReadonlyArray; projectId: ProjectId | undefined; /** * The server the selected project belongs to. A project id is only unique within its own @@ -514,12 +495,7 @@ export function PullRequestFiltersMenu({ value: pullRequestProjectKey(project), label: project.title, Icon: FolderGit2Icon, - favicon: { - environmentId: project.environmentId, - cwd: project.workspaceRoot, - faviconPath: project.faviconPath ?? null, - projectIcon: project.projectIcon ?? null, - }, + project, ...(unavailable.has(pullRequestProjectKey(project)) ? { unavailable: unavailable.get(pullRequestProjectKey(project)) } : {}), diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index e9cd1bbed43a..d88644fb7e3c 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -967,14 +967,7 @@ function ProjectDetail({ } control={
- +