diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 2e61de6039e..efeaa803ef5 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -5,6 +5,23 @@ name: Mobile EAS Production # in the same OS/pnpm as the EAS build; a macOS `eas build` computes a different # fingerprint (platform-specific deps + pnpm version) and errors. On this Linux # runner, with corepack pinning pnpm 10.24 in eas.json, local == build. +# +# Every merge to main that touches the mobile app reconciles, per platform: +# 1. Store builds: if the latest production build's version differs from +# app.config.ts, cut a new build with --auto-submit (TestFlight + +# Play internal track). Bumping `version` is therefore all it takes to +# start the next release train — the first build of a version enters +# external-TestFlight beta review immediately, and later builds of the +# same version auto-approve. Releasing to the App Store stays a manual +# App Store Connect step. +# 2. OTA: publish a production-channel update for each platform where at +# least one finished production build matches the current native +# fingerprint. Old-version binaries with a matching fingerprint receive +# it too. When native drift means no binary could install the update, +# it is skipped and flagged in the job summary instead of published +# into the void. +# workflow_dispatch remains as a manual override for both modes (e.g. to +# retry an errored build or force an OTA). on: workflow_dispatch: inputs: @@ -29,10 +46,30 @@ on: description: "OTA update message (mode=update only)" required: false type: string + push: + branches: [main] + paths: + - apps/mobile/** + - packages/client-runtime/** + - packages/contracts/** + - packages/shared/** + - assets/** + - scripts/** + - patches/** + - pnpm-lock.yaml + - pnpm-workspace.yaml + - .github/workflows/mobile-eas-production.yml + +# Serialize runs so OTAs publish in merge order. GitHub keeps at most one +# queued run per group, so a burst of merges collapses into one run of the +# newest commit — intermediate commits don't need their own OTA. +concurrency: + group: mobile-eas-production + cancel-in-progress: false jobs: production: - name: EAS Production ${{ inputs.mode }} + name: EAS Production ${{ github.event_name == 'push' && 'auto' || inputs.mode }} runs-on: blacksmith-8vcpu-ubuntu-2404 permissions: contents: read @@ -98,15 +135,15 @@ jobs: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} run: eas env:pull production --non-interactive - - name: Build and submit - if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'build' + - name: Build and submit (manual) + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait - - name: Publish OTA update - if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'update' + - name: Publish OTA update (manual) + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update' working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} @@ -117,3 +154,64 @@ jobs: --platform ${{ inputs.platform }} \ --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \ --non-interactive + + # No --status filter on build:list: an in-queue/in-progress build must + # count as existing, or every merge during the build window would cut a + # duplicate. Builds started here stay attached to this serialized run so + # the queued run for a later merge cannot overtake them and lose its OTA. + # After an errored build, retry via workflow_dispatch mode=build — pushes + # won't re-trigger it until the app version changes. + - id: store_builds + name: Ensure store builds exist for the current app version + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' + continue-on-error: true + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + failed=0 + version="$(npx expo config --json --type public | jq -r '.version')" + for platform in ios android; do + latest="$(eas build:list --platform "$platform" --build-profile production --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')" + if [ "$latest" = "$version" ]; then + echo "$platform: production build for $version already exists (or is in progress)" + continue + fi + echo "$platform: latest production build is $latest, app.config.ts says $version — building" + if eas build --platform "$platform" --profile production --auto-submit --non-interactive; then + echo ":building_construction: $platform: cut production build for $version (auto-submitted)" >> "$GITHUB_STEP_SUMMARY" + else + failed=1 + echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY" + fi + done + exit "$failed" + + - name: Publish fingerprint-gated OTA + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))" + for platform in ios android; do + # eas-cli prints an environment-loaded notice to stdout before the + # JSON even with --json, so discard everything before the document. + hash="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" + matching="$(eas build:list --platform "$platform" --build-profile production --status finished --fingerprint-hash "$hash" --limit 1 --json --non-interactive | jq 'length')" + if [ "$matching" -gt 0 ]; then + eas update \ + --channel production \ + --environment production \ + --platform "$platform" \ + --message "$message" \ + --non-interactive + echo ":white_check_mark: $platform: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" + else + echo ":warning: $platform: no finished production build matches fingerprint \`$hash\` — OTA skipped; JS changes reach $platform only once a matching build ships" >> "$GITHUB_STEP_SUMMARY" + fi + done + + - name: Propagate store build failure + if: steps.store_builds.outcome == 'failure' + run: exit 1 diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml new file mode 100644 index 00000000000..fd98817cd10 --- /dev/null +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -0,0 +1,205 @@ +name: Mobile Fingerprint Check + +# Detects whether a PR changes the native fingerprint — i.e. whether merging +# it would leave main un-OTA-able until a new store build ships. Native-change +# PRs get the "📱 Native Change" label so they can be held and merged as a +# batch right before the next store submission, keeping main OTA-able for +# everything else in between. (Once one native PR merges, every later merge +# inherits the drifted fingerprint and loses OTA reach too — that is why the +# signal has to fire before merge, not after.) +# +# The check is advisory: it always passes, the label is the signal. Both +# fingerprints are computed in this one job (same OS, same corepack-pinned +# pnpm), so the comparison is self-consistent; no EXPO_TOKEN needed. +on: + pull_request: + paths: + - apps/mobile/** + - packages/client-runtime/** + - packages/contracts/** + - packages/shared/** + - assets/** + - scripts/** + - patches/** + - pnpm-lock.yaml + - pnpm-workspace.yaml + - .github/workflows/mobile-fingerprint-check.yml + +concurrency: + group: mobile-fingerprint-check-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + fingerprint: + name: Native fingerprint diff + runs-on: blacksmith-8vcpu-ubuntu-2404 + permissions: + contents: read + issues: write + pull-requests: write + env: + APP_VARIANT: production + NODE_OPTIONS: --max-old-space-size=8192 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # Default pull_request checkout is the merge commit (PR applied on + # top of base), so the "head" fingerprint is the state main would + # actually be in after merging — stale branches compare cleanly. + fetch-depth: 0 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/mobile... + + - name: Expose pnpm + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Fingerprint merge result + working-directory: apps/mobile + run: | + mkdir -p "$RUNNER_TEMP/fp/head" "$RUNNER_TEMP/fp/base" + for platform in ios android; do + npx expo-updates fingerprint:generate --platform "$platform" > "$RUNNER_TEMP/fp/head/$platform.json" + done + + - name: Fingerprint base + run: | + git checkout --quiet "${{ github.event.pull_request.base.sha }}" + # Re-sync node_modules to the base commit's lockfile before + # fingerprinting — a dep-changing PR must not fingerprint the base + # against head's installed packages. + pnpm install --filter=@t3tools/mobile... + cd apps/mobile + for platform in ios android; do + npx expo-updates fingerprint:generate --platform "$platform" > "$RUNNER_TEMP/fp/base/$platform.json" + done + + - id: compare + name: Compare fingerprints + run: | + changed="" + { + echo "## Native fingerprint diff" + echo + for platform in ios android; do + head_hash="$(jq -r .hash "$RUNNER_TEMP/fp/head/$platform.json")" + base_hash="$(jq -r .hash "$RUNNER_TEMP/fp/base/$platform.json")" + if [ "$head_hash" = "$base_hash" ]; then + echo "- ✅ **$platform**: unchanged (\`$head_hash\`) — OTA-compatible" + continue + fi + changed="$changed $platform" + echo "- 📱 **$platform**: \`$base_hash\` → \`$head_hash\` — merging requires a new native build before OTAs work again" + jq -r -n \ + --slurpfile h "$RUNNER_TEMP/fp/head/$platform.json" \ + --slurpfile b "$RUNNER_TEMP/fp/base/$platform.json" ' + ($b[0].sources | map({ (.filePath // .id): .hash }) | add // {}) as $bm + | $h[0].sources[] + | select($bm[(.filePath // .id)] != .hash) + | " - \(.type): `\(.filePath // .id)`"' + done + } >> "$GITHUB_STEP_SUMMARY" + echo "changed_platforms=${changed# }" >> "$GITHUB_OUTPUT" + + - name: Sync native change label + # Fork PRs get a read-only token under pull_request; the check stays + # advisory there (summary only). This workflow must not move to + # pull_request_target — it installs and runs PR code. + if: github.event.pull_request.head.repo.full_name == github.repository + uses: actions/github-script@v8 + env: + CHANGED_PLATFORMS: ${{ steps.compare.outputs.changed_platforms }} + with: + script: | + const managedLabel = { + name: "📱 Native Change", + color: "d93f0b", + description: + "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.", + }; + const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== ""; + const issueNumber = context.payload.pull_request.number; + + try { + const { data: existing } = await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + }); + + if ( + existing.color !== managedLabel.color || + (existing.description ?? "") !== managedLabel.description + ) { + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + color: managedLabel.color, + description: managedLabel.description, + }); + } + } catch (error) { + if (error.status !== 404) { + throw error; + } + + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + color: managedLabel.color, + description: managedLabel.description, + }); + } catch (createError) { + if (createError.status !== 422) { + throw createError; + } + } + } + + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + const hasLabel = currentLabels.some((label) => label.name === managedLabel.name); + + if (nativeChanged && !hasLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [managedLabel.name], + }); + } else if (!nativeChanged && hasLabel) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: managedLabel.name, + }); + } catch (removeError) { + if (removeError.status !== 404) { + throw removeError; + } + } + } + + core.info( + `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, + ); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8596af69964..fc9d632019d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.32", + "version": "0.0.33", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 388b3fd2c15..c41bb34bb43 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -28,70 +28,6 @@ describe("ElectronDialog", () => { showErrorBoxMock.mockReset(); }); - it.effect("returns false without opening a confirm dialog for empty messages", () => - Effect.gen(function* () { - const dialog = yield* ElectronDialog.ElectronDialog; - - const result = yield* dialog.confirm({ - message: " ", - owner: Option.none(), - }); - - assert.isFalse(result); - assert.equal(showMessageBoxMock.mock.calls.length, 0); - }).pipe(Effect.provide(ElectronDialog.layer)), - ); - - it.effect("opens a confirm dialog for the owner window", () => - Effect.gen(function* () { - const owner = { id: 1 } as BrowserWindow; - showMessageBoxMock.mockResolvedValue({ response: 1 }); - const dialog = yield* ElectronDialog.ElectronDialog; - - const result = yield* dialog.confirm({ - message: "Delete worktree?", - owner: Option.some(owner), - }); - - assert.isTrue(result); - assert.deepEqual(showMessageBoxMock.mock.calls[0], [ - owner, - { - type: "question", - buttons: ["No", "Yes"], - defaultId: 0, - cancelId: 0, - noLink: true, - message: "Delete worktree?", - }, - ]); - }).pipe(Effect.provide(ElectronDialog.layer)), - ); - - it.effect("opens an app-level confirm dialog when there is no owner window", () => - Effect.gen(function* () { - showMessageBoxMock.mockResolvedValue({ response: 0 }); - const dialog = yield* ElectronDialog.ElectronDialog; - - const result = yield* dialog.confirm({ - message: "Delete worktree?", - owner: Option.none(), - }); - - assert.isFalse(result); - assert.deepEqual(showMessageBoxMock.mock.calls[0], [ - { - type: "question", - buttons: ["No", "Yes"], - defaultId: 0, - cancelId: 0, - noLink: true, - message: "Delete worktree?", - }, - ]); - }).pipe(Effect.provide(ElectronDialog.layer)), - ); - it.effect("preserves folder picker request context and cause", () => Effect.gen(function* () { const cause = new Error("folder picker failed"); @@ -117,31 +53,6 @@ describe("ElectronDialog", () => { }).pipe(Effect.provide(ElectronDialog.layer)), ); - it.effect("preserves confirmation request context and cause", () => - Effect.gen(function* () { - const cause = new Error("confirmation failed"); - const owner = { id: 9 } as BrowserWindow; - showMessageBoxMock.mockRejectedValue(cause); - const dialog = yield* ElectronDialog.ElectronDialog; - - const error = yield* Effect.flip( - dialog.confirm({ - owner: Option.some(owner), - message: " Confirm removal? ", - }), - ); - - assert.instanceOf(error, ElectronDialog.ElectronDialogConfirmError); - assert.strictEqual(error.ownerWindowId, 9); - assert.strictEqual(error.promptLength, "Confirm removal?".length); - assert.notProperty(error, "promptMessage"); - assert.strictEqual(error.cause, cause); - assert.include(error.message, "window 9"); - assert.notInclude(error.message, "Confirm removal?"); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), - ); - it.effect("preserves message box request context and cause", () => Effect.gen(function* () { const cause = new Error("message box failed"); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index f1add4c7cc7..c33a24befcf 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -6,8 +6,6 @@ import * as Schema from "effect/Schema"; import * as Electron from "electron"; -const CONFIRM_BUTTON_INDEX = 1; - export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass()( "ElectronDialogPickFolderError", { @@ -38,20 +36,6 @@ export class ElectronDialogPickFilesError extends Schema.TaggedErrorClass()( - "ElectronDialogConfirmError", - { - ownerWindowId: Schema.NullOr(Schema.Number), - promptLength: Schema.Number, - cause: Schema.Defect(), - }, -) { - override get message(): string { - const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`; - return `Failed to open an Electron confirmation dialog for ${owner} with a ${this.promptLength}-character prompt.`; - } -} - export class ElectronDialogShowMessageBoxError extends Schema.TaggedErrorClass()( "ElectronDialogShowMessageBoxError", { @@ -85,7 +69,6 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass; - readonly message: string; -} - export class ElectronDialog extends Context.Service< ElectronDialog, { @@ -117,9 +95,6 @@ export class ElectronDialog extends Context.Service< readonly pickFiles: ( input: ElectronDialogPickFilesInput, ) => Effect.Effect; - readonly confirm: ( - input: ElectronDialogConfirmInput, - ) => Effect.Effect; readonly showMessageBox: ( options: Electron.MessageBoxOptions, ) => Effect.Effect; @@ -188,39 +163,6 @@ export const make = ElectronDialog.of({ }); return result.canceled ? [] : result.filePaths; }), - confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) { - const normalizedMessage = input.message.trim(); - if (normalizedMessage.length === 0) { - return false; - } - - const options = { - type: "question" as const, - buttons: ["No", "Yes"], - defaultId: 0, - cancelId: 0, - noLink: true, - message: normalizedMessage, - }; - const ownerWindowId = Option.match(input.owner, { - onNone: () => null, - onSome: (owner) => owner.id, - }); - const result = yield* Effect.tryPromise({ - try: () => - Option.match(input.owner, { - onNone: () => Electron.dialog.showMessageBox(options), - onSome: (owner) => Electron.dialog.showMessageBox(owner, options), - }), - catch: (cause) => - new ElectronDialogConfirmError({ - ownerWindowId, - promptLength: normalizedMessage.length, - cause, - }), - }); - return result.response === CONFIRM_BUTTON_INDEX; - }), showMessageBox: (options) => Effect.tryPromise({ try: () => Electron.dialog.showMessageBox(options), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 503a586d9c5..cb35ad19ac7 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -31,7 +31,6 @@ import { setUpdateChannel, } from "./methods/updates.ts"; import { - confirm, getAppBranding, getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, @@ -81,7 +80,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(pickFolder); yield* ipc.handle(pickThemeFiles); - yield* ipc.handle(confirm); yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 4d8e783d122..4a1213e4ec6 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,6 +1,5 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; -export const CONFIRM_CHANNEL = "desktop:confirm"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index cfa854e7a16..7a39eb42927 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -220,19 +220,6 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ }), }); -export const confirm = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.CONFIRM_CHANNEL, - payload: Schema.String, - result: Schema.Boolean, - handler: Effect.fn("desktop.ipc.window.confirm")(function* (message) { - const dialog = yield* ElectronDialog.ElectronDialog; - const electronWindow = yield* ElectronWindow.ElectronWindow; - return yield* electronWindow.focusedMainOrFirst.pipe( - Effect.flatMap((owner) => dialog.confirm({ owner, message })), - ); - }), -}); - export const setTheme = DesktopIpc.makeIpcMethod({ channel: IpcChannels.SET_THEME_CHANNEL, payload: DesktopThemeSchema, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7e8859359b3..2aa345ee584 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -98,7 +98,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined), - confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index d4db6a229f0..d346bab96d9 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -231,6 +231,7 @@ describe("DesktopShellEnvironment", () => { "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", "C:\\Custom\\Bin", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index d9782c358b0..f693b2da79b 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -207,7 +207,7 @@ const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray => [ ...trimNonEmpty(env.USERPROFILE).pipe( Option.match({ onNone: () => [], - onSome: (value) => [`${value}\\.bun\\bin`, `${value}\\scoop\\shims`], + onSome: (value) => [`${value}\\.local\\bin`, `${value}\\.bun\\bin`, `${value}\\scoop\\shims`], }), ), ]; diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index c12f220ab1f..f1bc14e178b 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -53,7 +53,6 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { pickFolder: () => Effect.succeed(Option.none()), pickFiles: () => Effect.succeed([]), - confirm: () => Effect.succeed(false), showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), showErrorBox: () => Effect.void, } satisfies ElectronDialog.ElectronDialog["Service"]); diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx index 31e95cf9dbb..587abcc06f5 100644 --- a/apps/mobile/src/components/ControlPill.tsx +++ b/apps/mobile/src/components/ControlPill.tsx @@ -133,9 +133,25 @@ export function ControlPillMenu( } const { className: _className, ...menuProps } = props; + let children = menuProps.children; + // In long-press mode the wrapped pressable still receives the touch (the + // patched MenuView button is touch-transparent) and RN's Fabric touch + // handler is never cancelled by the in-tree UIContextMenuInteraction, so a + // bare onPress would fire on finger-up even after the menu opened — and + // also on a long press released just under the menu threshold. A dispatched + // onLongPress makes Pressability swallow the release, so holds past 350ms + // (below the ~500ms context-menu threshold) can only open the menu, never + // tap through. + if (props.shouldOpenOnLongPress && isValidElement(children)) { + const child = children as ReactElement<{ onLongPress?: () => void; delayLongPress?: number }>; + children = cloneElement(child, { + onLongPress: child.props.onLongPress ?? (() => undefined), + delayLongPress: child.props.delayLongPress ?? 350, + }); + } return ( - {menuProps.children} + {children} ); } diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index db7fecf64ff..7933e4ca601 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -49,6 +49,7 @@ import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reani import { useThemeColor } from "../../lib/useThemeColor"; import { useFontFamily } from "../../lib/useFontFamily"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, @@ -876,6 +877,12 @@ function renderFeedEntry( const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); const attachments = message.attachments ?? []; const hasReviewCommentContext = message.text.includes(" {message.text.trim().length > 0 ? ( diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 886644bf83e..cd8e8cad212 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -554,6 +554,8 @@ function toolDetailTextLooksLikeFailure(text: string): boolean { normalized.includes("command not found") || (normalized.includes("cannot find path") && normalized.includes("because it does not exist")) || (normalized.includes("is not recognized") && normalized.includes("the term '")) || + normalized.includes("is not recognized as the name of a cmdlet") || + normalized.includes("a parameter cannot be found that matches parameter name") || //i.test(text) || /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) || /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text) diff --git a/apps/mobile/src/lib/wideMarkdownBlocks.test.ts b/apps/mobile/src/lib/wideMarkdownBlocks.test.ts new file mode 100644 index 00000000000..9f0bcaee325 --- /dev/null +++ b/apps/mobile/src/lib/wideMarkdownBlocks.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { hasWideMarkdownBlock } from "./wideMarkdownBlocks"; + +describe("hasWideMarkdownBlock", () => { + it("ignores prose, inline code, and emphasis", () => { + expect(hasWideMarkdownBlock("just a message")).toBe(false); + expect(hasWideMarkdownBlock("I found it in `secteurs_intervention` earlier")).toBe(false); + expect(hasWideMarkdownBlock("a | b in a sentence")).toBe(false); + expect(hasWideMarkdownBlock("an em dash — and a rule\n\n---\n")).toBe(false); + }); + + it("detects fenced code blocks", () => { + expect(hasWideMarkdownBlock("before\n```\ncode\n```\nafter")).toBe(true); + expect(hasWideMarkdownBlock("before\n```ts\ncode\n```")).toBe(true); + expect(hasWideMarkdownBlock("before\n~~~\ncode\n~~~")).toBe(true); + expect(hasWideMarkdownBlock(" ```\ncode\n```")).toBe(true); + }); + + it("detects GFM tables", () => { + expect(hasWideMarkdownBlock("| a | b |\n| --- | --- |\n| 1 | 2 |")).toBe(true); + expect(hasWideMarkdownBlock("a | b\n:-- | --:\n1 | 2")).toBe(true); + }); +}); diff --git a/apps/mobile/src/lib/wideMarkdownBlocks.ts b/apps/mobile/src/lib/wideMarkdownBlocks.ts new file mode 100644 index 00000000000..801d826df54 --- /dev/null +++ b/apps/mobile/src/lib/wideMarkdownBlocks.ts @@ -0,0 +1,38 @@ +/** + * Detects markdown that the JS renderer draws as a standalone block View + * wrapping a horizontal ScrollView — fenced code blocks and GFM tables. + * + * Those blocks report an intrinsic width equal to their widest line, which is + * effectively unbounded. A user bubble sizes itself from its content + * (`maxWidth` with no `width`), so Android lays the bubble's children out + * during the unclamped intrinsic pass — where the surrounding paragraphs + * collapse to a single line — and never repositions them once the width is + * clamped back to `maxWidth`. The result is siblings drawn on top of each + * other inside an over-tall bubble. Pinning the bubble's width removes the + * intrinsic pass entirely, which is the same reason review-comment bubbles + * already carry an explicit width. + * + * Indented (four-space) code blocks are deliberately not detected: they are + * vanishingly rare in chat input and the check would fire on ordinary nested + * list continuations. + */ + +const FENCED_CODE_BLOCK = /^ {0,3}(?:```|~~~)/m; + +function isTableDelimiterRow(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed.includes("|") || !trimmed.includes("-")) { + return false; + } + return /^[|\-: \t]+$/.test(trimmed); +} + +export function hasWideMarkdownBlock(text: string): boolean { + if (FENCED_CODE_BLOCK.test(text)) { + return true; + } + if (!text.includes("|")) { + return false; + } + return text.split("\n").some(isTableDelimiterRow); +} diff --git a/apps/server/package.json b/apps/server/package.json index 08c611cbf08..627474b20be 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.32", + "version": "0.0.33", "license": "MIT", "repository": { "type": "git", diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 4af2ecb6457..ec4d2aae16e 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,7 +1,7 @@ import { expect, it } from "@effect/vitest"; import { describe } from "vite-plus/test"; -import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; +import { assetResponseHeaders, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; describe("http dev routing", () => { it("treats localhost and loopback addresses as local", () => { @@ -26,3 +26,22 @@ describe("http dev routing", () => { ); }); }); + +describe("assetResponseHeaders", () => { + it("sandboxes SVG assets", () => { + expect(assetResponseHeaders("/attachments/user-image.svg")).toMatchObject({ + "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox", + "X-Content-Type-Options": "nosniff", + }); + expect(assetResponseHeaders("/attachments/user-image.SVG")).toHaveProperty( + "Content-Security-Policy", + ); + }); + + it("does not apply document policy to raster images", () => { + expect(assetResponseHeaders("/attachments/user-image.png")).toEqual({ + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", + }); + }); +}); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 06f186fffd8..2fc56c4a4b2 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -43,6 +43,18 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["marcode://app", "marcode-dev://app"]; +const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; + +export function assetResponseHeaders(filePath: string): Record { + return { + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", + ...(filePath.toLowerCase().endsWith(".svg") + ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } + : {}), + }; +} + export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), { global: true, }); @@ -207,10 +219,7 @@ export const assetRouteLayer = HttpRouter.add( } return yield* HttpServerResponse.file(asset.path, { status: 200, - headers: { - "Cache-Control": "private, max-age=3600", - "X-Content-Type-Options": "nosniff", - }, + headers: assetResponseHeaders(asset.path), }).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), ); diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 3e67b6e0498..698099930da 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -160,6 +160,109 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { }), ); + it.effect("resolves icon hrefs from object-literal route metadata", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "src/routes/__root.tsx", + `export const Route = createRootRoute({ + head: () => ({ + links: [ + { rel: "stylesheet", href: "/app.css" }, + { rel: "icon", href: "/brand/logo.svg" }, + ], + }), +});`, + ); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); + + it.effect("resolves object-literal icon metadata when href precedes rel", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "src/root.tsx", + `const links = [{ href: "/brand/logo.svg", rel: "shortcut icon" }];`, + ); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); + + it.effect("resolves object-literal icon metadata alongside nested objects", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "src/root.tsx", + `const links = [{ attributes: {}, rel: "icon", href: "/brand/logo.svg" }];`, + ); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); + + it.effect("skips icon metadata without an href and keeps scanning", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "src/root.tsx", + `const links = [{ rel: "icon" }, { rel: "icon", href: "/brand/logo.svg" }];`, + ); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); + + // A large icon source with no icon metadata used to pin the server's event loop for + // minutes: the object pattern was unanchored, so it restarted at every offset and + // rescanned forward from each one. Anchoring keeps this proportional to file size. + it.effect("scans large icon sources without an icon in reasonable time", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + // Mirrors a generated single-file build: large, brace-sparse, and no icon metadata. + const filler = `

${"pokopia companion guide ".repeat(24)}

\n`; + yield* writeTextFile( + cwd, + "index.html", + `guide\n${filler.repeat(1200)}`, + ); + + const startedAt = performance.now(); + const resolved = yield* resolver.resolvePath(cwd); + const elapsedMs = performance.now() - startedAt; + + expect(resolved).toBeNull(); + expect(elapsedMs).toBeLessThan(5_000); + }), + ); + it.effect("returns null when no icon is present", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 0b447bd4f3e..458954daed4 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -55,10 +55,13 @@ const ICON_SOURCE_FILES = [ ] as const; // Matches tags or object-like icon metadata where rel/href can appear in any order. +// The tag pattern is anchored on `]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; -const LINK_ICON_OBJ_RE = - /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; +const ICON_REL_RE = /\brel\s*:\s*["'](?:icon|shortcut icon)["']/i; +const ICON_HREF_RE = /\bhref\s*:\s*["']([^"'?]+)/i; export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass()( "ProjectFaviconResolutionError", @@ -99,8 +102,13 @@ export class ProjectFaviconResolver extends Context.Service< function extractIconHref(source: string): string | null { const htmlMatch = source.match(LINK_ICON_HTML_RE); if (htmlMatch?.[1]) return htmlMatch[1]; - const objMatch = source.match(LINK_ICON_OBJ_RE); - if (objMatch?.[1]) return objMatch[1]; + // Icon metadata counts when `rel` and `href` share a brace-free run, so a run holding `rel` + // but no href falls through to the next one rather than ending the search. + for (const run of source.split("}")) { + if (!ICON_REL_RE.test(run)) continue; + const hrefMatch = run.match(ICON_HREF_RE); + if (hrefMatch?.[1]) return hrefMatch[1]; + } return null; } diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 0dafa7a6daf..cc15ee9cee6 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -18,7 +18,9 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import type { UsageRecord } from "./usageTranscripts.ts"; -export const USAGE_SCAN_CACHE_VERSION = 1 as const; +// v2: Codex fork-copy suppression changed what a file parses to, so v1 +// entries would keep serving double-counted records forever. +export const USAGE_SCAN_CACHE_VERSION = 2 as const; export interface CachedFile { readonly size: number; diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index 1fec9d28d9b..8f86a3d836b 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -134,6 +134,106 @@ describe("parseCodexLine", () => { parseCodexLine(turnContext, state); expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull(); }); + + // A forked/subagent rollout opens with the parent's history copied in and + // every line re-stamped to the fork instant, then the ancestors' session + // metas. Counting those again multiplied usage ~1.85x on real data (#5758). + describe("forked rollouts", () => { + const meta = (overrides: { + id: string; + timestamp: string; + forkedFromId?: string; + spawnParentId?: string; + }) => + JSON.stringify({ + type: "session_meta", + timestamp: overrides.timestamp, + payload: { + type: "session_meta", + id: overrides.id, + ...(overrides.forkedFromId === undefined + ? {} + : { forked_from_id: overrides.forkedFromId }), + ...(overrides.spawnParentId === undefined + ? {} + : { + source: { + subagent: { thread_spawn: { parent_thread_id: overrides.spawnParentId } }, + }, + }), + }, + }); + const stamped = (timestamp: string, line: string) => { + const parsed = JSON.parse(line) as { timestamp: string }; + parsed.timestamp = timestamp; + return JSON.stringify(parsed); + }; + + it("keeps the child session id over copied ancestor metas", () => { + const state = initialCodexScanState(); + parseCodexLine(meta({ id: "child", timestamp: "2026-08-01T05:00:00.000Z" }), state); + parseCodexLine(meta({ id: "parent", timestamp: "2026-08-01T05:00:00.000Z" }), state); + parseCodexLine(turnContext, state); + const record = parseCodexLine(tokenCount(100, 0, 10, 0), state); + + expect(record?.sessionId).toBe("child"); + }); + + it("drops the re-stamped copied burst and keeps the first real event", () => { + const state = initialCodexScanState(); + const forkInstant = "2026-08-01T05:00:00.000Z"; + parseCodexLine(meta({ id: "child", timestamp: forkInstant, forkedFromId: "parent" }), state); + parseCodexLine(meta({ id: "parent", timestamp: forkInstant }), state); + parseCodexLine(stamped(forkInstant, turnContext), state); + + // Copied history: written in one burst at the fork instant. + expect( + parseCodexLine(stamped("2026-08-01T05:00:00.001Z", tokenCount(100, 0, 10, 0)), state), + ).toBeNull(); + expect( + parseCodexLine(stamped("2026-08-01T05:00:00.002Z", tokenCount(200, 0, 20, 0)), state), + ).toBeNull(); + + // The child's first genuine turn lands seconds later and must count. + const real = parseCodexLine( + stamped("2026-08-01T05:00:06.000Z", tokenCount(300, 0, 30, 0)), + state, + ); + expect(real).not.toBeNull(); + expect(real?.totals.outputTokens).toBe(30); + + // Suppression never restarts, even for closely spaced later events. + const next = parseCodexLine( + stamped("2026-08-01T05:00:06.100Z", tokenCount(400, 0, 40, 0)), + state, + ); + expect(next).not.toBeNull(); + }); + + it("recognizes subagent spawns without forked_from_id", () => { + const state = initialCodexScanState(); + const spawnInstant = "2026-08-01T05:00:00.000Z"; + parseCodexLine( + meta({ id: "child", timestamp: spawnInstant, spawnParentId: "parent" }), + state, + ); + parseCodexLine(stamped(spawnInstant, turnContext), state); + expect( + parseCodexLine(stamped("2026-08-01T05:00:00.001Z", tokenCount(100, 0, 10, 0)), state), + ).toBeNull(); + }); + + it("does not suppress anything in a rollout that is not a fork", () => { + const state = initialCodexScanState(); + parseCodexLine(meta({ id: "root", timestamp: "2026-08-01T05:00:00.000Z" }), state); + parseCodexLine(stamped("2026-08-01T05:00:00.100Z", turnContext), state); + const record = parseCodexLine( + stamped("2026-08-01T05:00:00.200Z", tokenCount(100, 0, 10, 0)), + state, + ); + expect(record).not.toBeNull(); + }); + }); }); describe("totalTokens", () => { diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 338713d8b1b..49f9a1935cc 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -151,10 +151,42 @@ export interface CodexScanState { model: string; sessionId: string; lastUsageSignature: string | null; + sawSessionMeta: boolean; + /** While true, leading usage events are re-stamped copies of parent history. */ + suppressingForkCopies: boolean; + forkCopyAnchorMs: number; } export function initialCodexScanState(): CodexScanState { - return { model: "", sessionId: "", lastUsageSignature: null }; + return { + model: "", + sessionId: "", + lastUsageSignature: null, + sawSessionMeta: false, + suppressingForkCopies: false, + forkCopyAnchorMs: 0, + }; +} + +/** + * A forked or subagent rollout opens with the parent's full history copied in, + * every line re-stamped to the fork instant. Those copies are written in one + * synchronous burst (observed gaps 0-40ms), while the child's first genuine + * usage event only lands after a real model turn (observed 5s+). One second of + * separation splits the two cleanly; `ccusage` uses the same threshold. + */ +const FORK_COPY_MAX_GAP_MS = 1000; + +/** Whether a `session_meta` payload marks the rollout as a fork or subagent. */ +function isForkedSessionMeta(payload: Record): boolean { + if (typeof payload["forked_from_id"] === "string") return true; + const source = payload["source"]; + if (typeof source !== "object" || source === null) return false; + const subagent = (source as Record)["subagent"]; + if (typeof subagent !== "object" || subagent === null) return false; + const spawn = (subagent as Record)["thread_spawn"]; + if (typeof spawn !== "object" || spawn === null) return false; + return typeof (spawn as Record)["parent_thread_id"] === "string"; } /** @@ -181,8 +213,18 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord const payloadType = payloadRecord["type"]; if (record["type"] === "session_meta") { + // Only the first meta describes this file's own session. A forked rollout + // repeats the ancestors' metas right after it; letting those through would + // reassign every subsequent record to an ancestor session. + if (state.sawSessionMeta) return null; + state.sawSessionMeta = true; const id = payloadRecord["id"] ?? payloadRecord["session_id"]; if (typeof id === "string") state.sessionId = id; + const metaTimestampMs = parseTimestampMs(record["timestamp"]); + if (metaTimestampMs !== null && isForkedSessionMeta(payloadRecord)) { + state.suppressingForkCopies = true; + state.forkCopyAnchorMs = metaTimestampMs; + } return null; } @@ -213,6 +255,17 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord if (signature === state.lastUsageSignature) return null; state.lastUsageSignature = signature; + // In a forked rollout the copied parent history was already counted from the + // parent's own file. Drop the leading burst; the first usage event separated + // from its predecessor by a real turn's worth of time ends it for good. + if (state.suppressingForkCopies) { + if (timestampMs - state.forkCopyAnchorMs < FORK_COPY_MAX_GAP_MS) { + state.forkCopyAnchorMs = timestampMs; + return null; + } + state.suppressingForkCopies = false; + } + const inputTokens = int(lastRecord["input_tokens"]); const cachedInputTokens = int(lastRecord["cached_input_tokens"]); const cacheCreationTokens = int(lastRecord["cache_write_input_tokens"]); @@ -238,7 +291,8 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord totals, // Codex does not report cost in the rollout. reportedCostUsd: null, - // Rollout files are unique per session, so events need no global dedup. + // Events surviving the fork-copy suppression above are unique to this + // rollout, so they need no global dedup. dedupeKey: null, }; } diff --git a/apps/web/package.json b/apps/web/package.json index 4cbc9dcf2ce..f0c8393a624 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.32", + "version": "0.0.33", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 1011b6ff867..ff8c6e157dd 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -18,6 +18,10 @@ import { useLegacySidebarEnabled } from "../hooks/useSettings"; import { TOGGLE_SIDEBAR_EVENT } from "./FloatingPillNav"; import LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; +// Marcode does not mount upstream's in-sidebar settings chrome (SettingsSidebarNav, +// SidebarChromeHeader, useSidebarStageBackdropVariant): FloatingPillNav owns brand, settings and +// sidebar controls, so those imports stay dropped. See the `!isOnSettings` branch below. +import { useProjects } from "../state/entities"; import { resolveInitialThreadSidebarWidth, resolveThreadSidebarMaximumWidth, @@ -86,6 +90,14 @@ function SidebarControl() { return null; } +// Settings swaps the thread sidebar out of the tree. Keep the lightweight +// project projection subscribed so returning to a draft never renders the +// zero-project state while the environment snapshot reconnects. +function ProjectProjectionRetention() { + useProjects(); + return null; +} + export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); // Marcode: upstream's `legacySidebarEnabled` is now the single sidebar @@ -154,6 +166,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { return ( + {/* Marcode renders no sidebar on settings routes: FloatingPillNav owns brand, settings and sidebar controls, so upstream's in-sidebar SettingsSidebarNav would duplicate them. */} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ec7d732b91f..4a211587a8d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4814,6 +4814,7 @@ function ChatViewContent(props: ChatViewProps) { "This will discard newer messages and turn diffs in this thread.", "This action cannot be undone.", ].join("\n"), + { variant: "destructive" }, ); if (!confirmed) { return; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index fba51efc788..628dde521ef 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1513,16 +1513,32 @@ function OpenCommandPaletteDialog(props: { }, }); - actionItems.push({ - kind: "action", - value: "action:project-settings", - searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"], - title: "Project settings", - icon: , - run: async () => { - await navigate({ to: "/settings/projects" }); - }, - }); + // There is no projects listing page; the action targets the contextual + // project (active thread/draft, falling back to the first sidebar group). + const contextualProjectGroup = + (contextualProjectRef + ? projectGroupByTargetKey.get( + `${contextualProjectRef.environmentId}:${contextualProjectRef.projectId}`, + ) + : null) ?? + projectGroups[0] ?? + null; + if (contextualProjectGroup) { + actionItems.push({ + kind: "action", + value: "action:project-settings", + searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"], + title: "Project settings", + description: contextualProjectGroup.displayName, + icon: , + run: async () => { + await navigate({ + to: "/projects/$projectKey", + params: { projectKey: contextualProjectGroup.projectKey }, + }); + }, + }); + } const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); const sourceSelectionViewValue = diff --git a/apps/web/src/components/ConfirmDialogHost.tsx b/apps/web/src/components/ConfirmDialogHost.tsx new file mode 100644 index 00000000000..c169a1eff7f --- /dev/null +++ b/apps/web/src/components/ConfirmDialogHost.tsx @@ -0,0 +1,96 @@ +import { useEffect, useSyncExternalStore } from "react"; + +import { + completeConfirmDialogClose, + readConfirmDialogState, + registerConfirmDialogHost, + respondToConfirmDialog, + subscribeConfirmDialog, +} from "../confirmDialog"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; +import { Button } from "./ui/button"; + +type ConfirmationCopy = { + readonly title: string; + readonly description: string | null; +}; + +export function resolveConfirmDialogCopy(message: string): ConfirmationCopy { + const normalizedMessage = message.trim(); + const lines = normalizedMessage.split("\n"); + const questionLineIndex = lines.findIndex((line) => line.trim().endsWith("?")); + + if (questionLineIndex >= 0) { + const title = lines[questionLineIndex]!.trim(); + const description = lines + .filter((_, index) => index !== questionLineIndex) + .join("\n") + .trim(); + return { title, description: description || null }; + } + + const questionMarkIndex = normalizedMessage.indexOf("?"); + if (questionMarkIndex >= 0) { + return { + title: normalizedMessage.slice(0, questionMarkIndex + 1).trim(), + description: normalizedMessage.slice(questionMarkIndex + 1).trim() || null, + }; + } + + return { + title: "Confirm action", + description: normalizedMessage || "This action requires your confirmation.", + }; +} + +export function ConfirmDialogHost() { + const state = useSyncExternalStore( + subscribeConfirmDialog, + readConfirmDialogState, + readConfirmDialogState, + ); + + useEffect(() => registerConfirmDialogHost(), []); + + const copy = resolveConfirmDialogCopy(state.status === "idle" ? "" : state.message); + const confirmVariant = state.status === "idle" ? "default" : state.variant; + const onCancel = () => respondToConfirmDialog(false); + const onConfirm = () => respondToConfirmDialog(true); + + return ( + { + if (!open) onCancel(); + }} + onOpenChangeComplete={(open) => { + if (!open) completeConfirmDialogClose(); + }} + > + + + {copy.title} + {copy.description ? ( + + {copy.description} + + ) : null} + + + }>Cancel + + + + + ); +} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 54fbc12df94..f17508e25a6 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -104,7 +104,7 @@ import { } from "../keybindings"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { useShortcutModifierState } from "../shortcutModifierState"; -import { readLocalApi } from "../localApi"; +import { ensureLocalApi, readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useDesktopUpdateState } from "../state/desktopUpdate"; @@ -1499,6 +1499,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec : []), "This removes only this project entry.", ].join("\n"), + { variant: "destructive" }, ); if (!confirmed) { return; @@ -1547,7 +1548,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), "This removes only this project entry.", ].join("\n"); - const confirmed = await api.dialogs.confirm(message); + const confirmed = await api.dialogs.confirm(message, { variant: "destructive" }); if (!confirmed) { return; } @@ -1830,6 +1831,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec `Delete ${count} thread${count === 1 ? "" : "s"}?`, "This permanently clears conversation history for these threads.", ].join("\n"), + { variant: "destructive" }, ); if (!confirmed) return; } @@ -2180,6 +2182,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec `Delete thread "${thread.title}"?`, "This permanently clears conversation history for this thread.", ].join("\n"), + { variant: "destructive" }, ); if (!confirmed) { return; @@ -2730,6 +2733,7 @@ interface SidebarProjectsContentProps { arm64IntelBuildWarningDescription: string | null; desktopUpdateButtonAction: "download" | "install" | "none"; desktopUpdateButtonDisabled: boolean; + desktopUpdateActionPending: boolean; handleDesktopUpdateButtonClick: () => void; projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; @@ -2770,6 +2774,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( arm64IntelBuildWarningDescription, desktopUpdateButtonAction, desktopUpdateButtonDisabled, + desktopUpdateActionPending, handleDesktopUpdateButtonClick, projectSortOrder, threadSortOrder, @@ -2862,7 +2867,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( ); diff --git a/apps/web/src/components/WorkspaceBreadcrumb.tsx b/apps/web/src/components/WorkspaceBreadcrumb.tsx new file mode 100644 index 00000000000..3c67e4d3269 --- /dev/null +++ b/apps/web/src/components/WorkspaceBreadcrumb.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from "react"; + +import { cn } from "../lib/utils"; + +interface WorkspaceBreadcrumbProps { + readonly ariaLabel: string; + readonly children: ReactNode; + readonly className?: string; +} + +export function WorkspaceBreadcrumb({ ariaLabel, children, className }: WorkspaceBreadcrumbProps) { + return ( + + ); +} + +interface WorkspaceBreadcrumbItemProps { + readonly children: ReactNode; + readonly className?: string; + readonly current?: boolean; +} + +export function WorkspaceBreadcrumbItem({ + children, + className, + current = false, +}: WorkspaceBreadcrumbItemProps) { + return ( +
  • + {children} +
  • + ); +} + +export function WorkspaceBreadcrumbSeparator() { + return ( + + ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2294033897b..e079879da9e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3131,7 +3131,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) instanceEntries={providerInstanceEntries} keybindings={keybindings} modelOptionsByInstance={modelOptionsByInstance} - triggerClassName="-ms-px ps-0" + triggerClassName="-ms-px" terminalOpen={terminalOpen} open={isComposerModelPickerOpen} {...(composerProviderState.modelPickerIconClassName diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index d84f5e1a26d..62233c22fbc 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -130,7 +130,10 @@ export function DraftHeroHeadline({ {activeProjectDisplayName ?? "Choose a project"} diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 6593acd8e1f..8462757700e 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -323,6 +323,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ key={option.id} value={option.id} hideIndicator + // Base UI keeps radio menus open by default. Close on pick so + // the traits menu behaves like the model picker. + closeOnClick disabled={ultrathinkInBodyText && descriptor.id === primarySelectDescriptor?.id} > @@ -362,7 +365,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ }} > {(["on", "off"] as const).map((value) => ( - + {value === "on" ? "On" : "Off"} diff --git a/apps/web/src/components/clerk/authRedirect.test.ts b/apps/web/src/components/clerk/authRedirect.test.ts index 4c4cd72d932..140474120cc 100644 --- a/apps/web/src/components/clerk/authRedirect.test.ts +++ b/apps/web/src/components/clerk/authRedirect.test.ts @@ -8,11 +8,22 @@ describe("resolveClerkSignInProps", () => { expect(resolveClerkSignInProps(href, false)).toEqual({ forceRedirectUrl: href }); }); - it("omits the redirect override on packaged desktop", () => { - expect(resolveClerkSignInProps("t3code://app/#/settings/general", true)).toEqual({}); + it("removes a Clerk virtual pathname and callback params while preserving the desktop route", () => { + expect( + resolveClerkSignInProps( + "t3code://app/CLERK-ROUTER/VIRTUAL/sign-up?__clerk_status=complete#/settings/connections", + true, + ), + ).toEqual({ + forceRedirectUrl: "t3code://app/#/settings/connections", + signUpForceRedirectUrl: "t3code://app/#/settings/connections", + }); }); - it("omits the redirect override on development desktop", () => { - expect(resolveClerkSignInProps("t3code-dev://app/#/settings/general", true)).toEqual({}); + it("preserves a clean development desktop route", () => { + expect(resolveClerkSignInProps("t3code-dev://app/#/settings/general", true)).toEqual({ + forceRedirectUrl: "t3code-dev://app/#/settings/general", + signUpForceRedirectUrl: "t3code-dev://app/#/settings/general", + }); }); }); diff --git a/apps/web/src/components/clerk/authRedirect.ts b/apps/web/src/components/clerk/authRedirect.ts index 13331a27798..251c5ee3650 100644 --- a/apps/web/src/components/clerk/authRedirect.ts +++ b/apps/web/src/components/clerk/authRedirect.ts @@ -1,12 +1,19 @@ export interface ClerkSignInProps { forceRedirectUrl?: string; + signUpForceRedirectUrl?: string; } -// Clerk's native-app allowlist only authorizes the bare renderer root -// (t3code://app/), which @clerk/electron's OAuth transport already supplies, -// so any page-derived redirect override gets the whole sign-in request -// rejected. On Electron, omit the override and let Clerk use its defaults. export function resolveClerkSignInProps(href: string, isElectron: boolean): ClerkSignInProps { - if (isElectron) return {}; + if (isElectron) { + // Electron routes through the hash, so reset any Clerk virtual pathname without losing the T3 page. + const redirectUrl = new URL(href); + redirectUrl.pathname = "/"; + redirectUrl.search = ""; + + return { + forceRedirectUrl: redirectUrl.toString(), + signUpForceRedirectUrl: redirectUrl.toString(), + }; + } return { forceRedirectUrl: href }; } diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index bc79bf0df99..3b454f98dcb 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -12,7 +12,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; import type { ServerProcessDiagnosticsEntry, ServerProcessResourceHistorySummary, @@ -22,6 +22,7 @@ import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import { cn } from "../../lib/utils"; +import { ensureLocalApi } from "../../localApi"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; import { useEnvironmentQuery } from "../../state/query"; @@ -857,6 +858,11 @@ export function DiagnosticsSettingsPanel() { const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false); const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState(null); const [signalingPid, setSignalingPid] = useState(null); + const signalingPidRef = useRef(null); + const environmentIdRef = useRef(environmentId); + const processDataRef = useRef(processData); + environmentIdRef.current = environmentId; + processDataRef.current = processData; const openLogsDirectory = useCallback(() => { const logsDirectoryPath = observability?.logsDirectoryPath ?? null; @@ -895,28 +901,51 @@ export function DiagnosticsSettingsPanel() { const isInitialLoading = isPending && data === null; const isProcessInitialLoading = isProcessPending && processData === null; const signalProcess = useCallback( - (pid: number, signal: ServerProcessSignal) => { - if ( - signal === "SIGKILL" && - !window.confirm(`Send SIGKILL to process ${pid}? This cannot be handled by the process.`) - ) { - return; + async (pid: number, signal: ServerProcessSignal) => { + if (signalingPidRef.current !== null) return; + signalingPidRef.current = pid; + setSignalingPid(pid); + const clearSignaling = () => { + signalingPidRef.current = null; + setSignalingPid(null); + }; + if (signal === "SIGKILL") { + let confirmed = false; + try { + confirmed = await ensureLocalApi().dialogs.confirm( + `Send SIGKILL to process ${pid}? This cannot be handled by the process.`, + { variant: "destructive" }, + ); + } catch (error) { + clearSignaling(); + toastManager.add({ + type: "error", + title: "Could not confirm signal", + description: error instanceof Error ? error.message : `Failed to send ${signal}.`, + }); + return; + } + if (!confirmed) { + clearSignaling(); + return; + } } - if (environmentId === null) { + const currentEnvironmentId = environmentIdRef.current; + if (currentEnvironmentId === null) { + clearSignaling(); return; } - const process = processData?.processes.find((entry) => entry.pid === pid); + const process = processDataRef.current?.processes.find((entry) => entry.pid === pid); if (process === undefined) { + clearSignaling(); return; } - setSignalingPid(pid); - void (async () => { + try { const result = await signalServerProcess({ - environmentId, + environmentId: currentEnvironmentId, input: { pid, startTimeMs: process.startTimeMs, signal }, }); - setSignalingPid(null); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); @@ -949,9 +978,11 @@ export function DiagnosticsSettingsPanel() { return; } refreshProcesses(); - })(); + } finally { + clearSignaling(); + } }, - [environmentId, processData?.processes, refreshProcesses, signalServerProcess], + [refreshProcesses, signalServerProcess], ); const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null; diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 8c2c9f081be..50cf9c31804 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -13,6 +13,7 @@ import { selectProjectGroupingSettings, } from "../../logicalProject"; import type { + ContextMenuItem, ModelSelection, ProviderDriverKind, SidebarProjectGroupingMode, @@ -21,10 +22,18 @@ import type { } from "@t3tools/contracts"; import { resolveEnvModeLabel } from "../BranchToolbar.logic"; import { createModelSelection } from "@t3tools/shared/model"; -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { useCanGoBack, useNavigate } from "@tanstack/react-router"; import * as Cause from "effect/Cause"; -import { CopyIcon, FolderIcon, PlusIcon, ServerIcon, SettingsIcon, Trash2Icon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MouseEvent as ReactMouseEvent, +} from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; import { isElectron } from "../../env"; @@ -59,11 +68,7 @@ import { import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; import { useProjects, useThreadShells } from "../../state/entities"; import { projectEnvironment } from "../../state/projects"; -import { - primaryServerKeybindingsAtom, - primaryServerProvidersAtom, - serverEnvironment, -} from "../../state/server"; +import { primaryServerProvidersAtom, serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; @@ -76,10 +81,27 @@ import { type NewProjectScriptInput, type ProjectScriptEditorRequest, } from "../projectScriptEditor"; +import { cn } from "../../lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { + Menu, + MenuGroup, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuSeparator, + MenuTrigger, +} from "../ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { SidebarInset } from "../ui/sidebar"; import { stackedThreadToast, toastManager } from "../ui/toast"; +import { + WorkspaceBreadcrumb, + WorkspaceBreadcrumbItem, + WorkspaceBreadcrumbSeparator, +} from "../WorkspaceBreadcrumb"; import { SettingResetButton, SettingsPageContainer, @@ -123,34 +145,119 @@ function memberKey(member: { environmentId: string; id: string }): string { return `${member.environmentId}:${member.id}`; } -export function ProjectSettingsPanel({ - selectedProjectKey, -}: { - selectedProjectKey: string | null; -}) { - const groups = useSettingsProjectGroups(); +export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { const navigate = useNavigate(); - const currentHash = useLocation({ select: (location) => location.hash }); + const canGoBack = useCanGoBack(); + const navigateBackWithinApp = useCallback(() => { + if (canGoBack) { + window.history.back(); + return; + } + void navigate({ to: "/" }); + }, [canGoBack, navigate]); - // The index route auto-selects the first project so /settings/projects is - // never a dead end. Hash is preserved for settings-search jumps. useEffect(() => { - if (selectedProjectKey !== null) return; - const first = groups[0]; - if (!first) return; - void navigate({ - to: "/settings/projects/$projectKey", - params: { projectKey: first.projectKey }, - ...(currentHash ? { hash: currentHash } : {}), - replace: true, - hashScrollIntoView: false, + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + if (event.key !== "Escape") return; + event.preventDefault(); + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement) { + activeElement.blur(); + } + navigateBackWithinApp(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [navigateBackWithinApp]); + + return ( + +
    + {!isElectron && ( +
    + +
    + )} + {isElectron && ( +
    + +
    + )} + +
    +
    + ); +} + +function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) { + const groups = useSettingsProjectGroups(); + const navigate = useNavigate(); + const selected = groups.find((group) => group.projectKey === projectKey) ?? null; + const openProjectMenu = (event: ReactMouseEvent) => { + const api = readLocalApi(); + if (!api) return; + + const rect = event.currentTarget.getBoundingClientRect(); + const items: ContextMenuItem[] = groups.map((group) => ({ + id: group.projectKey, + label: group.displayName, + })); + void settlePromise(() => + api.contextMenu.show(items, { x: rect.left, y: rect.bottom + 4 }), + ).then((clicked) => { + if (clicked._tag === "Failure" || clicked.value === null) return; + void navigate({ + to: "/projects/$projectKey", + params: { projectKey: clicked.value }, + replace: true, + hashScrollIntoView: false, + }); }); - }, [currentHash, groups, navigate, selectedProjectKey]); + }; + + return ( + + Projects + + + {selected ? ( + + ) : ( + Unavailable project + )} + + + ); +} + +export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { + const groups = useSettingsProjectGroups(); + const navigate = useNavigate(); - const selected = - selectedProjectKey === null - ? null - : (groups.find((group) => group.projectKey === selectedProjectKey) ?? null); + const selected = groups.find((group) => group.projectKey === projectKey) ?? null; // Remember the members of the last rendered group so a grouping-rule change // (which changes the group key) can follow the project to its new group. @@ -163,112 +270,43 @@ export function ProjectSettingsPanel({ }; }, [selected]); - // Recover when the selected key stops matching (regroup, removal, or a - // stale deep link) instead of parking on a dead-end message. + // A grouping-rule change replaces the group key mid-visit; follow the + // project to its new key instead of parking on the not-found state. useEffect(() => { - if (selectedProjectKey === null || selected !== null || groups.length === 0) return; + if (selected !== null) return; const last = lastSelectionRef.current; - const successor = - last?.key === selectedProjectKey - ? (groups.find((group) => - group.memberProjects.some((member) => - last.memberKeys.includes(member.physicalProjectKey), - ), - ) ?? null) - : null; + if (last?.key !== projectKey) return; + const successor = groups.find((group) => + group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), + ); if (successor) { void navigate({ - to: "/settings/projects/$projectKey", + to: "/projects/$projectKey", params: { projectKey: successor.projectKey }, replace: true, hashScrollIntoView: false, }); - } else { - void navigate({ to: "/settings/projects", replace: true, hashScrollIntoView: false }); } - }, [groups, navigate, selected, selectedProjectKey]); + }, [groups, navigate, projectKey, selected]); - const selectProject = useCallback( - (projectKey: string) => { - void navigate({ - to: "/settings/projects/$projectKey", - params: { projectKey }, - replace: true, - hashScrollIntoView: false, - }); - }, - [navigate], - ); - - return ( -
    - - {selected ? ( - - ) : ( -
    - {groups.length === 0 ? "Add a project from the sidebar to configure it here." : null} -
    - )} -
    - ); + if (!selected) { + return ( +
    + {groups.length === 0 + ? "Add a project from the sidebar to configure it here." + : "This project is no longer available."} +
    + ); + } + return ; } -function ProjectDetail({ - group, - groups, - onSelectProject, -}: { - group: SidebarProjectSnapshot; - groups: ReadonlyArray; - onSelectProject: (projectKey: string) => void; -}) { +function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const navigate = useNavigate(); const settings = usePrimarySettings(); const updateClientSettings = useUpdateClientSettings(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const serverProviders = useAtomValue(primaryServerProvidersAtom); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); const threads = useThreadShells(); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); @@ -307,11 +345,6 @@ function ProjectDetail({ } return counts; }, [threads]); - const groupThreadCount = group.memberProjects.reduce( - (total, member) => total + (threadCountByMember.get(memberKey(member)) ?? 0), - 0, - ); - const reportFailure = useCallback((title: string, result: AtomCommandResult) => { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); @@ -324,15 +357,15 @@ function ProjectDetail({ ); }, []); - // Group-shared fields (default model, scripts) live on each physical - // project record, so a group-level edit fans out to every member. + // Group-shared fields live on each physical project record, so a + // group-level edit fans out to every member. const updateAllMembers = useCallback( async ( input: Partial<{ + title: string; defaultModelSelection: ModelSelection | null; defaultThreadEnvMode: ThreadEnvMode | null; faviconPath: string | null; - scripts: ReadonlyArray>; }>, failureTitle: string, ): Promise> => { @@ -361,6 +394,20 @@ function ProjectDetail({ [group.memberProjects, reportFailure, updateProject], ); + const renameGroup = useCallback( + async (nextTitle: string) => { + const title = nextTitle.trim(); + if (!title) { + toastManager.add({ type: "warning", title: "Project title cannot be empty" }); + return; + } + if (title === group.displayName) return; + if (group.memberProjects.every((member) => member.title === title)) return; + await updateAllMembers({ title }, "Failed to rename project"); + }, + [group.displayName, group.memberProjects, updateAllMembers], + ); + // ----- default model ----- const storedSelection = representative.defaultModelSelection; const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection); @@ -414,14 +461,25 @@ function ProjectDetail({ [updateAllMembers], ); - // ----- scripts ----- - const scripts = representative.scripts; + // ----- checkout selection and scripts ----- + const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(representative.physicalProjectKey); + const selectedCheckout = + group.memberProjects.find((member) => member.physicalProjectKey === selectedCheckoutKey) ?? + representative; + const selectedServerConfig = useAtomValue( + serverEnvironment.configValueAtom(selectedCheckout.environmentId), + ); + const keybindings = selectedServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; + const scripts = selectedCheckout.scripts; const [editorRequest, setEditorRequest] = useState(null); // Script writes replace the whole array, so two overlapping writes computed // from the same snapshot would drop each other's changes. One at a time. const [isSavingScripts, setIsSavingScripts] = useState(false); const savingScriptsRef = useRef(false); - const t3File = useT3ProjectFileState(representative.environmentId, representative.workspaceRoot); + const t3File = useT3ProjectFileState( + selectedCheckout.environmentId, + selectedCheckout.workspaceRoot, + ); // What the "Default" option resolves to while no override is set: the // repo's t3.json value when present, otherwise the global setting. const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? settings.defaultThreadEnvMode; @@ -456,20 +514,24 @@ function ProjectDetail({ // Captured before the write so a cleared or deleted binding can be // removed from the keybindings config afterwards. const previousKeybinding = keybindingValueForCommand(keybindings, keybindingCommand); - const updateResult = await updateAllMembers( - { scripts: nextScripts }, - "Failed to save scripts", + const updateResult = mapAtomCommandResult( + await updateProject({ + environmentId: selectedCheckout.environmentId, + input: { projectId: selectedCheckout.id, scripts: nextScripts }, + }), + () => undefined, ); - if (updateResult._tag === "Failure") return updateResult; + if (updateResult._tag === "Failure") { + reportFailure("Failed to save scripts", updateResult); + return updateResult; + } const keybindingRule = decodeProjectScriptKeybindingRule({ keybinding, command: keybindingCommand, }); if (!isElectron) return updateResult; - const environmentIds = [ - ...new Set(group.memberProjects.map((member) => member.environmentId)), - ]; + const environmentIds = [selectedCheckout.environmentId]; const previousTarget = previousKeybinding ? decodeProjectScriptKeybindingRule({ keybinding: previousKeybinding, @@ -512,11 +574,12 @@ function ProjectDetail({ } }, [ - group.memberProjects, keybindings, removeKeybinding, reportFailure, - updateAllMembers, + selectedCheckout.environmentId, + selectedCheckout.id, + updateProject, upsertKeybinding, ], ); @@ -589,26 +652,6 @@ function ProjectDetail({ ); // ----- checkouts ----- - const renameMember = useCallback( - async (member: SidebarProjectGroupMember, nextTitle: string) => { - const title = nextTitle.trim(); - if (!title) { - toastManager.add({ type: "warning", title: "Project title cannot be empty" }); - return; - } - if (title === member.title) return; - const result = mapAtomCommandResult( - await updateProject({ - environmentId: member.environmentId, - input: { projectId: member.id, title }, - }), - () => undefined, - ); - reportFailure("Failed to rename project", result); - }, - [reportFailure, updateProject], - ); - const updateGroupingPreference = useCallback( (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => { const overrideKey = deriveProjectGroupingOverrideKey(member); @@ -657,6 +700,7 @@ function ProjectDetail({ : "Other entries in this grouped project are unaffected.", "This action cannot be undone.", ].join("\n"), + { variant: "destructive" }, ), ); if (confirmed._tag === "Failure" || !confirmed.value) return; @@ -689,8 +733,10 @@ function ProjectDetail({ draftStore.clearProjectDraftThreadId(projectRef); } + // The project's settings page just deleted itself; there is no projects + // listing to fall back to, so leave settings entirely. if (isWholeGroup) { - void navigate({ to: "/settings/projects", replace: true }); + void navigate({ to: "/", replace: true }); } }, [ @@ -703,424 +749,420 @@ function ProjectDetail({ ], ); - const repositoryLine = - representative.repositoryIdentity?.displayName ?? - representative.repositoryIdentity?.canonicalKey ?? - "No git remote detected"; - const environmentCount = new Set(group.memberProjects.map((member) => member.environmentId)).size; + const selectedCheckoutThreadCount = threadCountByMember.get(memberKey(selectedCheckout)) ?? 0; + const selectedCheckoutGrouping = + projectGroupingSettings.sidebarProjectGroupingOverrides?.[ + deriveProjectGroupingOverrideKey(selectedCheckout) + ] ?? "inherit"; + const selectedCheckoutLabel = selectedCheckout.environmentLabel ?? "This machine"; return ( - -
    - -
    -

    - {group.displayName} -

    -

    - {repositoryLine} - {" · "} - {group.memberProjects.length === 1 - ? "1 checkout" - : `${group.memberProjects.length} checkouts`} - {environmentCount > 1 ? ` across ${environmentCount} environments` : ""} - {" · "} - {groupThreadCount === 1 ? "1 thread" : `${groupThreadCount} threads`} -

    -
    - -
    - - - void setFaviconPath(null)} - /> - ) : null - } - control={ -
    - - -
    - } - /> -
    - - - setDefaultModel(null)} + <> + + + { + void renameGroup(event.currentTarget.value); + }} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} /> - ) : null - } - control={ - resolvedSelection && activeEntry ? ( -
    - { - setDefaultModel(createModelSelection(instanceId, model)); - }} + } + /> + void setFaviconPath(null)} /> - {}} - modelOptions={resolvedSelection.options ?? []} - allowPromptInjectedEffort={false} - triggerVariant="outline" - triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" - onModelOptionsChange={(nextOptions) => { - setDefaultModel( - createModelSelection( - resolvedSelection.instanceId, - resolvedSelection.model, - nextOptions, - ), - ); - }} + ) : null + } + control={ +
    + +
    - ) : ( - No providers available - ) - } - /> - + } + /> + - - setDefaultThreadEnvMode(null)} - /> - ) : null - } - control={ + + setDefaultModel(null)} + /> + ) : null + } + control={ + resolvedSelection && activeEntry ? ( +
    + { + setDefaultModel(createModelSelection(instanceId, model)); + }} + /> + {}} + modelOptions={resolvedSelection.options ?? []} + allowPromptInjectedEffort={false} + triggerVariant="outline" + triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" + onModelOptionsChange={(nextOptions) => { + setDefaultModel( + createModelSelection( + resolvedSelection.instanceId, + resolvedSelection.model, + nextOptions, + ), + ); + }} + /> +
    + ) : ( + No providers available + ) + } + /> + setDefaultThreadEnvMode(null)} + /> + ) : null + } + control={ + + } + /> +
    + + { - if (value === "worktree" || value === "local") { - setDefaultThreadEnvMode(value); - } else if (value === "inherit") { - setDefaultThreadEnvMode(null); - } - }} + value={selectedCheckout.physicalProjectKey} + onValueChange={(value) => setSelectedCheckoutKey(String(value))} > - - - {storedEnvMode === null - ? `Default (${resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})` - : resolveEnvModeLabel(storedEnvMode)} - + + {selectedCheckoutLabel} - - Default ({inheritedEnvModeSource}:{" "} - {resolveEnvModeLabel(inheritedEnvMode).toLowerCase()}) - - {resolveEnvModeLabel("worktree")} - {resolveEnvModeLabel("local")} + {group.memberProjects.map((member) => ( + + {member.environmentLabel ?? "This machine"} · {member.workspaceRoot} + + ))} } - /> - - - - setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT }) + > +
    +
    + +
    + {selectedCheckoutThreadCount === 1 + ? "1 thread" + : `${selectedCheckoutThreadCount} threads`} +
    +
    +
    + { + if ( + value === "inherit" || + value === "repository" || + value === "repository_path" || + value === "separate" + ) { + updateGroupingPreference(selectedCheckout, value); + } + }} + > + + + {selectedCheckoutGrouping === "inherit" + ? `Default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})` + : PROJECT_GROUPING_MODE_LABELS[selectedCheckoutGrouping]} + + + + + Use global default + + + {PROJECT_GROUPING_MODE_LABELS.repository} + + + {PROJECT_GROUPING_MODE_LABELS.repository_path} + + + {PROJECT_GROUPING_MODE_LABELS.separate} + + + } - > - - Add action - - } - > - {scripts.length === 0 ? ( -

    - No scripts yet. Scripts run in a project terminal from the thread top bar; one script - can run automatically when a worktree is created. -

    - ) : ( -
    - {scripts.map((script) => { + /> + {group.memberProjects.length > 1 ? ( + void removeMembers([selectedCheckout])} + > + + Remove checkout + + } + /> + ) : null} +
    +
    +

    Actions

    +

    + Saved and run only in {selectedCheckoutLabel}. +

    +
    +
    + {importableScripts.length > 0 ? ( + + + } + > + Import scripts + + + + + Import from t3.json +

    + Add actions declared by this checkout without editing them first. +

    +
    + + {importableScripts.map((fileScript) => ( + void importFileScript(fileScript)} + > + +
    +
    {fileScript.name}
    +
    + {fileScript.command} +
    +
    +
    + ))} +
    +
    + ) : null} + +
    +
    + {scripts.length === 0 ? ( +

    + No actions configured for this checkout. +

    + ) : ( + scripts.map((script) => { const shortcutLabel = shortcutLabelForCommand( keybindings, commandForProjectScript(script.id), ); return ( -
    - - - {script.name} - - {script.runOnWorktreeCreate ? ( - - setup - - ) : null} - {script.previewUrl ? ( - - preview · desktop only + className="group py-2" + title={ + + + {script.name} + + {script.command} + + {script.runOnWorktreeCreate ? ( + + setup + + ) : null} + {script.previewUrl ? ( + + preview · desktop only + + ) : null} - ) : null} - - {script.command} - - {shortcutLabel ? ( - {shortcutLabel} - ) : null} - -
    + } + control={ + <> + {shortcutLabel ? ( + {shortcutLabel} + ) : null} + + + } + /> ); - })} -
    - )} - {t3File.status === "invalid" ? ( - - ) : null} - {importableScripts.length > 0 ? ( + }) + )} + {t3File.status === "invalid" ? ( + + ) : null} +
    + + 1 ? "Remove this project everywhere" : "Remove project" + } + description={ + group.memberProjects.length > 1 + ? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.` + : "Deletes the project entry and its threads. Files on disk are not touched." + } control={ -
    - {importableScripts.map((fileScript) => ( - - ))} -
    + } /> - ) : null} -
    - - -
    - {group.memberProjects.map((member) => { - const threadCount = threadCountByMember.get(memberKey(member)) ?? 0; - const groupingOverride = - projectGroupingSettings.sidebarProjectGroupingOverrides?.[ - deriveProjectGroupingOverrideKey(member) - ] ?? "inherit"; - return ( -
    -
    - - - {member.environmentLabel ?? "Current environment"} - - - {threadCount === 1 ? "1 thread" : `${threadCount} threads`} - - {group.memberProjects.length > 1 ? ( - - ) : null} -
    -
    - - - {member.workspaceRoot} - - -
    -
    - - -
    -
    - ); - })} -
    -
    - - - 1 ? "Remove this project everywhere" : "Remove project" - } - description={ - group.memberProjects.length > 1 - ? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.` - : "Deletes the project entry and its threads. Files on disk are not touched." - } - control={ - - } - /> - +
    + - + ); } diff --git a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx index b4f2b09c6fc..062326c216a 100644 --- a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx +++ b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx @@ -27,7 +27,7 @@ import type { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; -import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -38,6 +38,7 @@ import { useResourceTelemetryHistory, } from "../../lib/resourceTelemetryState"; import { cn } from "../../lib/utils"; +import { ensureLocalApi } from "../../localApi"; import { usePrimaryEnvironment } from "../../state/environments"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -845,26 +846,54 @@ export function ResourceTelemetryDiagnostics() { reportFailure: false, }); const [signalingKeys, setSignalingKeys] = useState>(() => new Set()); + const signalingKeysRef = useRef>(new Set()); + signalingKeysRef.current = signalingKeys; + const primaryEnvironmentIdRef = useRef(primaryEnvironment?.environmentId); + primaryEnvironmentIdRef.current = primaryEnvironment?.environmentId; const [isRetrying, setIsRetrying] = useState(false); const snapshot = telemetry.data; const allT3 = snapshot?.groups.allT3; const signalProcess = useCallback( - (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => { - if ( - signal === "SIGKILL" && - !window.confirm( - `Send SIGKILL to process ${process.identity.pid}? This cannot be handled by the process.`, - ) - ) { - return; - } + async (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => { const identityKey = processIdentityKey(process); - const environmentId = primaryEnvironment?.environmentId; + if (signalingKeysRef.current.has(identityKey)) return; + const nextSignalingKeys = new Set(signalingKeysRef.current).add(identityKey); + signalingKeysRef.current = nextSignalingKeys; + setSignalingKeys(nextSignalingKeys); + const clearSignaling = () => { + const next = new Set(signalingKeysRef.current); + next.delete(identityKey); + signalingKeysRef.current = next; + setSignalingKeys(next); + }; + + if (signal === "SIGKILL") { + let confirmed = false; + try { + confirmed = await ensureLocalApi().dialogs.confirm( + `Send SIGKILL to process ${process.identity.pid}? This cannot be handled by the process.`, + { variant: "destructive" }, + ); + } catch (error) { + clearSignaling(); + toastManager.add({ + type: "error", + title: "Could not confirm signal", + description: error instanceof Error ? error.message : `Failed to send ${signal}.`, + }); + return; + } + if (!confirmed) { + clearSignaling(); + return; + } + } + const environmentId = primaryEnvironmentIdRef.current; if (environmentId === undefined) { + clearSignaling(); return; } - setSignalingKeys((current) => new Set(current).add(identityKey)); void signalServerProcess({ environmentId, input: { @@ -896,15 +925,10 @@ export function ResourceTelemetryDiagnostics() { }); }) .finally(() => { - setSignalingKeys((current) => { - if (!current.has(identityKey)) return current; - const next = new Set(current); - next.delete(identityKey); - return next; - }); + clearSignaling(); }); }, - [primaryEnvironment?.environmentId, signalServerProcess], + [signalServerProcess], ); const retryCollector = useCallback(() => { diff --git a/apps/web/src/components/settings/SettingsBreadcrumb.tsx b/apps/web/src/components/settings/SettingsBreadcrumb.tsx new file mode 100644 index 00000000000..bb631187cb1 --- /dev/null +++ b/apps/web/src/components/settings/SettingsBreadcrumb.tsx @@ -0,0 +1,34 @@ +import { + WorkspaceBreadcrumb, + WorkspaceBreadcrumbItem, + WorkspaceBreadcrumbSeparator, +} from "../WorkspaceBreadcrumb"; +import { SETTINGS_SECTION_LABELS } from "./settingsSearch"; + +const SETTINGS_BREADCRUMB_LABELS: Readonly> = { + ...SETTINGS_SECTION_LABELS, + "/settings/diagnostics": "Diagnostics", +}; + +function settingsBreadcrumbLabel(pathname: string): string | null { + const normalizedPathname = pathname.replace(/\/+$/, "") || "/"; + return SETTINGS_BREADCRUMB_LABELS[normalizedPathname] ?? null; +} + +export function SettingsBreadcrumb({ pathname }: { pathname: string }) { + const sectionLabel = settingsBreadcrumbLabel(pathname); + + return ( + + {sectionLabel ? ( + <> + Settings + + + ) : null} + + {sectionLabel ?? "Settings"} + + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c987ef64299..6743952ae26 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -220,6 +220,7 @@ function AboutVersionTitle() { function AboutVersionSection() { const updateState = useDesktopUpdateState(); const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false); + const [isUpdateActionPending, setIsUpdateActionPending] = useState(false); const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); const selectedUpdateChannel = updateState?.channel ?? "latest"; @@ -255,7 +256,7 @@ function AboutVersionSection() { [selectedUpdateChannel], ); - const handleButtonClick = useCallback(() => { + const handleButtonClick = useCallback(async () => { const bridge = window.desktopBridge; if (!bridge) return; @@ -275,22 +276,43 @@ function AboutVersionSection() { } if (action === "install") { - const confirmed = window.confirm( - getDesktopUpdateInstallConfirmationMessage( - updateState ?? { availableVersion: null, downloadedVersion: null }, - navigator.platform, - ), - ); - if (!confirmed) return; - void bridge.installUpdate().catch((error: unknown) => { + if (isUpdateActionPending) return; + setIsUpdateActionPending(true); + let confirmed = false; + try { + confirmed = await ensureLocalApi().dialogs.confirm( + getDesktopUpdateInstallConfirmationMessage( + updateState ?? { availableVersion: null, downloadedVersion: null }, + navigator.platform, + ), + ); + } catch (error) { + setIsUpdateActionPending(false); toastManager.add( stackedThreadToast({ type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "Install failed.", + title: "Could not confirm update", + description: error instanceof Error ? error.message : "Update confirmation failed.", }), ); - }); + return; + } + if (!confirmed) { + setIsUpdateActionPending(false); + return; + } + void bridge + .installUpdate() + .catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "Install failed.", + }), + ); + }) + .finally(() => setIsUpdateActionPending(false)); return; } @@ -318,7 +340,7 @@ function AboutVersionSection() { }), ); }); - }, [updateState]); + }, [isUpdateActionPending, updateState]); const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null; @@ -352,7 +374,7 @@ function AboutVersionSection() { ) : ( - - / - + / )}
    {isSearching && results.length === 0 ? ( @@ -281,8 +277,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { )) : SETTINGS_NAV_ITEMS.map((item) => { const Icon = item.icon; - // Prefix match keeps the section active on nested routes - // like /settings/projects/$projectKey. const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`); return ( diff --git a/apps/web/src/components/settings/ThemeImportDialog.tsx b/apps/web/src/components/settings/ThemeImportDialog.tsx index a74842acac3..be0de13c645 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.tsx +++ b/apps/web/src/components/settings/ThemeImportDialog.tsx @@ -1,4 +1,4 @@ -import { PlusIcon, UploadIcon } from "lucide-react"; +import { DownloadIcon, PlusIcon } from "lucide-react"; import type { ChangeEvent, DragEvent, UIEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "../../lib/utils"; @@ -456,7 +456,7 @@ export function ThemeImportDialog({ ); const chooseButton = (label = "Choose files") => ( ); diff --git a/apps/web/src/components/settings/ThemeSettings.tsx b/apps/web/src/components/settings/ThemeSettings.tsx index 4eb7904508c..02897205e03 100644 --- a/apps/web/src/components/settings/ThemeSettings.tsx +++ b/apps/web/src/components/settings/ThemeSettings.tsx @@ -183,7 +183,7 @@ function ThemeLibraryCard({ onDownload(); }} > - + } /> @@ -555,9 +555,8 @@ export function ThemeLibrary({

    Themes

    -
    diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 85f568019a1..cf532a77212 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -160,7 +160,7 @@ export function SettingsRow({ ...rowProps }: Omit, "title"> & { title: ReactNode; - description: ReactNode; + description?: ReactNode; status?: ReactNode; resetAction?: ReactNode; control?: ReactNode; @@ -183,9 +183,11 @@ export function SettingsRow({ {resetAction}
    -

    - {description} -

    + {description ? ( +

    + {description} +

    + ) : null} {status ?
    {status}
    : null} {control ? ( diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 061a9848f26..a5851b2c714 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -45,7 +45,7 @@ describe("searchSettings", () => { it("matches normalized title substrings", () => { expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); - expect(searchSettings("work").map((item) => item.id)).toEqual(["project-new-thread-workspace"]); + expect(searchSettings("glass").map((item) => item.id)).toEqual(["setting-glass-opacity"]); expect(searchSettings("xyzzy")).toEqual([]); }); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index f2cd5ec3419..34fd4602f78 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -2,7 +2,6 @@ export type SettingsPath = | "/settings/general" | "/settings/appearance" | "/settings/keybindings" - | "/settings/projects" | "/settings/providers" | "/settings/source-control" | "/settings/connections" @@ -23,7 +22,6 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/general": "General", "/settings/appearance": "Appearance", "/settings/keybindings": "Keybindings", - "/settings/projects": "Projects", "/settings/providers": "Providers", "/settings/source-control": "Source Control", "/settings/connections": "Connections", @@ -176,31 +174,6 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Keybindings", to: "/settings/keybindings", }, - { - id: "projects", - title: "Projects", - to: "/settings/projects", - }, - { - id: "project-default-model", - title: "Project default model", - to: "/settings/projects", - }, - { - id: "project-new-thread-workspace", - title: "Project new-thread workspace", - to: "/settings/projects", - }, - { - id: "project-scripts", - title: "Project scripts", - to: "/settings/projects", - }, - { - id: "project-checkouts", - title: "Project checkouts", - to: "/settings/projects", - }, { id: "providers", title: "Providers", diff --git a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx index 6e490f4352c..421934b98a1 100644 --- a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx @@ -13,7 +13,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; const PROVIDER_UPDATE_PILL_STYLES = { loading: - "bg-update-surface text-update group-has-[button.provider-update-main:hover]/provider-update:bg-update/22", + "bg-update-surface text-update-foreground group-has-[button.provider-update-main:hover]/provider-update:bg-update/22", success: "bg-success/12 text-success group-has-[button.provider-update-main:hover]/provider-update:bg-success/18", warning: diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index f113e0fc943..503411eb68e 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -1,6 +1,7 @@ import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react"; import { useCallback, useState } from "react"; import { isElectron } from "../../env"; +import { ensureLocalApi } from "../../localApi"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { @@ -70,6 +71,7 @@ function SidebarUpdateReleaseNotesTooltip({ export function SidebarUpdatePill() { const state = useDesktopUpdateState(); const [dismissed, setDismissed] = useState(false); + const [isActionPending, setIsActionPending] = useState(false); const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed; const tooltip = state ? getDesktopUpdateButtonTooltip(state) : "Update available"; @@ -80,10 +82,12 @@ export function SidebarUpdatePill() { const arm64Description = state && showArm64Warning ? getArm64IntelBuildWarningDescription(state) : null; - const handleAction = useCallback(() => { + const handleAction = useCallback(async () => { const bridge = window.desktopBridge; if (!bridge || !state) return; - if (disabled || action === "none") return; + if (disabled || action === "none" || isActionPending) return; + + setIsActionPending(true); if (action === "download") { void bridge @@ -111,15 +115,32 @@ export function SidebarUpdatePill() { description: error instanceof Error ? error.message : "An unexpected error occurred.", }), ); - }); + }) + .finally(() => setIsActionPending(false)); return; } if (action === "install") { - const confirmed = window.confirm( - getDesktopUpdateInstallConfirmationMessage(state, navigator.platform), - ); - if (!confirmed) return; + let confirmed = false; + try { + confirmed = await ensureLocalApi().dialogs.confirm( + getDesktopUpdateInstallConfirmationMessage(state, navigator.platform), + ); + } catch (error) { + setIsActionPending(false); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not confirm update", + description: error instanceof Error ? error.message : "Update confirmation failed.", + }), + ); + return; + } + if (!confirmed) { + setIsActionPending(false); + return; + } void bridge .installUpdate() .then((result) => { @@ -142,9 +163,10 @@ export function SidebarUpdatePill() { description: error instanceof Error ? error.message : "An unexpected error occurred.", }), ); - }); + }) + .finally(() => setIsActionPending(false)); } - }, [action, disabled, state]); + }, [action, disabled, isActionPending, state]); if (!visible && !showArm64Warning) return null; @@ -159,7 +181,7 @@ export function SidebarUpdatePill() { )} {visible && (
    @@ -170,8 +192,8 @@ export function SidebarUpdatePill() { -
    -

    Usage

    + +
    + {!isElectron && ( +
    + + Usage + +
    + )} + + {isElectron && ( +
    + + Usage + +
    + )} + + +
    +

    {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)}

    -
    -
    -
    -
    - {WINDOW_OPTIONS.map((option) => ( +
    +
    + {WINDOW_OPTIONS.map((option) => ( + + ))} +
    - ))} +
    - -
    - - {settling ? ( - <> - {environments.length > 1 ? : null} - - - ) : ( - <> - + {settling ? ( + <> + {environments.length > 1 ? : null} + + + ) : ( + <> + - {/* Cost first: the financial answer, then the provider split. */} -
    - {/* The summary follows the chart toggle, so the headline and the + {/* Cost first: the financial answer, then the provider split. */} +
    + {/* The summary follows the chart toggle, so the headline and the series are always reading the same units. */} -
    -
    - - {metric === "cost" ? "Raw token cost" : "Processed tokens"} - - - {metric === "cost" - ? `${formatUsd(merged.costUsd)}*` - : formatTokens(merged.totalTokens)} - - - {metric === "cost" - ? "* if billed at full API rate" - : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`} - -
    - - {orderedProviders.map((provider) => { - const share = metric === "cost" ? provider.costShare : provider.tokenShare; - return ( -
    -
    - - - {PROVIDER_LABEL[provider.provider]} - - - {metric === "cost" - ? formatUsd(provider.costUsd) - : formatTokens(provider.totalTokens)} - -
    -
    -
    -
    +
    +
    + + {metric === "cost" ? "Raw token cost" : "Processed tokens"} + + + {metric === "cost" + ? `${formatUsd(merged.costUsd)}*` + : formatTokens(merged.totalTokens)} + {metric === "cost" - ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` - : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} + ? "* if billed at full API rate" + : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`}
    - ); - })} -
    -
    -
    -

    - Daily {metric === "tokens" ? "processed tokens" : "cost"} -

    -
    + {orderedProviders.map((provider) => { + const share = metric === "cost" ? provider.costShare : provider.tokenShare; + return ( +
    +
    + + + {PROVIDER_LABEL[provider.provider]} + + + {metric === "cost" + ? formatUsd(provider.costUsd) + : formatTokens(provider.totalTokens)} + +
    +
    +
    +
    + + {metric === "cost" + ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` + : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} + +
    + ); + })} +
    + +
    +
    +

    + Daily {metric === "tokens" ? "processed tokens" : "cost"} +

    +
    +
    + {(["cost", "tokens"] as const).map((option) => ( + + ))} +
    + +
    +
    + +
    +
    + +
    + + + + + 0 + ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` + : "vs full input rates" + } + /> +
    + +
    +
    +

    Breakdown

    - {(["cost", "tokens"] as const).map((option) => ( + {(["model", "day"] as const).map((option) => ( ))}
    -
    -
    - -
    - - -
    - - - - - 0 - ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` - : "vs full input rates" - } - /> -
    - -
    -
    -

    Breakdown

    -
    - {(["model", "day"] as const).map((option) => ( - - ))} -
    -
    - {breakdown === "model" ? ( - - - - - - - - - - - {merged.models.length === 0 ? ( - - - - ) : ( - merged.models.map((model) => ( - - - - - + {breakdown === "model" ? ( +
    ModelCostShareTokens
    - No activity in this window. -
    - - - {model.model} - - - {formatUsd(model.costUsd)} - - {formatPercent(model.costShare)} - - {formatTokens(model.totalTokens)} -
    + + + + + + - )) - )} - -
    ModelCostShareTokens
    - ) : ( - - - - - {PROVIDER_ORDER.map((provider) => ( - - ))} - - - - - - {recentDays.length === 0 ? ( - - - - ) : ( - recentDays.map((day) => ( - - - {PROVIDER_ORDER.map((provider) => ( - + {merged.models.length === 0 ? ( + + + + ) : ( + merged.models.map((model) => ( + + + + + + + )) + )} + +
    Day - {PROVIDER_LABEL[provider]} - TotalTokens
    - No activity in this window. -
    {formatDayShort(day.day)} - {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} + +
    + No activity in this window.
    + + + {model.model} + + + {formatUsd(model.costUsd)} + + {formatPercent(model.costShare)} + + {formatTokens(model.totalTokens)} +
    + ) : ( + + + + + {PROVIDER_ORDER.map((provider) => ( + ))} - - + + - )) - )} - -
    Day + {PROVIDER_LABEL[provider]} + - {formatUsd(day.costUsd)} - - {formatTokens(day.totalTokens)} - TotalTokens
    - )} -
    - - )} + + + {recentDays.length === 0 ? ( + + + No activity in this window. + + + ) : ( + recentDays.map((day) => ( + + {formatDayShort(day.day)} + {PROVIDER_ORDER.map((provider) => ( + + {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} + + ))} + + {formatUsd(day.costUsd)} + + + {formatTokens(day.totalTokens)} + + + )) + )} + + + )} + + + )} +
    + - + ); } diff --git a/apps/web/src/confirmDialog.test.ts b/apps/web/src/confirmDialog.test.ts new file mode 100644 index 00000000000..ef60c55ce89 --- /dev/null +++ b/apps/web/src/confirmDialog.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { + completeConfirmDialogClose, + readConfirmDialogState, + registerConfirmDialogHost, + requestConfirmDialog, + resetConfirmDialogForTests, + respondToConfirmDialog, +} from "./confirmDialog"; + +function requireConfirmation(confirmation: Promise | undefined): Promise { + if (!confirmation) { + throw new Error("Expected a registered confirmation host."); + } + return confirmation; +} + +describe("confirm dialog coordinator", () => { + beforeEach(() => { + resetConfirmDialogForTests(); + }); + + it("returns undefined until a themed host is mounted", () => { + expect(requestConfirmDialog("Confirm this action?")).toBeUndefined(); + expect(readConfirmDialogState()).toEqual({ status: "idle" }); + }); + + it("resolves a displayed confirmation and waits for its close transition", async () => { + const unregister = registerConfirmDialogHost(); + const confirmation = requireConfirmation( + requestConfirmDialog("Delete this thread?", { variant: "destructive" }), + ); + + expect(readConfirmDialogState()).toEqual({ + status: "confirming", + message: "Delete this thread?", + variant: "destructive", + }); + + respondToConfirmDialog(true); + await expect(confirmation).resolves.toBe(true); + expect(readConfirmDialogState()).toEqual({ + status: "closing", + message: "Delete this thread?", + variant: "destructive", + }); + + completeConfirmDialogClose(); + expect(readConfirmDialogState()).toEqual({ status: "idle" }); + unregister(); + }); + + it("serializes concurrent confirmations", async () => { + const unregister = registerConfirmDialogHost(); + const first = requireConfirmation(requestConfirmDialog("Delete the project?")); + const second = requireConfirmation(requestConfirmDialog("Delete the worktree too?")); + + respondToConfirmDialog(false); + await expect(first).resolves.toBe(false); + expect(readConfirmDialogState()).toEqual({ + status: "closing", + message: "Delete the project?", + variant: "default", + }); + + completeConfirmDialogClose(); + expect(readConfirmDialogState()).toEqual({ + status: "confirming", + message: "Delete the worktree too?", + variant: "default", + }); + + respondToConfirmDialog(true); + await expect(second).resolves.toBe(true); + completeConfirmDialogClose(); + expect(readConfirmDialogState()).toEqual({ status: "idle" }); + unregister(); + }); + + it("cancels active and queued confirmations if the last host unmounts", async () => { + const unregister = registerConfirmDialogHost(); + const active = requireConfirmation(requestConfirmDialog("Delete the thread?")); + const queued = requireConfirmation(requestConfirmDialog("Delete the worktree too?")); + + unregister(); + + await expect(Promise.all([active, queued])).resolves.toEqual([false, false]); + expect(readConfirmDialogState()).toEqual({ status: "idle" }); + }); + + it("ignores responses after the active dialog has been closed", () => { + const unregister = registerConfirmDialogHost(); + const confirmation = requireConfirmation(requestConfirmDialog("Continue?")); + + respondToConfirmDialog(true); + respondToConfirmDialog(false); + completeConfirmDialogClose(); + + expect(readConfirmDialogState()).toEqual({ status: "idle" }); + unregister(); + return expect(confirmation).resolves.toBe(true); + }); +}); diff --git a/apps/web/src/confirmDialog.ts b/apps/web/src/confirmDialog.ts new file mode 100644 index 00000000000..1bff7c930a0 --- /dev/null +++ b/apps/web/src/confirmDialog.ts @@ -0,0 +1,131 @@ +import type { ConfirmDialogOptions, ConfirmDialogVariant } from "@t3tools/contracts"; + +export type ConfirmDialogState = + | { readonly status: "idle" } + | { + readonly status: "confirming"; + readonly message: string; + readonly variant: ConfirmDialogVariant; + } + | { + readonly status: "closing"; + readonly message: string; + readonly variant: ConfirmDialogVariant; + }; + +type PendingConfirmation = { + readonly message: string; + readonly variant: ConfirmDialogVariant; + readonly resolve: (confirmed: boolean) => void; +}; + +const idleState: ConfirmDialogState = { status: "idle" }; +let state: ConfirmDialogState = idleState; +let activeConfirmation: PendingConfirmation | null = null; +let queuedConfirmations: PendingConfirmation[] = []; +let registeredHostCount = 0; +const listeners = new Set<() => void>(); + +function publish(next: ConfirmDialogState): void { + state = next; + for (const listener of listeners) { + listener(); + } +} + +function resolvePendingConfirmations(confirmed: boolean): void { + activeConfirmation?.resolve(confirmed); + for (const confirmation of queuedConfirmations) { + confirmation.resolve(confirmed); + } + activeConfirmation = null; + queuedConfirmations = []; +} + +export function readConfirmDialogState(): ConfirmDialogState { + return state; +} + +export function subscribeConfirmDialog(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Registers the renderer host that can present themed confirmations. The + * returned cleanup function also cancels any request left without a host. + */ +export function registerConfirmDialogHost(): () => void { + registeredHostCount += 1; + let registered = true; + + return () => { + if (!registered) return; + registered = false; + registeredHostCount = Math.max(0, registeredHostCount - 1); + + if (registeredHostCount === 0) { + resolvePendingConfirmations(false); + publish(idleState); + } + }; +} + +/** + * Requests a themed confirmation when a host is mounted. An undefined result + * means no themed host is currently available. + */ +export function requestConfirmDialog( + message: string, + options?: ConfirmDialogOptions, +): Promise | undefined { + if (registeredHostCount === 0) return undefined; + + const confirmation = new Promise((resolve) => { + const pending = { + message, + variant: options?.variant ?? "default", + resolve, + } satisfies PendingConfirmation; + if (activeConfirmation || state.status === "closing") { + queuedConfirmations.push(pending); + return; + } + + activeConfirmation = pending; + publish({ status: "confirming", message, variant: pending.variant }); + }); + + return confirmation; +} + +export function respondToConfirmDialog(confirmed: boolean): void { + if (state.status !== "confirming" || !activeConfirmation) return; + + const confirmation = activeConfirmation; + activeConfirmation = null; + confirmation.resolve(confirmed); + publish({ status: "closing", message: state.message, variant: state.variant }); +} + +export function completeConfirmDialogClose(): void { + if (state.status !== "closing") return; + + const next = queuedConfirmations.shift(); + if (!next) { + publish(idleState); + return; + } + + activeConfirmation = next; + publish({ status: "confirming", message: next.message, variant: next.variant }); +} + +export function resetConfirmDialogForTests(): void { + resolvePendingConfirmations(false); + registeredHostCount = 0; + publish(idleState); + listeners.clear(); +} diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 85ffde776b4..24efe4ea196 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -250,6 +250,7 @@ export function useThreadActionMenu(input: { `Delete thread "${thread.title}"?`, "This permanently clears conversation history for this thread.", ].join("\n"), + { variant: "destructive" }, ), ); if (confirmed._tag === "Failure" || !confirmed.value) return; diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 22548b23360..569b4be96e6 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -324,6 +324,7 @@ export function useThreadActions() { "", "Delete the worktree too?", ].join("\n"), + { variant: "destructive" }, ), ); if (confirmationResult._tag === "Failure") { @@ -678,6 +679,7 @@ export function useThreadActions() { `Delete thread "${title}"?`, "This permanently clears conversation history for this thread.", ].join("\n"), + { variant: "destructive" }, ), ); if (confirmationResult._tag === "Failure") { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 9ebb5c9f593..d975f8c668e 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1006,7 +1006,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --warning-foreground: var(--color-amber-400); --warning-surface: color-mix(in srgb, var(--warning) 16%, transparent); --update: var(--primary); - --update-foreground: var(--primary); + --update-foreground: var(--color-blue-400); --update-surface: color-mix(in srgb, var(--update) 18%, transparent); --sidebar: var(--card); --sidebar-foreground: var(--foreground); diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 260256c1250..064b927031d 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_CLIENT_SETTINGS, + type ConfirmDialogOptions, type ContextMenuItem, type DesktopBridge, } from "@t3tools/contracts"; @@ -13,10 +14,17 @@ const showContextMenuFallbackMock = ) => Promise >(); +const requestConfirmDialogMock = + vi.fn<(message: string, options?: ConfirmDialogOptions) => Promise | undefined>(); + vi.mock("./contextMenuFallback", () => ({ showContextMenuFallback: showContextMenuFallbackMock, })); +vi.mock("./confirmDialog", () => ({ + requestConfirmDialog: requestConfirmDialogMock, +})); + function createLocalStorageStub(): Storage { const values = new Map(); return { @@ -77,6 +85,24 @@ describe("LocalApi", () => { expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); }); + it("uses the themed confirmation host when it is available", async () => { + requestConfirmDialogMock.mockResolvedValue(true); + const { createLocalApi } = await import("./localApi"); + const options = { variant: "destructive" } as const; + + await expect(createLocalApi().dialogs.confirm("Delete this thread?", options)).resolves.toBe( + true, + ); + expect(requestConfirmDialogMock).toHaveBeenCalledWith("Delete this thread?", options); + }); + + it("fails closed in a browser when no themed host is available", async () => { + requestConfirmDialogMock.mockReturnValue(undefined); + const { createLocalApi } = await import("./localApi"); + + await expect(createLocalApi().dialogs.confirm("Delete this thread?")).resolves.toBe(false); + }); + it("delegates host capabilities and persistence to the desktop bridge", async () => { const showContextMenu = vi.fn().mockResolvedValue("delete"); const pickFolder = vi.fn().mockResolvedValue("/tmp/project"); @@ -94,6 +120,8 @@ describe("LocalApi", () => { const items = [{ id: "delete", label: "Delete" }] as const; await expect(api.contextMenu.show(items)).resolves.toBe("delete"); + requestConfirmDialogMock.mockReturnValue(undefined); + await expect(api.dialogs.confirm("Install update?")).resolves.toBe(false); await expect(api.dialogs.pickFolder({ initialPath: "/tmp" })).resolves.toBe("/tmp/project"); await expect(api.persistence.getClientSettings()).resolves.toEqual(DEFAULT_CLIENT_SETTINGS); await api.persistence.setClientSettings(DEFAULT_CLIENT_SETTINGS); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index b42702c7a4a..5c8f4ec9da8 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -1,8 +1,9 @@ -import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; +import type { ConfirmDialogOptions, ContextMenuItem, LocalApi } from "@t3tools/contracts"; -import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; +import { requestConfirmDialog } from "./confirmDialog"; import { showContextMenuFallback } from "./contextMenuFallback"; import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage"; +import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; let cachedApi: LocalApi | undefined; @@ -13,11 +14,8 @@ function createBrowserLocalApi(): LocalApi { if (!window.desktopBridge) return null; return window.desktopBridge.pickFolder(options); }, - confirm: async (message) => { - if (window.desktopBridge) { - return window.desktopBridge.confirm(message); - } - return window.confirm(message); + confirm: async (message, options?: ConfirmDialogOptions) => { + return requestConfirmDialog(message, options) ?? false; }, }, shell: { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index ac7f4111157..e500a8fcbd7 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -17,15 +17,14 @@ import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' -import { Route as SettingsProjectsRouteImport } from './routes/settings.projects' import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' +import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey' import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' -import { Route as SettingsProjectsProjectKeyRouteImport } from './routes/settings.projects_.$projectKey' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -68,11 +67,6 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ path: '/providers', getParentRoute: () => SettingsRoute, } as any) -const SettingsProjectsRoute = SettingsProjectsRouteImport.update({ - id: '/projects', - path: '/projects', - getParentRoute: () => SettingsRoute, -} as any) const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ id: '/keybindings', path: '/keybindings', @@ -103,17 +97,16 @@ const SettingsAppearanceRoute = SettingsAppearanceRouteImport.update({ path: '/appearance', getParentRoute: () => SettingsRoute, } as any) +const ProjectsProjectKeyRoute = ProjectsProjectKeyRouteImport.update({ + id: '/projects/$projectKey', + path: '/projects/$projectKey', + getParentRoute: () => rootRouteImport, +} as any) const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ id: '/connect_/callback', path: '/connect/callback', getParentRoute: () => rootRouteImport, } as any) -const SettingsProjectsProjectKeyRoute = - SettingsProjectsProjectKeyRouteImport.update({ - id: '/projects_/$projectKey', - path: '/projects/$projectKey', - getParentRoute: () => SettingsRoute, - } as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -133,18 +126,17 @@ export interface FileRoutesByFullPath { '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute '/connect/callback': typeof ConnectCallbackRoute + '/projects/$projectKey': typeof ProjectsProjectKeyRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute - '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute - '/settings/projects/$projectKey': typeof SettingsProjectsProjectKeyRoute } export interface FileRoutesByTo { '/connect': typeof ConnectRoute @@ -152,19 +144,18 @@ export interface FileRoutesByTo { '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute '/connect/callback': typeof ConnectCallbackRoute + '/projects/$projectKey': typeof ProjectsProjectKeyRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute - '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute - '/settings/projects/$projectKey': typeof SettingsProjectsProjectKeyRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -174,19 +165,18 @@ export interface FileRoutesById { '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute '/connect_/callback': typeof ConnectCallbackRoute + '/projects/$projectKey': typeof ProjectsProjectKeyRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute - '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/_chat/': typeof ChatIndexRoute '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute - '/settings/projects_/$projectKey': typeof SettingsProjectsProjectKeyRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -197,18 +187,17 @@ export interface FileRouteTypes { | '/settings' | '/usage' | '/connect/callback' + | '/projects/$projectKey' | '/settings/appearance' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' - | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/$environmentId/$threadId' | '/draft/$draftId' - | '/settings/projects/$projectKey' fileRoutesByTo: FileRoutesByTo to: | '/connect' @@ -216,19 +205,18 @@ export interface FileRouteTypes { | '/settings' | '/usage' | '/connect/callback' + | '/projects/$projectKey' | '/settings/appearance' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' - | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/' | '/$environmentId/$threadId' | '/draft/$draftId' - | '/settings/projects/$projectKey' id: | '__root__' | '/_chat' @@ -237,19 +225,18 @@ export interface FileRouteTypes { | '/settings' | '/usage' | '/connect_/callback' + | '/projects/$projectKey' | '/settings/appearance' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' - | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/_chat/' | '/_chat/$environmentId/$threadId' | '/_chat/draft/$draftId' - | '/settings/projects_/$projectKey' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -259,6 +246,7 @@ export interface RootRouteChildren { SettingsRoute: typeof SettingsRouteWithChildren UsageRoute: typeof UsageRoute ConnectCallbackRoute: typeof ConnectCallbackRoute + ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute } declare module '@tanstack/react-router' { @@ -319,13 +307,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProvidersRouteImport parentRoute: typeof SettingsRoute } - '/settings/projects': { - id: '/settings/projects' - path: '/projects' - fullPath: '/settings/projects' - preLoaderRoute: typeof SettingsProjectsRouteImport - parentRoute: typeof SettingsRoute - } '/settings/keybindings': { id: '/settings/keybindings' path: '/keybindings' @@ -368,6 +349,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsAppearanceRouteImport parentRoute: typeof SettingsRoute } + '/projects/$projectKey': { + id: '/projects/$projectKey' + path: '/projects/$projectKey' + fullPath: '/projects/$projectKey' + preLoaderRoute: typeof ProjectsProjectKeyRouteImport + parentRoute: typeof rootRouteImport + } '/connect_/callback': { id: '/connect_/callback' path: '/connect/callback' @@ -375,13 +363,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ConnectCallbackRouteImport parentRoute: typeof rootRouteImport } - '/settings/projects_/$projectKey': { - id: '/settings/projects_/$projectKey' - path: '/projects/$projectKey' - fullPath: '/settings/projects/$projectKey' - preLoaderRoute: typeof SettingsProjectsProjectKeyRouteImport - parentRoute: typeof SettingsRoute - } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -420,10 +401,8 @@ interface SettingsRouteChildren { SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute - SettingsProjectsRoute: typeof SettingsProjectsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute - SettingsProjectsProjectKeyRoute: typeof SettingsProjectsProjectKeyRoute } const SettingsRouteChildren: SettingsRouteChildren = { @@ -433,10 +412,8 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, - SettingsProjectsRoute: SettingsProjectsRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, - SettingsProjectsProjectKeyRoute: SettingsProjectsProjectKeyRoute, } const SettingsRouteWithChildren = SettingsRoute._addFileChildren( @@ -450,6 +427,7 @@ const rootRouteChildren: RootRouteChildren = { SettingsRoute: SettingsRouteWithChildren, UsageRoute: UsageRoute, ConnectCallbackRoute: ConnectCallbackRoute, + ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 04b0adddfe1..0e021757b7b 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -22,6 +22,7 @@ import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding"; import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; +import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; import { FloatingPillNav } from "../components/FloatingPillNav"; import { FloatingCodePill } from "../editor/floating-code-pill"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; @@ -154,6 +155,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? : null} diff --git a/apps/web/src/routes/projects.$projectKey.tsx b/apps/web/src/routes/projects.$projectKey.tsx new file mode 100644 index 00000000000..6ae03719c04 --- /dev/null +++ b/apps/web/src/routes/projects.$projectKey.tsx @@ -0,0 +1,15 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +import { ProjectSettingsPage } from "../components/settings/ProjectSettingsPanel"; + +export const Route = createFileRoute("/projects/$projectKey")({ + beforeLoad: async ({ context }) => { + if ( + context.authGateState.status !== "authenticated" && + context.authGateState.status !== "hosted-static" + ) { + throw redirect({ to: "/pair", replace: true }); + } + }, + component: () => , +}); diff --git a/apps/web/src/routes/settings.projects.tsx b/apps/web/src/routes/settings.projects.tsx deleted file mode 100644 index c8dab231145..00000000000 --- a/apps/web/src/routes/settings.projects.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; - -import { ProjectSettingsPanel } from "../components/settings/ProjectSettingsPanel"; - -function SettingsProjectsRoute() { - return ; -} - -export const Route = createFileRoute("/settings/projects")({ - component: SettingsProjectsRoute, -}); diff --git a/apps/web/src/routes/settings.projects_.$projectKey.tsx b/apps/web/src/routes/settings.projects_.$projectKey.tsx deleted file mode 100644 index 477ab0c5ebd..00000000000 --- a/apps/web/src/routes/settings.projects_.$projectKey.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; - -import { ProjectSettingsPanel } from "../components/settings/ProjectSettingsPanel"; - -function SettingsProjectDetailRoute() { - const { projectKey } = Route.useParams(); - return ; -} - -export const Route = createFileRoute("/settings/projects_/$projectKey")({ - component: SettingsProjectDetailRoute, -}); diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index ed2c132aae4..f14793ba544 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -10,6 +10,7 @@ import { import { useCallback, useEffect, useState } from "react"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; +import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb"; import { Button } from "../components/ui/button"; import { SidebarInset } from "../components/ui/sidebar"; import { isElectron } from "../env"; @@ -79,7 +80,7 @@ function SettingsContentLayout() { )} >
    - Settings + {showRestoreDefaults ? (
    @@ -96,14 +97,14 @@ function SettingsContentLayout() { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, )} > - - Settings - - {showRestoreDefaults ? ( -
    - -
    - ) : null} +
    + + {showRestoreDefaults ? ( +
    + +
    + ) : null} +
    )} diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 2f6fb043454..686201fa2d9 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -566,7 +566,7 @@ const T3_CODE_DARK_THEME_COLORS: ThemeColors = { warningForeground: "#ffb900", warningSurface: "#312108", update: "#366ffb", - updateForeground: "#366ffb", + updateForeground: "#51a2ff", updateSurface: "#121c35", accentSurface: "#141414", accentSurfaceForeground: "#f5f5f5", diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index ec26e3e5bec..416bd069094 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -73,6 +73,28 @@ describe("buildProjectGroups", () => { } }); + it("uses a shared custom title as the repository group's label", () => { + const projects = [ + makeProject("first", "/work/t3code", { title: "Custom project" }), + makeProject("second", "/work/t3code-2", { title: "Custom project" }), + ]; + + expect(buildProjectGroups({ projects, settings: settings("repository") })[0]?.label).toBe( + "Custom project", + ); + }); + + it("keeps the repository label when shared titles match its repository name", () => { + const projects = [ + makeProject("first", "/work/t3code", { title: "t3code" }), + makeProject("second", "/work/t3code-2", { title: "t3code" }), + ]; + + expect(buildProjectGroups({ projects, settings: settings("repository") })[0]?.label).toBe( + "T3 Code", + ); + }); + it("keeps physical clones in separate groups when requested", () => { const projects = [ makeProject("t3code", "/work/t3code"), diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index 8606c4855f2..43785d85dbb 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -169,16 +169,26 @@ export function deriveProjectGroupLabel(input: { readonly representative: Pick; readonly members: ReadonlyArray>; }): string { + const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title)); const sharedDisplayNames = uniqueNonEmptyValues( input.members.map((member) => member.repositoryIdentity?.displayName), ); + const sharedRepositoryNames = uniqueNonEmptyValues( + input.members.map((member) => member.repositoryIdentity?.name), + ); + const sharedTitle = sharedTitles[0]; + if ( + sharedTitles.length === 1 && + sharedTitle !== undefined && + !sharedDisplayNames.includes(sharedTitle) && + !sharedRepositoryNames.includes(sharedTitle) + ) { + return sharedTitle; + } if (sharedDisplayNames.length === 1) { return sharedDisplayNames[0]!; } - const sharedRepositoryNames = uniqueNonEmptyValues( - input.members.map((member) => member.repositoryIdentity?.name), - ); if (sharedRepositoryNames.length === 1) { return sharedRepositoryNames[0]!; } diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 357156ec039..d19d90f1552 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.32", + "version": "0.0.33", "private": true, "files": [ "dist" diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f662da558c9..67cc0b28044 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1050,7 +1050,6 @@ export interface DesktopBridge { * web callers fall back to a plain file input. */ pickThemeFiles?: () => Promise; - confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], @@ -1145,6 +1144,12 @@ export interface DesktopPreviewBridge { onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void; } +export type ConfirmDialogVariant = "default" | "destructive"; + +export interface ConfirmDialogOptions { + readonly variant?: ConfirmDialogVariant; +} + /** * APIs bound to the local app shell, not to any particular backend environment. * @@ -1158,7 +1163,7 @@ export interface DesktopPreviewBridge { export interface LocalApi { dialogs: { pickFolder: (options?: PickFolderOptions) => Promise; - confirm: (message: string) => Promise; + confirm: (message: string, options?: ConfirmDialogOptions) => Promise; }; shell: { openExternal: (url: string) => Promise; diff --git a/packages/shared/src/composerInlineTokens.test.ts b/packages/shared/src/composerInlineTokens.test.ts index 5a7c14f1725..81fd6add205 100644 --- a/packages/shared/src/composerInlineTokens.test.ts +++ b/packages/shared/src/composerInlineTokens.test.ts @@ -129,4 +129,25 @@ describe("collectComposerInlineTokens", () => { }, ]); }); + + it("still collects a file link whose label is at the length cap", () => { + const label = `${"a".repeat(508)}.tsx`; + const tokens = collectComposerInlineTokens(`see [${label}](src/${label}) ok`); + + expect(tokens).toHaveLength(1); + expect(tokens[0]?.value).toBe(`src/${label}`); + }); + + it("leaves a file link past the label cap as plain text", () => { + const label = `${"a".repeat(509)}.tsx`; + expect(collectComposerInlineTokens(`see [${label}](src/${label}) ok`)).toEqual([]); + }); + + it("stays fast on unterminated bracket runs", () => { + // Unbounded, the label body rescanned the rest of the text from every + // whitespace: this input took seconds. + const started = performance.now(); + expect(collectComposerInlineTokens(" [[".repeat(40_000))).toEqual([]); + expect(performance.now() - started).toBeLessThan(1_000); + }); }); diff --git a/packages/shared/src/composerInlineTokens.ts b/packages/shared/src/composerInlineTokens.ts index dda548059df..11a5accf37b 100644 --- a/packages/shared/src/composerInlineTokens.ts +++ b/packages/shared/src/composerInlineTokens.ts @@ -20,7 +20,21 @@ export interface CollectComposerInlineTokensOptions { const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g; const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; -const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g; +/** + * The label body is bounded rather than `*`. Unbounded, every whitespace in + * the composer is a candidate start: the engine scans the rest of the text for + * a closing `]`, fails, and rescans from the next whitespace — quadratic on + * input like " [[[[[…". A cap makes each attempt constant-bounded. + * + * Only a basename ever survives the `label !== basename` check below, so this + * cannot reject a link a user could meaningfully write; the longest filename + * any common filesystem allows is 255. + */ +const MAX_FILE_LINK_LABEL_LENGTH = 512; +const FILE_LINK_TOKEN_REGEX = new RegExp( + `(^|\\s)\\[((?:\\\\.|[^\\]\\\\]){0,${MAX_FILE_LINK_LABEL_LENGTH}})\\]\\(([^)\\s]+)\\)(?=\\s)`, + "g", +); const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; // Autocomplete emits canonical file links, so ambiguous bare @scope/package text stays a package. diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 1186f47ce78..4c0d4709c62 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -338,6 +338,7 @@ describe("resolveKnownWindowsCliDirs", () => { "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", ]); @@ -478,6 +479,7 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", "C:\\Shell\\Bin", @@ -526,6 +528,7 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", "C:\\Shell\\Bin", @@ -566,6 +569,7 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { ).toEqual({ PATH: [ "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", "C:\\Windows\\System32", diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 0c0353ba17b..5d1b807b92a 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -682,7 +682,9 @@ export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArra ...(appData ? [`${appData}\\npm`] : []), ...(localAppData ? [`${localAppData}\\Programs\\nodejs`, `${localAppData}\\Volta\\bin`] : []), ...(localAppData ? [`${localAppData}\\pnpm`] : []), - ...(userProfile ? [`${userProfile}\\.bun\\bin`, `${userProfile}\\scoop\\shims`] : []), + ...(userProfile + ? [`${userProfile}\\.local\\bin`, `${userProfile}\\.bun\\bin`, `${userProfile}\\scoop\\shims`] + : []), ]; }