From 86d8838e91b81d328fd8ced042860c7c60160dcd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 6 Nov 2026 02:21:39 -0800 Subject: [PATCH 01/51] fix(web): dragging a sidebar thread no longer scrolls the list to the bottom (#27691) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/Sidebar.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1ed3065a92c0d..2d904e1ec6f70 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -17,6 +17,7 @@ import { DndContext, useSensor, useSensors, + type AutoScrollOptions, type DragEndEvent, type DragOverEvent, type DragStartEvent, @@ -876,6 +877,9 @@ function checkThreadOperations( const SIDEBAR_DRAG_DISTANCE = 6; +// dnd-kit's 20% edge zone at full speed runs the list away while the pointer rests near the bottom. +const SIDEBAR_AUTO_SCROLL: AutoScrollOptions = { threshold: { x: 0, y: 0.05 }, acceleration: 3 }; + const SIDEBAR_DRAG_LABEL_HEIGHT = 24; const threadSearchResultId = (index: number) => `sidebar-thread-search-result-${index}`; @@ -3280,7 +3284,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve > Date: Fri, 6 Nov 2026 02:33:20 -0800 Subject: [PATCH 02/51] fix(web): tagging a thread by drag no longer scrolls the sidebar (#31924) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/Sidebar.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2d904e1ec6f70..d45ba6a3a3686 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1055,6 +1055,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } | null>(null); const dragSensorRef = useRef(null); const contextDragKeyRef = useRef(null); + const dragScrollRef = useRef<{ readonly viewport: Element; readonly top: number } | null>(null); const [actionSweep, setActionSweep] = useState<{ action: SidebarSweepAction; keys: ReadonlySet; @@ -1713,6 +1714,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const finishThreadDrag = (started: boolean) => { dragSensorRef.current = null; contextDragKeyRef.current = null; + dragScrollRef.current = null; endThreadContextDrag(); if (started) { listMotionRef.current?.release(); @@ -1739,6 +1741,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const bounds = threadListRef.current?.getBoundingClientRect(); return bounds !== undefined && (point.x < bounds.left || point.x > bounds.right); }; + // The path out of the list often crosses dnd-kit's edge zone, which autoscrolls the sidebar. + const restoreDragScroll = () => { + const scroll = dragScrollRef.current; + if (scroll && scroll.viewport.scrollTop !== scroll.top) scroll.viewport.scrollTop = scroll.top; + }; const moveThreadContextDrag = (point: { x: number; y: number }) => { const contextDrag = pointerOutsideThreadList(point); setDragState((current) => @@ -1750,6 +1757,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve endThreadContextDrag(); return false; } + restoreDragScroll(); const threads = contextDragThreads(); const title = threadByKeyRef.current.get(contextDragKeyRef.current ?? "")?.title.trim() || "Thread"; @@ -1758,6 +1766,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }; const dropThreadContextDrag = (point: { x: number; y: number }) => { if (!pointerOutsideThreadList(point)) return false; + restoreDragScroll(); dropThreadContext(point, contextDragThreads()); return true; }; @@ -1884,6 +1893,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if (activeSection === undefined) return; listMotionRef.current?.suspend(); const list = threadListRef.current; + const viewport = list?.closest('[data-slot="scroll-area-viewport"]'); + dragScrollRef.current = viewport ? { viewport, top: viewport.scrollTop } : null; const header = list?.querySelector('[data-testid="sidebar-pinned-header"]'); if (list && header) { const listRect = list.getBoundingClientRect(); From 9899b4724d14ad1911a228cfebeca35aa6cad8b1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 6 Nov 2026 02:41:05 -0800 Subject: [PATCH 03/51] ci: stacked PRs stop evicting the shared pnpm cache (#31937) A PR based on a branch other than the default saved a ~1 GB pnpm store that only its own reruns can read, which evicted shared entries. These PRs still restore and install; pushes, releases, and default-base PRs save as before. Co-Authored-By: Claude Opus 5.5 --- .github/actions/setup-ci-deps/action.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index e9b7ae70a36ad..b4fe25fab83fd 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -123,10 +123,12 @@ runs: key: ${{ steps.pnpm_verified.outputs.cache-primary-key }} # A filtered install that started from the current full store adds nothing - # worth a second copy of it. + # worth a second copy of it. A stacked PR, based on a branch other than the + # default, saves a ~1 GB store only its own reruns can read, so it only restores. - name: Save pnpm store if: >- inputs.install == 'true' && + (github.event_name != 'pull_request' || github.base_ref == github.event.repository.default_branch) && steps.pnpm_cache.outputs.cache-hit != 'true' && (steps.pnpm_store.outputs.namespace == 'full' || steps.pnpm_cache.outputs.cache-matched-key != format('pnpm-full-v1-{0}-{1}-{2}', runner.os, runner.arch, hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json'))) From 6dcaf6cbe727e520f5ff9d854d3be30c89b42bda Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 6 Nov 2026 02:54:00 -0800 Subject: [PATCH 04/51] fix(web): remote subagents no longer split from the lineage list (#29285) Co-authored-by: Claude Opus 5.5 --- ...hreadRelationshipsControl.browser.test.tsx | 5 + .../chat/ThreadRelationshipsControl.tsx | 98 ++++++++++++------- 2 files changed, 65 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx index 59f9fb69adc1d..6a677fc02f665 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx @@ -310,6 +310,11 @@ it("shows a remote subagent's full title in its tooltip even when its environmen { router: "/environment/fork" }, ); + // A finished remote task sits with the other finished agents, not in its own list. + await expect + .element(screen.getByRole("button", { name: new RegExp(longTitle) })) + .not.toBeInTheDocument(); + await screen.getByRole("button", { name: /Previous agents/ }).click(); const row = screen.getByRole("button", { name: new RegExp(longTitle) }); await userEvent.hover(row); await vi.waitFor(() => diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 55abc90a0fa4f..f392c652e05de 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -136,18 +136,32 @@ function ThreadLineageRowList(props: { ); } -function ThreadLineageGroup(props: { +type RemoteSubagent = OrchestrationV2Subagent & { + readonly remoteChild: OrchestrationV2RemoteTaskChild; +}; + +/** A task delegated to a linked environment has no thread here, so it gets its own row kind. */ +type LineageRow = ThreadRelationshipWalkRow | { readonly remote: RemoteSubagent }; + +const lineageRowStatus = (row: LineageRow) => + "remote" in row ? row.remote.status : row.edge.status; + +const isFinishedAgentStatus = (status: string | null | undefined) => + ["completed", "failed", "error", "cancelled", "interrupted", "idle"].includes(status ?? ""); + +function ThreadLineageGroup(props: { readonly label: string | null; - readonly rows: ReadonlyArray; + readonly rows: ReadonlyArray; readonly expanded: boolean; - readonly children: (rows: ReadonlyArray) => ReactNode; + readonly children: (rows: ReadonlyArray) => ReactNode; }) { const [expanded, setExpanded] = useState(props.expanded); const [visibleCount, setVisibleCount] = useState(THREAD_LINEAGE_INITIAL_COUNT); const { visibleRows, hiddenCount } = resolveThreadLineageWindow(props.rows, visibleCount); - const failedCount = props.rows.filter( - ({ edge }) => edge.status === "failed" || edge.status === "error", - ).length; + const failedCount = props.rows.filter((row) => { + const status = lineageRowStatus(row); + return status === "failed" || status === "error"; + }).length; if (props.rows.length === 0) return null; return (
@@ -247,9 +261,7 @@ function currentSubagent( * the thread there, when this client is connected to that environment. */ function RemoteSubagentRow(props: { - readonly agent: OrchestrationV2Subagent & { - readonly remoteChild: OrchestrationV2RemoteTaskChild; - }; + readonly agent: RemoteSubagent; readonly provider: ServerProvider | undefined; }) { const navigate = useNavigate(); @@ -537,22 +549,29 @@ export function ThreadRelationshipsPanel(props: { if (!isParent && graph.nodes.get(threadId)?.thread?.lineage.sideChat === true) return "sideChats"; if (edge.kind !== "subagent" || isParent) return "related"; - return ["completed", "failed", "error", "cancelled", "interrupted", "idle"].includes( - edge.status ?? "", - ) - ? "previous" - : "active"; + return isFinishedAgentStatus(edge.status) ? "previous" : "active"; }); - const groups = [ + const { remoteActive = [], remotePrevious = [] } = groupBy( + (projection?.subagents ?? []) + .filter((agent): agent is RemoteSubagent => agent.remoteChild !== undefined) + .map((remote) => ({ remote })), + ({ remote }) => (isFinishedAgentStatus(remote.status) ? "remotePrevious" : "remoteActive"), + ); + const groups: ReadonlyArray<{ + id: string; + label: string | null; + rows: ReadonlyArray; + expanded: boolean; + }> = [ { id: "related", label: null, rows: related, expanded: true }, - { id: "active", label: null, rows: active, expanded: true }, - { id: "previous", label: "Previous agents", rows: previous, expanded: false }, + { id: "active", label: null, rows: [...active, ...remoteActive], expanded: true }, + { + id: "previous", + label: "Previous agents", + rows: [...previous, ...remotePrevious], + expanded: false, + }, ]; - // Tasks running in a linked environment have no thread here; they get their own rows. - const remoteSubagents = (projection?.subagents ?? []).filter( - (agent): agent is OrchestrationV2Subagent & { remoteChild: OrchestrationV2RemoteTaskChild } => - agent.remoteChild !== undefined, - ); const runningCount = (projection?.subagents.filter( (agent) => agent.childThreadId === null && agent.status === "running", @@ -567,7 +586,8 @@ export function ThreadRelationshipsPanel(props: { active.length > 0 || previous.length > 0 || runningCount > 0 || - remoteSubagents.length > 0 || + remoteActive.length > 0 || + remotePrevious.length > 0 || delegatedFrom !== undefined || // Disconnect lives in this heading, so a thread with only side chats still shows it. (canDetach && sideChats.length > 0)); @@ -929,6 +949,7 @@ export function ThreadRelationshipsPanel(props: { canDetach || related.length > 0 || active.length > 0 || + remoteActive.length > 0 || delegatedFrom !== undefined } data-thread-relationships-panel @@ -968,23 +989,24 @@ export function ThreadRelationshipsPanel(props: { )} {groups.map((group) => ( - {(visibleRows) => visibleRows.map(renderRelationshipRow)} + {(visibleRows) => + visibleRows.map((row) => + "remote" in row ? ( + entry.instanceId === row.remote.providerInstanceId)} + /> + ) : ( + renderRelationshipRow(row) + ), + ) + } ))} - {remoteSubagents.length > 0 ? ( - {}}> - {remoteSubagents.map((agent) => ( - entry.instanceId === agent.providerInstanceId)} - /> - ))} - - ) : null} ) : null} {sideChatsVisible ? ( From 10af8eed7360a6efc302a036f66474d5012e4882 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 6 Nov 2026 02:55:31 -0800 Subject: [PATCH 05/51] perf(ci): release uploads stop recompressing installers and CLI archives (#31942) Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-desktop.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 3af4e6abeafe9..b7dce1a102e5e 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -449,6 +449,8 @@ jobs: name: cli-${{ inputs.platform }}-${{ inputs.arch }} path: release-cli/* if-no-files-found: error + # The archive is already gzip or zip; deflating it again saves under 2%. + compression-level: 0 - name: Install Linux desktop build libraries if: inputs.platform == 'linux' @@ -761,6 +763,8 @@ jobs: name: desktop-${{ inputs.platform }}-${{ inputs.arch }} path: release-publish/* if-no-files-found: error + # Installers, zips and blockmaps are already compressed; deflating them again saves under 0.3%. + compression-level: 0 - name: Upload resource monitor uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 From d14f87f80a3de1c732c47309ae3f6c573dcc430d Mon Sep 17 00:00:00 2001 From: "Patrick C. Toulme" <135739773+patrick-toulme@users.noreply.github.com> Date: Fri, 6 Nov 2026 05:58:40 -0500 Subject: [PATCH 06/51] feat(antigravity): identify as T3 Code to agy acp 1.5.1+ (#31158) Co-authored-by: Theo Browne Co-authored-by: Claude Opus 5.5 --- .../src/provider/AntigravityInstallation.ts | 1 + .../acp/AntigravityAcpSupport.test.ts | 24 +++++++++++-------- .../src/provider/acp/AntigravityAcpSupport.ts | 17 ++++++++++--- .../src/server/AcpSessionRuntime.ts | 1 + 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index f03a7874f8790..a89f1ab25b501 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -378,6 +378,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma tempDirectory: profileDirectory, }); const runtime = yield* makeAntigravityAcpRuntime({ + runtimeVersion: expectedVersion, spawn: buildAntigravityAcpSpawnInput({ installation: executable, profile, diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index ea5efe230a234..3c3d90186066f 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -16,6 +16,7 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { applyAntigravityAcpModelSelection, + antigravityClientInfo, antigravitySessionMeta, buildAntigravityPrompt, isQualifiedAntigravityRelease, @@ -59,17 +60,19 @@ function makeModelRuntime( describe("Antigravity release gates", () => { it.each([ - { version: "1.4.1", filter: false, native: false }, - { version: "1.4.2", filter: true, native: false }, - { version: "1.4.4", filter: true, native: false }, - { version: "1.4.5", filter: true, native: true }, - { version: "1.4.6", filter: false, native: false }, - { version: "1.3.0", filter: false, native: false }, - { version: undefined, filter: false, native: false }, - { version: "unknown", filter: false, native: false }, + { version: "1.4.1", filter: false, native: false, ownName: false }, + { version: "1.4.2", filter: true, native: false, ownName: false }, + { version: "1.4.4", filter: true, native: false, ownName: false }, + { version: "1.4.5", filter: true, native: true, ownName: false }, + { version: "1.4.6", filter: false, native: false, ownName: false }, + { version: "1.5.0", filter: false, native: false, ownName: false }, + { version: "1.5.1", filter: false, native: false, ownName: true }, + { version: "1.3.0", filter: false, native: false, ownName: false }, + { version: undefined, filter: false, native: false, ownName: false }, + { version: "unknown", filter: false, native: false, ownName: false }, ])( - "$version: workflow filter $filter, native reads and system prompt $native", - ({ version, filter, native }) => { + "$version: workflow filter $filter, native reads and system prompt $native, own client name $ownName", + ({ version, filter, native, ownName }) => { const initialize = { protocolVersion: 1, ...(version === undefined ? {} : { agentInfo: { name: "antigravity-acp", version } }), @@ -86,6 +89,7 @@ describe("Antigravity release gates", () => { filter || native ? { antigravity } : undefined, ); expect(isQualifiedAntigravityRelease(version)).toBe(native); + expect(antigravityClientInfo(version).name).toBe(ownName ? "t3-code" : "zed"); }, ); }); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index 52fbd6c6070fb..0834571785891 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -23,6 +23,7 @@ import { makeAntigravityStdoutTransform, } from "../antigravityAuthSupport.ts"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import packageJson from "../../../package.json" with { type: "json" }; import { normalizeAntigravitySessionUpdate } from "./AntigravityProtocol.ts"; export interface AntigravityAcpRuntimeInput extends Omit< @@ -42,7 +43,7 @@ export interface AntigravityAcpRuntimeInput extends Omit< readonly onAuthorizationUrl?: (url: string) => Effect.Effect; /** Serve `fs/read_text_file` for runtimes whose native reader is not qualified. */ readonly clientFileReads?: boolean; - /** The installed version, known before `initialize`; it picks the file reader. */ + /** The installed version, known before `initialize`; it picks the file reader and client name. */ readonly runtimeVersion?: string | null; /** Conversational sessions only; helpers and probes leave the system prompt untouched. */ readonly sessionSystemPromptAppend?: string; @@ -83,6 +84,9 @@ export function isQualifiedAntigravityRelease(version: string | null | undefined return version?.trim() === "1.4.5"; } +// `agy acp` 1.5.1 offers every model to every ACP client; older runtimes allowlist editors. +const OPEN_MODEL_ACCESS_RELEASE = [1, 5, 1] as const; + /** * 1.4.5 appends `systemPrompt.append` to its system instructions on new, load and resume, and * keeps none of it between processes, so every activation sends it again. @@ -106,6 +110,14 @@ export function antigravitySessionMeta( return Object.keys(antigravity).length === 0 ? undefined : { antigravity }; } +/** Older releases offer non-Gemini models only to allowlisted clients such as Zed. */ +export function antigravityClientInfo(version: string | null | undefined) { + const parsed = parseAntigravityVersion(version); + return parsed !== undefined && compareVersions(parsed, OPEN_MODEL_ACCESS_RELEASE) >= 0 + ? { name: "t3-code", title: "T3 Code", version: packageJson.version } + : { name: "zed", version: "0.0.0" }; +} + export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")(function* ( input: AntigravityAcpRuntimeInput, ): Effect.fn.Return< @@ -120,8 +132,7 @@ export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")( authenticateEagerly: true, resumeMethod: "resume", cancelBehavior: "wait-for-prompt", - // The agent offers non-Gemini models only to allowlisted clients such as Zed. - clientInfo: { name: "zed", version: "0.0.0" }, + clientInfo: antigravityClientInfo(input.runtimeVersion), sessionMeta: (initializeResult) => antigravitySessionMeta(initializeResult, input.sessionSystemPromptAppend), clientCapabilities: { diff --git a/packages/provider-acp/src/server/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts index ecbe61e342746..514ac91164153 100644 --- a/packages/provider-acp/src/server/AcpSessionRuntime.ts +++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts @@ -118,6 +118,7 @@ export interface AcpSessionRuntimeOptions { readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; readonly clientInfo: { readonly name: string; + readonly title?: string; readonly version: string; }; readonly authMethodId?: string; From 8344de78eb5e1ee1c32a685bea62cf89afcf1752 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 6 Nov 2026 03:01:01 -0800 Subject: [PATCH 07/51] ci(release): JS bundle jobs stop building an unused Linux helper (#31941) Release and macOS preview bundle jobs can check out a ref from before build:desktop:bundle existed. Those refs now install libsecret and run build:desktop as before. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/desktop-macos-preview.yml | 17 ++++++++++++--- .github/workflows/release.yml | 24 +++++++++++++++------ apps/desktop/vite.config.ts | 6 ++++++ package.json | 1 + 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml index 6dcdd702c2da1..0213f3fce6a99 100644 --- a/.github/workflows/desktop-macos-preview.yml +++ b/.github/workflows/desktop-macos-preview.yml @@ -73,16 +73,27 @@ jobs: # Public T3 Connect identifiers (Clerk publishable key, relay URL). cp .env.example .env - - uses: ./.github/actions/setup-apt-mirrors + # PR heads from before build:desktop:bundle use the full build, which + # compiles the Linux browser secret helper and needs libsecret. + - name: Check bundle-only build support + id: bundle_build + run: | + script="$(node -p "require('./package.json').scripts['build:desktop:bundle'] ? 'build:desktop:bundle' : 'build:desktop'")" + echo "script=$script" >> "$GITHUB_OUTPUT" + + - if: steps.bundle_build.outputs.script == 'build:desktop' + uses: ./.github/actions/setup-apt-mirrors - name: Install browser secret helper build libraries + if: steps.bundle_build.outputs.script == 'build:desktop' run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - # desktop-macos-preview-publish.yml packages this bundle as the preview channel. + # desktop-macos-preview-publish.yml packages this bundle as the preview + # channel. The Linux browser secret helper is not part of a macOS app. - name: Build JS bundle env: T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && 'preview' || 'dev' }} - run: vp run build:desktop + run: vp run ${{ steps.bundle_build.outputs.script }} # Same layout as release.yml's js-bundle so release-desktop.yml can # package it unchanged. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82f39f8034cc4..9c3c594972327 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -565,22 +565,32 @@ jobs: - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - uses: ./.github/actions/setup-apt-mirrors + # Older release refs lack build:desktop:bundle and use the full build, + # which compiles the Linux browser secret helper and needs libsecret. + - name: Check bundle-only build support + id: bundle_build + run: | + script="$(node -p "require('./package.json').scripts['build:desktop:bundle'] ? 'build:desktop:bundle' : 'build:desktop'")" + echo "script=$script" >> "$GITHUB_OUTPUT" + + - if: steps.bundle_build.outputs.script == 'build:desktop' + uses: ./.github/actions/setup-apt-mirrors - # @t3tools/desktop#build compiles the Linux browser secret helper on a - # Linux host before packing, and that needs libsecret headers. - name: Install browser secret helper build libraries + if: steps.bundle_build.outputs.script == 'build:desktop' run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - # Runs t3#build (which depends on @t3tools/web#build) and - # @t3tools/desktop#build, so apps/server/dist holds the server bundle - # plus the web client and apps/desktop/dist-electron the Electron main. + # Runs t3#build (which depends on @t3tools/web#build) and the desktop + # bundle, so apps/server/dist holds the server bundle plus the web client + # and apps/desktop/dist-electron the Electron main. build:desktop:bundle + # skips the Linux browser secret helper, which this artifact does not + # carry; each Linux package job compiles its own per arch. # The release channel is already stable, nightly, or preview. Builds in # other repositories report "dev" telemetry. - name: Build JS bundle env: T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && needs.preflight.outputs.release_channel || 'dev' }} - run: vp run build:desktop + run: vp run ${{ steps.bundle_build.outputs.script }} # Two paths under apps/ so the artifact root is apps/; consumers download # into `apps` to restore both at their original locations. diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index d06efd4d9d7bb..ba37576815097 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -25,6 +25,12 @@ export default defineConfig({ dependsOn: ["t3#build"], cache: false, }, + // CI bundle jobs skip the Linux browser secret helper; packaging stages one per arch. + "build:bundle": { + command: "node scripts/build-preview-annotation-css.mjs && vp pack", + dependsOn: ["t3#build"], + cache: false, + }, dev: { command: "node scripts/build-browser-secret.mjs && node scripts/build-preview-annotation-css.mjs && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", diff --git a/package.json b/package.json index f6731683bbc37..0664806c3ad61 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "build": "vp run --filter './apps/*' build", "build:marketing": "vp run --filter @t3tools/marketing build", "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build", + "build:desktop:bundle": "vp run --filter @t3tools/desktop build:bundle", "build:resource-monitor": "cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml", "typecheck": "vp run -r --cache --concurrency-limit 4 typecheck", "tc": "vp run -r --cache --concurrency-limit 4 typecheck", From 59ec1bac9fc2624171854cfdb9c4140b0166a115 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 6 Nov 2026 03:02:39 -0800 Subject: [PATCH 08/51] feat(marketing): help search engines tell t3.codes apart from copycat sites (#31142) Give crawlers a consistent download URL, a sitemap, and links to the official repository and app listings. Publish the monitored security contact in the site and repository policies. Takeover by Claude Opus 5.5 in Claude Code. Follow-up by GPT-6.1 Terra [1m] in Codex. Co-authored-by: Victor Valentine Romo <213858052+b2bvic@users.noreply.github.com> --- .github/SECURITY.md | 2 +- apps/marketing/astro.config.mjs | 2 + apps/marketing/public/robots.txt | 4 + apps/marketing/src/layouts/Layout.astro | 11 ++- apps/marketing/src/lib/seo.ts | 79 +++++++++++++++++++ .../src/pages/.well-known/security.txt.ts | 19 +++++ apps/marketing/src/pages/404.astro | 44 +++++++++++ apps/marketing/src/pages/download.astro | 13 ++- apps/marketing/src/pages/index.astro | 6 +- apps/marketing/src/pages/llms.txt.ts | 31 ++++++++ .../marketing/src/pages/security-policy.astro | 8 +- apps/marketing/src/pages/sitemap.xml.ts | 16 ++++ apps/marketing/vercel.ts | 2 + 13 files changed, 229 insertions(+), 8 deletions(-) create mode 100644 apps/marketing/public/robots.txt create mode 100644 apps/marketing/src/lib/seo.ts create mode 100644 apps/marketing/src/pages/.well-known/security.txt.ts create mode 100644 apps/marketing/src/pages/404.astro create mode 100644 apps/marketing/src/pages/llms.txt.ts create mode 100644 apps/marketing/src/pages/sitemap.xml.ts diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 69dc53dc4393f..56e3f03525e01 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,7 +1,7 @@ # Security policy Report security vulnerabilities affecting T3 Code or T3 Tools-operated infrastructure to -[security@ping.gg](mailto:security@ping.gg). Please do not disclose them publicly until we have had +[security@t3.codes](mailto:security@t3.codes). Please do not disclose them publicly until we have had a reasonable opportunity to investigate and remediate them. See the [full security policy](https://t3.codes/security-policy) for reporting details, scope, diff --git a/apps/marketing/astro.config.mjs b/apps/marketing/astro.config.mjs index 15a8a8f916333..338b79c0b8f5c 100644 --- a/apps/marketing/astro.config.mjs +++ b/apps/marketing/astro.config.mjs @@ -8,6 +8,8 @@ delete process.env.VP_RUN_NODE_CLIENT_PATH; export default defineConfig({ site: "https://t3.codes", + // Vercel 308s `/path/` to `/path` (see vercel.ts), so every internal link and canonical uses one form. + trailingSlash: "never", server: { port: Number(process.env.PORT ?? 4173), }, diff --git a/apps/marketing/public/robots.txt b/apps/marketing/public/robots.txt new file mode 100644 index 0000000000000..495a72e25e4b3 --- /dev/null +++ b/apps/marketing/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://t3.codes/sitemap.xml diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index d1aea12f09bc9..c21c60b14527f 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -9,18 +9,25 @@ import { IOS_APP_STORE_URL, MARKETING_STATS, } from "../lib/site"; +import { type JsonLd, jsonLdGraph, organizationJsonLd, websiteJsonLd } from "../lib/seo"; interface Props { title?: string; description?: string; pageClass?: string; + /** Page-specific schema.org nodes. Organization and WebSite are always included. */ + jsonLd?: ReadonlyArray; + noindex?: boolean; } const { title = "T3 Code", description = "T3 Code. The open-source control plane for coding agents.", pageClass, + jsonLd = [], + noindex = false, } = Astro.props; +const structuredData = jsonLdGraph([organizationJsonLd(), websiteJsonLd(), ...jsonLd]); // Link unfurlers require a 1200x630 JPG/PNG at an absolute URL. const socialImage = await getImage({ @@ -41,7 +48,7 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site); - + {noindex ? : } @@ -61,7 +68,9 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site); + {title} +