Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 86 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,90 @@ jobs:
fail_on_unmatched_files: true
token: ${{ steps.app_token.outputs.token }}

deploy_web:
name: Deploy hosted web app
needs: [preflight, release]
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 10
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
T3CODE_WEB_ROUTER_URL: ${{ vars.T3CODE_WEB_ROUTER_URL }}
T3CODE_WEB_LATEST_DOMAIN: ${{ vars.T3CODE_WEB_LATEST_DOMAIN }}
T3CODE_WEB_NIGHTLY_DOMAIN: ${{ vars.T3CODE_WEB_NIGHTLY_DOMAIN }}
VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.ref }}

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json

- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json

- name: Install release tooling dependencies
run: bun install --frozen-lockfile --filter=@t3tools/scripts --filter=@t3tools/web

- name: Align package versions to release version
run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"

- name: Refresh release lockfile
run: bun install --lockfile-only --ignore-scripts

- name: Deploy and alias channel
shell: bash
run: |
set -euo pipefail

if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then
echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2
exit 1
fi

router_url="${T3CODE_WEB_ROUTER_URL:-https://app.t3.codes}"
latest_domain="${T3CODE_WEB_LATEST_DOMAIN:-latest.app.t3.codes}"
nightly_domain="${T3CODE_WEB_NIGHTLY_DOMAIN:-nightly.app.t3.codes}"

if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then
channel_domain="$latest_domain"
channel_name="latest"
else
channel_domain="$nightly_domain"
channel_name="nightly"
fi

vercel_scope_args=()
if [[ -n "${VERCEL_TEAM_SLUG:-}" ]]; then
vercel_scope_args=(--scope "$VERCEL_TEAM_SLUG")
fi

echo "Deploying hosted web app for $channel_name channel."
deployment_url="$(
bunx vercel@53.1.1 deploy apps/web \
--prod \
--skip-domain \
--yes \
--token "$VERCEL_TOKEN" \
"${vercel_scope_args[@]}" \
--build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \
--build-env "VITE_HOSTED_APP_URL=$router_url" \
--build-env "VITE_HOSTED_APP_CHANNEL=$channel_name"
)"

echo "Aliasing $deployment_url to $channel_domain."
bunx vercel@53.1.1 alias set "$deployment_url" "$channel_domain" \
--token "$VERCEL_TOKEN" \
"${vercel_scope_args[@]}"

finalize:
name: Finalize release
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }}
Expand Down Expand Up @@ -651,8 +735,9 @@ jobs:
always() && !cancelled() &&
needs.preflight.result == 'success' &&
needs.release.result == 'success' &&
needs.deploy_web.result == 'success' &&
(needs.finalize.result == 'success' || needs.finalize.result == 'skipped')
needs: [preflight, release, finalize]
needs: [preflight, release, deploy_web, finalize]
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 10
steps:
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"@types/babel__core": "^7.20.5",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vercel/config": "^0.3.0",
"@vitejs/plugin-react": "^6.0.0",
"@vitest/browser-playwright": "^4.0.18",
"babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/branding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,22 @@ describe("branding", () => {
expect(branding.APP_STAGE_LABEL).toBe("Nightly");
expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)");
});

it("normalizes hosted app channel metadata", async () => {
vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "nightly");

const branding = await import("./branding");

expect(branding.HOSTED_APP_CHANNEL).toBe("nightly");
expect(branding.HOSTED_APP_CHANNEL_LABEL).toBe("Nightly");
});

it("ignores unknown hosted app channels", async () => {
vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "preview");

const branding = await import("./branding");

expect(branding.HOSTED_APP_CHANNEL).toBeNull();
expect(branding.HOSTED_APP_CHANNEL_LABEL).toBeNull();
});
});
5 changes: 5 additions & 0 deletions apps/web/src/branding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ function readInjectedDesktopAppBranding(): DesktopAppBranding | null {
}

const injectedDesktopAppBranding = readInjectedDesktopAppBranding();
const hostedAppChannel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLowerCase();

export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code";
export const APP_STAGE_LABEL =
injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "Alpha");
export const APP_DISPLAY_NAME =
injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`;
export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0";
export const HOSTED_APP_CHANNEL =
hostedAppChannel === "latest" || hostedAppChannel === "nightly" ? hostedAppChannel : null;
export const HOSTED_APP_CHANNEL_LABEL =
HOSTED_APP_CHANNEL === "nightly" ? "Nightly" : HOSTED_APP_CHANNEL === "latest" ? "Latest" : null;
Comment thread
cursor[bot] marked this conversation as resolved.
92 changes: 62 additions & 30 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime";
import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
import { createModelSelection } from "@t3tools/shared/model";
import * as Equal from "effect/Equal";
import { APP_VERSION } from "../../branding";
import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding";
import {
canCheckForUpdate,
getDesktopUpdateButtonTooltip,
Expand All @@ -26,6 +26,7 @@ import {
import { ProviderModelPicker } from "../chat/ProviderModelPicker";
import { TraitsPicker } from "../chat/TraitsPicker";
import { isElectron } from "../../env";
import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing";
import { useTheme } from "../../hooks/useTheme";
import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
import { useThreadActions } from "../../hooks/useThreadActions";
Expand Down Expand Up @@ -162,6 +163,7 @@ function AboutVersionSection() {
const updateState = updateStateQuery.data ?? null;
const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge);
const selectedUpdateChannel = updateState?.channel ?? "latest";
const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL;

const handleUpdateChannelChange = useCallback(
(channel: DesktopUpdateChannel) => {
Expand Down Expand Up @@ -314,36 +316,66 @@ function AboutVersionSection() {
</Tooltip>
}
/>
<SettingsRow
title="Update track"
description="Stable follows full releases. Nightly follows the nightly desktop channel and can switch back to stable immediately."
control={
<Select
value={selectedUpdateChannel}
onValueChange={(value) => {
handleUpdateChannelChange(value as DesktopUpdateChannel);
}}
>
<SelectTrigger
className="w-full sm:w-40"
aria-label="Update track"
disabled={!hasDesktopBridge || isChangingUpdateChannel}
{hasDesktopBridge ? (
<SettingsRow
title="Update track"
description="Stable follows full releases. Nightly follows the nightly desktop channel and can switch back to stable immediately."
control={
<Select
value={selectedUpdateChannel}
onValueChange={(value) => {
handleUpdateChannelChange(value as DesktopUpdateChannel);
}}
>
<SelectValue>
{selectedUpdateChannel === "nightly" ? "Nightly" : "Stable"}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem hideIndicator value="latest">
Stable
</SelectItem>
<SelectItem hideIndicator value="nightly">
Nightly
</SelectItem>
</SelectPopup>
</Select>
}
/>
<SelectTrigger
className="w-full sm:w-40"
aria-label="Update track"
disabled={isChangingUpdateChannel}
>
<SelectValue>
{selectedUpdateChannel === "nightly" ? "Nightly" : "Stable"}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem hideIndicator value="latest">
Stable
</SelectItem>
<SelectItem hideIndicator value="nightly">
Nightly
</SelectItem>
</SelectPopup>
</Select>
}
/>
) : selectedHostedAppChannel ? (
<SettingsRow
title="Update track"
description="Switches the hosted app release channel."
control={
<Select
value={selectedHostedAppChannel}
onValueChange={(value) => {
if (value === selectedHostedAppChannel) return;
window.location.assign(
buildHostedChannelSelectionUrl({ channel: value as HostedAppChannel }),
);
}}
>
<SelectTrigger className="w-full sm:w-40" aria-label="Update track">
<SelectValue>{HOSTED_APP_CHANNEL_LABEL}</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem hideIndicator value="latest">
Latest
</SelectItem>
<SelectItem hideIndicator value="nightly">
Nightly
</SelectItem>
</SelectPopup>
</Select>
}
/>
) : null}
</>
);
}
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/hostedPairing.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import {
buildHostedChannelSelectionUrl,
buildHostedPairingUrl,
hasHostedPairingRequest,
isHostedStaticApp,
Expand Down Expand Up @@ -42,6 +43,21 @@ describe("hostedPairing", () => {
expect(url.hash).toBe("#token=pairing-token");
});

it("builds hosted channel selection URLs through the configured router origin", () => {
vi.stubEnv("VITE_HOSTED_APP_URL", "https://app.t3.codes");

const url = new URL(
buildHostedChannelSelectionUrl({
channel: "nightly",
}),
);

expect(url.origin).toBe("https://app.t3.codes");
expect(url.pathname).toBe("/__t3code/channel");
expect(url.searchParams.get("channel")).toBe("nightly");
expect(url.searchParams.has("next")).toBe(false);
});

it("ignores incomplete hosted pairing requests", () => {
expect(
hasHostedPairingRequest(new URL("https://app.t3.codes/pair?host=backend.example.com")),
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/hostedPairing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface HostedPairingRequest {
readonly label: string;
}

export type HostedAppChannel = "latest" | "nightly";

function configuredHostedAppUrl(): string {
return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL;
}
Expand Down Expand Up @@ -68,3 +70,11 @@ export function buildHostedPairingUrl(input: {

return setPairingTokenOnUrl(url, input.token).toString();
}

export function buildHostedChannelSelectionUrl(input: {
readonly channel: HostedAppChannel;
}): string {
const url = new URL("/__t3code/channel", configuredHostedAppUrl());
url.searchParams.set("channel", input.channel);
return url.toString();
}
1 change: 1 addition & 0 deletions apps/web/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ interface ImportMetaEnv {
readonly VITE_HTTP_URL: string;
readonly VITE_WS_URL: string;
readonly VITE_HOSTED_APP_URL: string;
readonly VITE_HOSTED_APP_CHANNEL: string;
readonly APP_VERSION: string;
}

Expand Down
2 changes: 1 addition & 1 deletion apps/web/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,5 @@
}
]
},
"include": ["src", "vite.config.ts", "test"]
"include": ["src", "vite.config.ts", "vercel.ts", "test"]
}
10 changes: 0 additions & 10 deletions apps/web/vercel.json

This file was deleted.

Loading
Loading