From b5ee8b72ff6625aeb6a078d33f181e072e7d2dda Mon Sep 17 00:00:00 2001 From: riyadmondol2006 Date: Fri, 30 Oct 2026 20:39:46 +0600 Subject: [PATCH 1/3] feat(desktop): ship a self-updating .rpm that coexists with the AppImage Fedora and openSUSE users only had the AppImage. The Linux release build now also packs an .rpm in the same electron-builder run as the AppImage and .deb, the release publishes it, and the app updates it through electron-updater like the .deb. electron-builder's stock after-remove script runs as the old package's %postun, which rpm executes after the new package's %post during an upgrade, so every rpm update would have deleted /usr/bin/t3code. The .rpm ships its own script that only cleans up on erase. An AppImage started beside an installed .deb or .rpm of the same channel used to point the t3code:// handler and the t3 command back at itself, which broke both once the AppImage was deleted. It now points them at the package, writing the same entries the package writes. --- .github/workflows/release-desktop.yml | 4 +- .github/workflows/release.yml | 1 + README.md | 8 ++++ .../resources/linux/rpm-after-remove.sh | 23 ++++++++++ apps/desktop/src/app/DesktopCliShim.ts | 24 +++++++---- apps/desktop/src/app/DesktopLinuxPackage.ts | 38 +++++++++++++++++ .../src/app/DesktopLinuxUrlHandler.test.ts | 25 ++++++++++- .../desktop/src/app/DesktopLinuxUrlHandler.ts | 3 ++ .../src/app/DesktopPreReadyPlatform.ts | 23 +++++++--- .../src/updates/DesktopUpdates.test.ts | 8 +++- apps/desktop/src/updates/DesktopUpdates.ts | 14 +++---- apps/marketing/src/pages/download.astro | 4 +- .../settings/UpdateTrackSwitchDialog.tsx | 2 +- docs/operations/development.md | 9 ++-- docs/operations/release.md | 4 +- docs/user/install.md | 13 ++++-- scripts/build-desktop-artifact.test.ts | 42 ++++++++++++++++++- scripts/build-desktop-artifact.ts | 37 +++++++++++++++- 18 files changed, 245 insertions(+), 37 deletions(-) create mode 100644 apps/desktop/resources/linux/rpm-after-remove.sh create mode 100644 apps/desktop/src/app/DesktopLinuxPackage.ts diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 5a16a9b4c170f..fc0ea89572892 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -447,7 +447,8 @@ jobs: shell: bash run: | sudo apt-get update - sudo apt-get install -y libsecret-1-dev pkg-config + # rpm provides rpmbuild, which electron-builder needs for the .rpm. + sudo apt-get install -y libsecret-1-dev pkg-config rpm if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then sudo apt-get install -y imagemagick fi @@ -701,6 +702,7 @@ jobs: "release/*.zip" "release/*.AppImage" "release/*.deb" + "release/*.rpm" "release/*.exe" ) # Preview builds have no publish config, so electron-builder writes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d89b7c7abe7a..306f32d220100 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -883,6 +883,7 @@ jobs: echo 'release-assets/*.zip' echo 'release-assets/*.AppImage' echo 'release-assets/*.deb' + echo 'release-assets/*.rpm' echo 'release-assets/*.exe' if [[ "${{ needs.preflight.outputs.release_channel }}" != "preview" ]]; then echo 'release-assets/*.blockmap' diff --git a/README.md b/README.md index 6f6dab985e154..898df4d8b54db 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,14 @@ Download the `.deb` from [GitHub Releases](https://github.com/pingdotgg/t3code/r sudo apt install ./T3-Code-*.deb ``` +#### Fedora, openSUSE (`.rpm`) + +Download the `.rpm` from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), then: + +```bash +sudo dnf install ./T3-Code-*.rpm +``` + #### Arch Linux (AUR) Stable: diff --git a/apps/desktop/resources/linux/rpm-after-remove.sh b/apps/desktop/resources/linux/rpm-after-remove.sh new file mode 100644 index 0000000000000..045b3c7eed7b6 --- /dev/null +++ b/apps/desktop/resources/linux/rpm-after-remove.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# electron-builder's after-remove template, run as %postun. rpm passes the number of installs +# left: 0 on erase, 1 when an upgrade runs the old package's %postun after the new %post. +# Cleaning up then would remove the link and profile the new package just installed. +[ "$1" = "0" ] || exit 0 + +if type update-alternatives >/dev/null 2>&1; then + update-alternatives --remove '${executable}' '/opt/${sanitizedProductName}/${executable}' +else + rm -f '/usr/bin/${executable}' +fi + +APPARMOR_PROFILE_DEST='/etc/apparmor.d/${executable}' + +if [ -f "$APPARMOR_PROFILE_DEST" ]; then + if apparmor_status --enabled > /dev/null 2>&1; then + if ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; } && hash apparmor_parser 2>/dev/null; then + apparmor_parser --remove "$APPARMOR_PROFILE_DEST" || true + fi + fi + rm -f "$APPARMOR_PROFILE_DEST" +fi diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index 3d4b94abff462..443c474535cb8 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -4,6 +4,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopLinuxPackage from "./DesktopLinuxPackage.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; // AppImage mounts change each run; the launcher must mount the image itself. @@ -127,16 +128,23 @@ export const install = Effect.gen(function* () { const windows = environment.platform === "win32"; const shimPath = launcherPath(environment); const entry = path.join(environment.serverRoot, SERVER_ENTRY); + const systemPackage = yield* DesktopLinuxPackage.findSystemPackageBesideAppImage(environment); const target: CliShimTarget = windows ? { kind: "windows", executable: process.execPath, entry } - : Option.match(environment.appImagePath, { - onSome: (appImage) => ({ - kind: "appimage" as const, - appImage, - executableName: path.basename(process.execPath), - }), - onNone: () => ({ kind: "direct" as const, executable: process.execPath, entry }), - }); + : Option.isSome(systemPackage) + ? { + kind: "direct", + executable: systemPackage.value.executable, + entry: systemPackage.value.serverEntry, + } + : Option.match(environment.appImagePath, { + onSome: (appImage) => ({ + kind: "appimage" as const, + appImage, + executableName: path.basename(process.execPath), + }), + onNone: () => ({ kind: "direct" as const, executable: process.execPath, entry }), + }); const content = renderCliShim({ target, shimPath, t3Home: environment.baseDir }); return yield* Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopLinuxPackage.ts b/apps/desktop/src/app/DesktopLinuxPackage.ts new file mode 100644 index 0000000000000..ec1ceaaaa4678 --- /dev/null +++ b/apps/desktop/src/app/DesktopLinuxPackage.ts @@ -0,0 +1,38 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; + +import type * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +// electron-builder writes resources/package-type into its .deb and .rpm; electron-updater reads +// the same marker to install updates with dpkg, or dnf/zypper. +const SYSTEM_PACKAGE_TYPES: ReadonlyArray = ["deb", "rpm"]; + +export const isSystemPackageType = (marker: string) => SYSTEM_PACKAGE_TYPES.includes(marker.trim()); + +// electron-builder installs the .deb and .rpm at /opt/; the packaged product name is +// the display name, so each channel has its own install. +export const systemPackagePaths = (displayName: string) => { + const root = `/opt/${displayName}`; + return { + marker: `${root}/resources/package-type`, + executable: `${root}/t3code`, + serverEntry: `${root}/resources/app.asar/apps/server/dist/bin.mjs`, + }; +}; + +/** + * The .deb or .rpm of this channel when an AppImage runs beside one. Shared launch integration + * points at the package then, so removing the AppImage leaves links and the `t3` command working. + */ +export const findSystemPackageBesideAppImage = Effect.fn( + "DesktopLinuxPackage.findSystemPackageBesideAppImage", +)(function* (environment: DesktopEnvironment.DesktopEnvironment["Service"]) { + if (environment.platform !== "linux" || Option.isNone(environment.appImagePath)) { + return Option.none>(); + } + const fileSystem = yield* FileSystem.FileSystem; + const paths = systemPackagePaths(environment.displayName); + const marker = yield* fileSystem.readFileString(paths.marker).pipe(Effect.option); + return Option.filter(marker, isSystemPackageType).pipe(Option.as(paths)); +}); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 9404b0ac3087c..35b0528efabc0 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -69,6 +69,7 @@ const layerHandler = ( readonly xdgMimeExitCode?: number; readonly writeError?: PlatformError.PlatformError; readonly existingEntry?: string; + readonly systemPackageType?: string; readonly iconSource?: string; readonly iconCopyError?: PlatformError.PlatformError; } = {}, @@ -98,7 +99,12 @@ const layerHandler = ( : Effect.sync(() => { recorded.copies.push({ source, destination }); }), - readFileString: () => Effect.succeed(input.existingEntry ?? ""), + readFileString: (path) => + Effect.succeed( + path === "/opt/T3 Code (Alpha)/resources/package-type" + ? (input.systemPackageType ?? "") + : (input.existingEntry ?? ""), + ), makeDirectory: (path) => Effect.sync(() => { recorded.directories.push(path); @@ -237,6 +243,23 @@ describe("DesktopLinuxUrlHandler", () => { }); }); + it.effect("hands links to an installed .deb or .rpm instead of the AppImage beside it", () => + Effect.gen(function* () { + for (const systemPackageType of ["deb\n", "rpm"]) { + const recorded = emptyRecording(); + yield* runRegister(recorded, { systemPackageType }); + assert.include(recorded.files[0]?.content, 'Exec="/opt/T3 Code (Alpha)/t3code" %U'); + } + + const unrelated = emptyRecording(); + yield* runRegister(unrelated, { systemPackageType: "pacman" }); + assert.include( + unrelated.files[0]?.content, + "Exec=/home/alice/Applications/T3-Code.AppImage %U", + ); + }), + ); + it.effect("launches a distribution package through its configured launcher", () => { const recorded = emptyRecording(); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index ebd7d71b25726..b8d28b073268b 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -10,6 +10,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as DesktopAssets from "./DesktopAssets.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopLinuxPackage from "./DesktopLinuxPackage.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; // AppImage integrators choose the .desktop filename, so setDesktopName cannot identify it for protocol registration. @@ -122,6 +123,7 @@ export const make = Effect.gen(function* () { const assets = yield* DesktopAssets.DesktopAssets; const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment); + const systemPackage = yield* DesktopLinuxPackage.findSystemPackageBesideAppImage(environment); const desktopEntryPath = environment.path.join( environment.linuxApplicationsDir, environment.linuxDesktopEntryName, @@ -137,6 +139,7 @@ export const make = Effect.gen(function* () { // Inside the mounted AppImage, process.execPath points at a transient // /tmp/.mount_* path — the handler must launch the AppImage itself. const execTarget = environment.desktopLauncherPath.pipe( + Option.orElse(() => Option.map(systemPackage, (installed) => installed.executable)), Option.orElse(() => environment.appImagePath), Option.getOrElse(() => process.execPath), ); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 8bef7366e378a..288c463627ce4 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -11,6 +11,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts"; import { resolveDesktopAppBranding } from "./DesktopEnvironment.ts"; +import { isSystemPackageType, systemPackagePaths } from "./DesktopLinuxPackage.ts"; import { renderUrlHandlerDesktopEntry } from "./DesktopLinuxUrlHandler.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; @@ -102,16 +103,28 @@ export const make = Effect.gen(function* () { ); } catch {} } + const { displayName } = resolveDesktopAppBranding({ + isDevelopment: linux.isDevelopment, + appVersion: Electron.app.getVersion(), + }); + // Same target as DesktopLinuxUrlHandler, which leaves an identical entry untouched. + const appImage = process.env.APPIMAGE?.trim(); + const systemPackage = systemPackagePaths(displayName); + let besideSystemPackage = false; + if (appImage) { + try { + besideSystemPackage = isSystemPackageType( + NodeFS.readFileSync(systemPackage.marker, "utf8"), + ); + } catch {} + } NodeFS.writeFileSync( NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName), renderUrlHandlerDesktopEntry({ - displayName: resolveDesktopAppBranding({ - isDevelopment: linux.isDevelopment, - appVersion: Electron.app.getVersion(), - }).displayName, + displayName, execTarget: process.env.T3CODE_DESKTOP_LAUNCHER_PATH?.trim() || - process.env.APPIMAGE?.trim() || + (besideSystemPackage ? systemPackage.executable : appImage) || process.execPath, scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), ...(iconPath === undefined ? {} : { iconPath }), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index c6bcd46b8c206..77844900df2bd 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -22,7 +22,7 @@ import * as DesktopUpdates from "./DesktopUpdates.ts"; import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; describe("DesktopUpdates", () => { - it.effect("updates Linux .deb installs and leaves other non-AppImage installs off", () => + it.effect("updates Linux .deb and .rpm installs and leaves other non-AppImage installs off", () => Effect.gen(function* () { const linuxState = (packageType: string | undefined) => Effect.scoped( @@ -36,6 +36,12 @@ describe("DesktopUpdates", () => { const deb = yield* linuxState("deb\n"); assert.equal(deb.status, "idle"); + const rpm = yield* linuxState("rpm"); + assert.equal(rpm.status, "idle"); + + const pacman = yield* linuxState("pacman"); + assert.equal(pacman.status, "disabled"); + const unmarked = yield* linuxState(undefined); assert.equal(unmarked.status, "disabled"); }), diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 3a0b41cd9e369..dc85208fa0414 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -28,6 +28,7 @@ import * as Stream from "effect/Stream"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopLinuxPackage from "../app/DesktopLinuxPackage.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; @@ -319,7 +320,7 @@ function getAutoUpdateDisabledReason(args: { isPackaged: boolean; platform: NodeJS.Platform; appImage?: string | undefined; - isDebPackage: boolean; + isSystemPackage: boolean; disabledByEnv: boolean; hasUpdateFeedConfig: boolean; }): string | null { @@ -332,8 +333,8 @@ function getAutoUpdateDisabledReason(args: { if (args.disabledByEnv) { return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting."; } - if (args.platform === "linux" && !args.appImage && !args.isDebPackage) { - return "Automatic updates on Linux require the AppImage or the .deb package."; + if (args.platform === "linux" && !args.appImage && !args.isSystemPackage) { + return "Automatic updates on Linux require the AppImage, .deb, or .rpm package."; } return null; } @@ -419,13 +420,12 @@ export const make = Effect.gen(function* () { ), ); - // electron-updater uses this marker to install .deb updates with dpkg. - const isDebPackage = + const isSystemPackage = environment.platform === "linux" && environment.isPackaged ? yield* fileSystem .readFileString(environment.path.join(environment.resourcesPath, "package-type")) .pipe( - Effect.map((packageType) => packageType.trim() === "deb"), + Effect.map(DesktopLinuxPackage.isSystemPackageType), Effect.orElseSucceed(() => false), ) : false; @@ -442,7 +442,7 @@ export const make = Effect.gen(function* () { isPackaged: environment.isPackaged, platform: environment.platform, appImage: Option.getOrUndefined(config.appImagePath), - isDebPackage, + isSystemPackage, disabledByEnv: config.disableAutoUpdate, hasUpdateFeedConfig: hasFeedConfig, }), diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro index 494ad60ef94e4..12fedba84f426 100644 --- a/apps/marketing/src/pages/download.astro +++ b/apps/marketing/src/pages/download.astro @@ -109,7 +109,9 @@ const imageProps = {

- On ARM? Download the arm64 .deb or + Fedora or openSUSE? Download the .rpm. + On ARM? Download the arm64 .deb, + .rpm, or AppImage.

diff --git a/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx index f746cff28c270..22e2f7708c06f 100644 --- a/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx +++ b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx @@ -74,7 +74,7 @@ function SwitchPlan({ const updateState = useDesktopUpdateState(); const { environments } = useEnvironments(); const crossesLocal = desktopUpdateChannelOf(APP_VERSION) !== release.channel; - // With its updater off (a .deb install, or no feed) this app stays put and only remotes move. + // With its updater off (an unsupported Linux install, or no feed) this app stays put and only remotes move. const updatesOff = updateState?.enabled === false; // After a partial switch this app may already be on the track; then only remotes move. const switchesLocal = crossesLocal && !updatesOff; diff --git a/docs/operations/development.md b/docs/operations/development.md index 0501250c29d16..8fb68866dc1da 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -235,25 +235,26 @@ options. ### Linux AppImage prerequisites Build on Linux because the browser-secret helper links against the host's libsecret. Install -Rust, C/C++ build tools, libsecret development headers, pkg-config, and ImageMagick. +Rust, C/C++ build tools, libsecret development headers, pkg-config, ImageMagick, and `rpmbuild` +for the `.rpm`. Ubuntu and Debian: ```sh sudo apt-get update -sudo apt-get install cargo rustc build-essential libsecret-1-dev pkg-config imagemagick +sudo apt-get install cargo rustc build-essential libsecret-1-dev pkg-config imagemagick rpm ``` Fedora: ```sh -sudo dnf install rust cargo gcc gcc-c++ make libsecret-devel pkgconf-pkg-config ImageMagick +sudo dnf install rust cargo gcc gcc-c++ make libsecret-devel pkgconf-pkg-config ImageMagick rpm-build ``` Arch Linux: ```sh -sudo pacman -S rust base-devel libsecret pkgconf imagemagick +sudo pacman -S rust base-devel libsecret pkgconf imagemagick rpm-tools ``` The C toolchain, pkg-config, and libsecret headers are also needed for Linux desktop development. diff --git a/docs/operations/release.md b/docs/operations/release.md index 18daadc5f2787..026cb4c9c2a6f 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -34,7 +34,7 @@ the version. - Builds six desktop artifacts in parallel for both channels, each as its own job (`desktop__`, one call of `release-desktop.yml`), gated only on the bundle. macOS and Linux targets build on hardware of their own architecture. Both Windows targets build and sign (with jsign) on a Linux runner, which packages faster than Windows runners, and a Windows job of the target's architecture (`windows-2025` or `windows-11-arm`, the `verify_runner`) runs the packaged natives and the CLI archive. The Windows jobs embed the same-arch Linux CLI archive as the WSL runtime and wait for that artifact partway through, not for the whole Linux job: - macOS `arm64` DMG - macOS `x64` DMG - - Linux `x64` and `arm64` AppImage and `.deb`, from one electron-builder run. The `.deb` updates in the app through electron-updater, which installs it with `dpkg`. + - Linux `x64` and `arm64` AppImage, `.deb`, and `.rpm`, from one electron-builder run. The packages update in the app through electron-updater, which installs the `.deb` with `dpkg` and the `.rpm` with `dnf` or `zypper`. The `.rpm` ships its own after-remove script because rpm runs the old package's `%postun` after the new `%post` during an upgrade. - Windows `x64` and `arm64` NSIS installer - Opens the packaged Linux x64 AppImage (under Xvfb) and macOS arm64 DMG against a seeded database and opens a thread (`scripts/smoke-desktop-app.ts`). A build whose backend or bundle cannot start fails here, so nothing is published. The database in `scripts/fixtures/desktop-launch` is an old snapshot on purpose: the packaged server must migrate it forward, as it does for upgrading users. Do not regenerate it on schema changes. - Publishes one GitHub Release with all produced files. @@ -474,7 +474,7 @@ available. - `T3CODE_DESKTOP_UPDATE_REPOSITORY` (format `owner/repo`), if set. - otherwise `GITHUB_REPOSITORY` from GitHub Actions. - Required release assets for updater: - - platform installers (`.exe`, `.dmg`, `.AppImage`, `.deb`, plus macOS `.zip` for Squirrel.Mac update payloads) + - platform installers (`.exe`, `.dmg`, `.AppImage`, `.deb`, `.rpm`, plus macOS `.zip` for Squirrel.Mac update payloads) - channel metadata: `latest*.yml` for stable releases, `nightly*.yml` for nightly releases - `*.blockmap` files (used for differential downloads) - macOS metadata note: diff --git a/docs/user/install.md b/docs/user/install.md index 1e1693d0b32ee..be781ba37af6c 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -70,13 +70,20 @@ or use a package manager: | Windows | `winget install T3Tools.T3Code` | | macOS | `brew install --cask t3-code` | | Debian, Ubuntu | `sudo apt install ./T3-Code-*.deb` | +| Fedora | `sudo dnf install ./T3-Code-*.rpm` | +| openSUSE | `sudo zypper install ./T3-Code-*.rpm` | | Arch Linux | `yay -S t3code-bin` | | Arch Linux nightly | `yay -S t3code-nightly-bin` | | Nix | `nix profile add github:NixOS/nixpkgs/nixos-unstable#t3code` | -The `.deb` updates itself like the other desktop builds. It asks for your -password to install each update. If your desktop has no password prompt, the -update fails. Download the new `.deb` and install it the same way. +The `.deb` and `.rpm` update themselves like the other desktop builds. They ask +for your password to install each update. If your desktop has no password +prompt, the update fails. Download the new package and install it the same way. + +You can install the `.deb` or `.rpm` over an AppImage you already use. Both +share your threads and settings. Once the package is installed, links and the +`t3` command open the package, even when you start the AppImage, so you can +delete the AppImage whenever you like. ### Nix (nixpkgs) diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index a4cef9ba03b8d..b67bc19a22c52 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -1,4 +1,5 @@ -// @effect-diagnostics nodeBuiltinImport:off - Tests use Node's glob matcher to verify electron-builder exclusions. +// @effect-diagnostics nodeBuiltinImport:off - Tests use Node's glob matcher to verify electron-builder exclusions and run package scripts in a real shell. +import * as NodeChildProcess from "node:child_process"; import * as NodeCrypto from "node:crypto"; import * as NodePath from "node:path"; @@ -41,6 +42,7 @@ import { resolveMacPasskeySigningConfiguration, resolveMergedStageDependencies, resolveBuildOptions, + RPM_AFTER_REMOVE_SCRIPT, stageWslRuntimeArchive, ancestorNodeModulesPaths, copyDirectoryPreservingSymlinks, @@ -1090,6 +1092,44 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ); }); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "keeps the t3code link when an rpm upgrade runs the old package's %postun", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-rpm-postun-" }); + const template = yield* fs.readFileString( + path.join(import.meta.dirname, "..", RPM_AFTER_REMOVE_SCRIPT), + ); + const script = path.join(root, "postun.sh"); + yield* fs.writeFileString( + script, + template + .replaceAll("${executable}", "t3code") + .replaceAll("${sanitizedProductName}", "T3 Code (Beta)"), + ); + const calls = path.join(root, "calls"); + yield* fs.makeDirectory(path.join(root, "bin")); + yield* fs.writeFileString( + path.join(root, "bin", "update-alternatives"), + `#!/bin/sh\necho "$*" >> '${calls}'\n`, + { mode: 0o755 }, + ); + const postun = (installsLeft: string) => { + NodeChildProcess.spawnSync("bash", [script, installsLeft], { + env: { PATH: `${path.join(root, "bin")}:${process.env.PATH}` }, + }); + return fs.readFileString(calls).pipe(Effect.orElseSucceed(() => "")); + }; + + assert.equal(yield* postun("1"), ""); + assert.equal(yield* postun("0"), "--remove t3code /opt/T3 Code (Beta)/t3code\n"); + }), + ), + ); + it.effect("refuses a Linux build on a host that cannot build the browser secret helper", () => { const commands: Array<{ readonly command: string }> = []; const spawnerLayer = Layer.succeed( diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 3a8686f248f0a..d147ef05e0ac2 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -348,8 +348,15 @@ export const LINUX_DESKTOP_BUILD_PREREQUISITES = [ packages: ["libsecret-1-dev", "pkg-config"], }, { id: "imagemagick", description: "ImageMagick", packages: ["imagemagick"] }, + { id: "rpmbuild", description: "rpmbuild, for the .rpm package", packages: ["rpm"] }, ] as const; +// One electron-builder run packs the AppImage, .deb and .rpm from the same unpacked app. +const linuxPackageTargets = (target: string): ReadonlyArray => + target === "AppImage" ? [target, "deb", "rpm"] : [target]; + +export const RPM_AFTER_REMOVE_SCRIPT = "apps/desktop/resources/linux/rpm-after-remove.sh"; + export class LinuxDesktopBuildPrerequisitesMissingError extends Schema.TaggedError()( "LinuxDesktopBuildPrerequisitesMissingError", { @@ -1912,6 +1919,7 @@ const rustTargetIsInstalled = Effect.fn("rustTargetIsInstalled")(function* (targ export const preflightLinuxDesktopBuild = Effect.fn("preflightLinuxDesktopBuild")(function* ( arch: typeof BuildArch.Type = "x64", + target = "AppImage", ) { const reuseResourceMonitor = yield* Config.Boolean("T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR").pipe( Config.withDefault(false), @@ -1938,6 +1946,9 @@ export const preflightLinuxDesktopBuild = Effect.fn("preflightLinuxDesktopBuild" desktopBuildProbeSucceeds(ChildProcess.make("magick", ["-version"]), "magick"), desktopBuildProbeSucceeds(ChildProcess.make("convert", ["-version"]), "convert"), ]).pipe(Effect.map(([magick, convert]) => magick || convert)), + rpmbuild: linuxPackageTargets(target).includes("rpm") + ? desktopBuildProbeSucceeds(ChildProcess.make("rpmbuild", ["--version"]), "rpmbuild") + : Effect.succeed(true), }, { concurrency: "unbounded" }, ); @@ -2890,7 +2901,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( const path = yield* Path.Path; const repoRoot = yield* RepoRoot; buildConfig.linux = { - target: target === "AppImage" ? [target, "deb"] : [target], + target: linuxPackageTargets(target), executableName: "t3code", icon: "icons", category: "Development", @@ -2929,6 +2940,28 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( "xdg-utils", ], }; + buildConfig.rpm = { + fpm: [ + `${path.join(repoRoot, "apps/desktop/resources/linux/com.t3tools.t3code.metainfo.xml")}=/usr/share/metainfo/com.t3tools.t3code.metainfo.xml`, + `${path.join(repoRoot, "LICENSE")}=/usr/share/licenses/t3code/LICENSE`, + ], + // Fedora names first, openSUSE names as rich-dependency fallbacks. + depends: [ + "(alsa-lib or libasound2)", + "at-spi2-core", + "gtk3", + "(libsecret or libsecret-1-0)", + "libnotify", + "(libuuid or libuuid1)", + "libXScrnSaver", + "(libXtst or libXtst6)", + "(mesa-libgbm or libgbm1)", + "nss", + "xdg-utils", + ], + // The stock script would delete /usr/bin/t3code on every rpm upgrade; see the script. + afterRemove: path.join(repoRoot, RPM_AFTER_REMOVE_SCRIPT), + }; } if (platform === "win") { @@ -3565,7 +3598,7 @@ const buildDesktopArtifactUnlocked = Effect.fn("buildDesktopArtifactUnlocked")(f const fs = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcess.Platform; if (hostPlatform === "linux" && options.platform === "linux") { - yield* preflightLinuxDesktopBuild(options.arch); + yield* preflightLinuxDesktopBuild(options.arch, options.target); } if (hostPlatform === "darwin" && options.platform === "mac") { yield* preflightMacDesktopBuild(options.arch); From 464dbda8fdad86dba5957699d93c54b9670636af Mon Sep 17 00:00:00 2001 From: riyadmondol2006 Date: Fri, 30 Oct 2026 20:39:57 +0600 Subject: [PATCH 2/3] feat: import an exported CLI transcript into a project A conversation saved with the coding CLI's /export command could not be continued in T3 Code. A project's sidebar menu and the command palette now offer an import that takes the .txt file, picked or dropped, and opens it as a new thread in that project. The server parses the transcript without a model: it keeps prompts and replies verbatim, drops tool calls, tool output, thinking and local commands, and keeps the first prompt plus the newest messages, 200 in total, as the existing conversation import does. The thread has no native session, so the next message hands the history over like a migrated thread. The file's text crosses the wire once, so it works over remote connections. The RPC is guarded by the orchestration operate scope. --- .../src/observability/RpcInstrumentation.ts | 1 + .../src/project/AgentSessionImporter.test.ts | 35 +++- .../src/project/AgentSessionImporter.ts | 94 ++++++++++- .../server/src/project/AgentSessionScanner.ts | 2 +- .../project/ClaudeExportTranscript.test.ts | 85 ++++++++++ .../src/project/ClaudeExportTranscript.ts | 82 +++++++++ apps/server/src/telemetry/FeatureUsage.ts | 1 + apps/server/src/ws.ts | 2 + apps/web/src/components/CommandPalette.tsx | 35 ++++ .../components/CommandPaletteProjectItems.tsx | 5 +- apps/web/src/components/Sidebar.tsx | 11 ++ .../src/components/TranscriptImportDialog.tsx | 158 ++++++++++++++++++ apps/web/src/routes/__root.tsx | 3 + apps/web/src/state/agentSessions.ts | 5 + docs/user/thread-sidebar.md | 12 ++ .../src/state/commandPermissions.test.ts | 6 +- packages/contracts/src/agentSessions.ts | 35 +++- .../contracts/src/clientRpcPermissions.ts | 1 + packages/contracts/src/rpc.ts | 16 ++ 19 files changed, 581 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/project/ClaudeExportTranscript.test.ts create mode 100644 apps/server/src/project/ClaudeExportTranscript.ts create mode 100644 apps/web/src/components/TranscriptImportDialog.tsx diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index cb8b0b6a86cf3..2a497521a897d 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -157,6 +157,7 @@ const RPC_AGGREGATES = { [WS_METHODS.filesystemGetMetadata]: "workspace", [WS_METHODS.agentSessionsScan]: "workspace", [WS_METHODS.agentSessionsImport]: "workspace", + [WS_METHODS.agentSessionsImportTranscript]: "orchestration", [WS_METHODS.assetsCreateUrl]: "workspace", [WS_METHODS.assetsPersistChatAttachments]: "orchestration", [WS_METHODS.attachmentsCreateUploadUrl]: "workspace", diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 69026f91202af..20b05b2ab5765 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -235,9 +235,42 @@ const makeImporter = Effect.fn(function* ( ), ); const importer = Context.get(context, AgentSessionImporter.AgentSessionImporter); - return { run: importer.importRecentAgentThreads({ projectId }), published }; + return { run: importer.importRecentAgentThreads({ projectId }), importer, published }; }); +it.effect("imports an exported transcript as a thread without a native session", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const { importer } = yield* makeImporter([]); + + const result = yield* importer.importClaudeExport({ + projectId, + contents: "> Fix it\n\n⏺ Bash(vp test)\n ⎿ 1 failed\n\n⏺ Fixed\n", + }); + + expect(result.messageCount).toBe(2); + const records = yield* projections.getThreadRecords(result.threadId, [ + "messages", + "providerThreads", + ]); + expect(records.messages.map((message) => [message.role, message.text])).toEqual([ + ["user", "Fix it"], + ["assistant", "Fixed"], + ]); + expect(records.thread).toMatchObject({ + projectId, + title: "Fix it", + historyOrigin: "v1_import", + activeProviderThreadId: null, + }); + expect(records.providerThreads).toEqual([]); + const unreadable = yield* importer + .importClaudeExport({ projectId, contents: "not an export" }) + .pipe(Effect.flip); + expect(unreadable._tag).toBe("AgentSessionTranscriptUnreadableError"); + }).pipe(Effect.scoped, Effect.provide(persistence)), +); + it.effect( "ignores owned sessions, including archived and deleted threads, but imports other instances and unowned sessions", () => diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 11f168ff1d7be..e0f90f44c819a 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -8,6 +8,8 @@ import { AgentSessionImportSource, AgentSessionScanError, AgentSessionSource, + AgentSessionTranscriptUnreadableError, + defaultInstanceIdForDriver, EventId, MessageId, ProjectId, @@ -16,6 +18,8 @@ import { TurnItemId, type AgentSessionImportInput, type AgentSessionImportResult, + type AgentSessionTranscriptImportInput, + type AgentSessionTranscriptImportResult, type OrchestrationV2AppThread, type OrchestrationV2ConversationMessage, type OrchestrationV2DomainEvent, @@ -36,6 +40,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import * as AgentSessionScanner from "./AgentSessionScanner.ts"; +import { parseClaudeExportTranscript } from "./ClaudeExportTranscript.ts"; import * as ProjectService from "./ProjectService.ts"; const IMPORT_EVENT_PREFIX = "agent-session-import:v2"; @@ -401,7 +406,94 @@ const make = Effect.gen(function* () { return { importedCount, skippedCount } satisfies AgentSessionImportResult; }); - return { importRecentAgentThreads }; + // An exported transcript has no resumable session, so the thread starts without a + // provider thread and its first new message hands the imported history over. + const importClaudeExport = Effect.fn("AgentSessionImporter.importClaudeExport")(function* ( + input: AgentSessionTranscriptImportInput, + ) { + const project = yield* projects.getById(input.projectId).pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(new AgentSessionImportProjectNotFoundError({ projectId: input.projectId })), + onSome: Effect.succeed, + }), + ), + ); + const transcript = parseClaudeExportTranscript(input.contents); + if (transcript === null) return yield* new AgentSessionTranscriptUnreadableError(); + const threadId = yield* idAllocator.allocate + .thread({ projectId: project.id }) + .pipe( + Effect.mapError( + (cause) => new AgentSessionScanError({ operation: "import-transcript", cause }), + ), + ); + const driver = ProviderDriverKind.make("claudeAgent"); + const instanceId = defaultInstanceIdForDriver(driver); + const now = yield* DateTime.now; + const createdAt = DateTime.formatIso(now); + const appThread: OrchestrationV2AppThread = { + createdBy: "user", + creationSource: "server", + id: threadId, + projectId: project.id, + title: transcript.title, + providerInstanceId: instanceId, + modelSelection: { instanceId, model: DEFAULT_MODEL_BY_PROVIDER[driver] ?? DEFAULT_MODEL }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + linkedPullRequest: null, + branchPullRequest: null, + activeProviderThreadId: null, + historyOrigin: "v1_import", + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: "settled", + settledAt: now, + unsettledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + pinOrderKey: null, + activeOrderKey: null, + lastVisitedAt: null, + deletedAt: null, + }; + yield* eventSink + .write({ + events: [ + { + id: EventId.make(`${IMPORT_EVENT_PREFIX}:thread:${threadId}:created`), + type: "thread.created", + threadId, + providerInstanceId: instanceId, + occurredAt: now, + payload: appThread, + }, + ...transcript.messages.flatMap((message, index) => + messageEvents({ threadId, index, message: { ...message, createdAt } }), + ), + ], + }) + .pipe( + Effect.mapError( + (cause) => new AgentSessionScanError({ operation: "import-transcript", cause }), + ), + ); + return { + threadId, + messageCount: transcript.messages.length, + } satisfies AgentSessionTranscriptImportResult; + }); + + return { importRecentAgentThreads, importClaudeExport }; }); type AgentSessionImporterShape = Effect.Success; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 7ec79c38a38a9..05b50809bf525 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -55,7 +55,7 @@ const MAX_METADATA_RECORDS_PER_TRANSCRIPT = 1_000; const RECENT_THREAD_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; // Screenshot-heavy transcripts can reach GiB; select fields while streaming to avoid allocating payloads. const MAX_IMPORTED_TRANSCRIPT_BYTES = 4 * 1024 * 1024 * 1024; -const MAX_IMPORTED_MESSAGES = 200; +export const MAX_IMPORTED_MESSAGES = 200; const MAX_IMPORT_HISTORY_BYTES = 32 * 1024 * 1024; const MAX_IMPORT_BYTES = 4 * 1024 * 1024 * 1024; const MAX_IMPORT_TRANSCRIPTS = 100; diff --git a/apps/server/src/project/ClaudeExportTranscript.test.ts b/apps/server/src/project/ClaudeExportTranscript.test.ts new file mode 100644 index 0000000000000..7d50d83ba500e --- /dev/null +++ b/apps/server/src/project/ClaudeExportTranscript.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { MAX_IMPORTED_MESSAGES } from "./AgentSessionScanner.ts"; +import { parseClaudeExportTranscript } from "./ClaudeExportTranscript.ts"; + +const EXPORT = [ + "╭───────────────────────────────────────────────────╮", + "│ ✻ Welcome to Claude Code! │", + "│ cwd: /home/alice/project │", + "╰───────────────────────────────────────────────────╯", + "", + "> /model ", + " ⎿ Set model to Opus", + "", + "> Why do the tests fail?", + " Only on CI.", + "", + "✻ Thinking…", + "", + " The user wants the CI failure explained.", + "", + "⏺ I'll look at the failing test first.", + "", + "⏺ Bash(vp test run apps/server/src/foo.test.ts", + " --reporter verbose)", + " ⎿ FAIL foo.test.ts", + " Expected 1, received 2", + "", + "⏺ The fixture depends on the timezone.", + "", + " - CI runs in UTC", + " - Your machine does not", + "", + "═══════════════ Conversation compacted · ctrl+o for history ═══════════════", + " ⎿ Read apps/server/src/foo.test.ts (40 lines)", + "", + "> Fix it", + " ⎿ UserPromptSubmit hook succeeded", + "", + "● Pinned the clock to UTC.", + "", +].join("\r\n"); + +describe("parseClaudeExportTranscript", () => { + it("keeps prompts and answers and drops tool calls, thinking, and local commands", () => { + expect(parseClaudeExportTranscript(EXPORT)).toEqual({ + title: "Why do the tests fail?", + messages: [ + { role: "user", text: "Why do the tests fail?\nOnly on CI." }, + { + role: "assistant", + text: [ + "I'll look at the failing test first.", + "", + "The fixture depends on the timezone.", + "", + "- CI runs in UTC", + "- Your machine does not", + ].join("\n"), + }, + { role: "user", text: "Fix it" }, + { role: "assistant", text: "Pinned the clock to UTC." }, + ], + }); + }); + + it("rejects files without a prompt", () => { + expect(parseClaudeExportTranscript("")).toBeNull(); + expect(parseClaudeExportTranscript("⏺ Only an answer\n")).toBeNull(); + }); + + it("keeps the first prompt and the newest messages", () => { + const turns = Array.from( + { length: MAX_IMPORTED_MESSAGES }, + (_, index) => `> prompt ${index}\n\n⏺ answer ${index}\n`, + ); + const messages = parseClaudeExportTranscript(turns.join("\n"))?.messages ?? []; + expect(messages).toHaveLength(MAX_IMPORTED_MESSAGES); + expect(messages[0]).toEqual({ role: "user", text: "prompt 0" }); + expect(messages.at(-1)).toEqual({ + role: "assistant", + text: `answer ${MAX_IMPORTED_MESSAGES - 1}`, + }); + }); +}); diff --git a/apps/server/src/project/ClaudeExportTranscript.ts b/apps/server/src/project/ClaudeExportTranscript.ts new file mode 100644 index 0000000000000..864841a35ee9b --- /dev/null +++ b/apps/server/src/project/ClaudeExportTranscript.ts @@ -0,0 +1,82 @@ +import { MAX_IMPORTED_MESSAGES } from "./AgentSessionScanner.ts"; + +export interface ClaudeExportMessage { + readonly role: "user" | "assistant"; + readonly text: string; +} + +export interface ClaudeExportTranscript { + readonly title: string; + readonly messages: ReadonlyArray; +} + +interface Block { + readonly role: "user" | "assistant" | null; + readonly lines: Array; + hasOutput: boolean; +} + +// `/export` writes the rendered terminal transcript: `> ` opens a prompt, `⏺ ` (`● ` on +// Windows) opens assistant text or a tool call, `⎿` lines hold tool and command output, +// and continuation lines are indented two spaces. +const USER_PREFIX = /^> ?/; +const ASSISTANT_PREFIX = /^[⏺●] ?/; +const OUTPUT_LINE = /^\s*⎿/; +// Thinking, compaction separators, and the welcome banner carry no conversation text. +const IGNORED_LINE = /^[✻═╭│╰]/; +// Local commands (`/model`, `! ls`, `# memory`) echo output under the prompt instead of an answer. +const LOCAL_COMMAND = /^[/!#]/; + +/** Parses a Claude Code `/export` text file into its visible user and assistant messages. */ +export function parseClaudeExportTranscript(contents: string): ClaudeExportTranscript | null { + const blocks: Array = []; + let current: Block | undefined; + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trimEnd(); + if (USER_PREFIX.test(line) || ASSISTANT_PREFIX.test(line)) { + const user = USER_PREFIX.test(line); + current = { + role: user ? "user" : "assistant", + lines: [line.replace(user ? USER_PREFIX : ASSISTANT_PREFIX, "")], + hasOutput: false, + }; + blocks.push(current); + } else if (OUTPUT_LINE.test(line)) { + if (current) current.hasOutput = true; + // Output runs until the next marker. + current = undefined; + } else if (IGNORED_LINE.test(line)) { + current = { role: null, lines: [], hasOutput: false }; + } else if (current) { + current.lines.push(line.startsWith(" ") ? line.slice(2) : line); + } + } + + const messages: Array = []; + for (const block of blocks) { + const text = block.lines.join("\n").trim(); + if (block.role === null || text === "") continue; + if (block.role === "user") { + if (!(block.hasOutput && LOCAL_COMMAND.test(text))) messages.push({ role: "user", text }); + continue; + } + // An assistant block with output is a tool call. + if (block.hasOutput) continue; + const previous = messages.at(-1); + if (previous?.role === "assistant") { + messages[messages.length - 1] = { role: "assistant", text: `${previous.text}\n\n${text}` }; + } else { + messages.push({ role: "assistant", text }); + } + } + + const firstUser = messages.find((message) => message.role === "user"); + if (firstUser === undefined) return null; + const newest = messages.slice(-MAX_IMPORTED_MESSAGES); + return { + title: firstUser.text.split("\n")[0]!.slice(0, 100).trim() || "Imported thread", + messages: newest.includes(firstUser) + ? newest + : [firstUser, ...messages.slice(-(MAX_IMPORTED_MESSAGES - 1))], + }; +} diff --git a/apps/server/src/telemetry/FeatureUsage.ts b/apps/server/src/telemetry/FeatureUsage.ts index 61fb57ada454e..aa10b2b7ddea5 100644 --- a/apps/server/src/telemetry/FeatureUsage.ts +++ b/apps/server/src/telemetry/FeatureUsage.ts @@ -68,6 +68,7 @@ const METHOD_FEATURES: Readonly FeatureUsag [WS_METHODS.scheduledTasksUpsert]: () => ({ feature: "scheduled_task.save" }), [WS_METHODS.scheduledTasksRunNow]: () => ({ feature: "scheduled_task.run_now" }), [WS_METHODS.agentSessionsImport]: () => ({ feature: "agent_session.import" }), + [WS_METHODS.agentSessionsImportTranscript]: () => ({ feature: "agent_session.import_transcript" }), [WS_METHODS.projectCloneStart]: () => ({ feature: "project.clone" }), [WS_METHODS.sourceControlPublishRepository]: () => ({ feature: "repository.publish" }), [WS_METHODS.sourceControlSignInStart]: () => ({ feature: "source_control.sign_in" }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index befad768da3e5..4ef4b8dc12745 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2846,6 +2846,8 @@ export const layerWsRpc = ( [WS_METHODS.agentSessionsScan]: () => agentSessionScanner.scan, [WS_METHODS.agentSessionsImport]: (input) => agentSessionImporter.importRecentAgentThreads(input), + [WS_METHODS.agentSessionsImportTranscript]: (input) => + agentSessionImporter.importClaudeExport(input), [WS_METHODS.assetsCreateUrl]: /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* (input) { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 50c3452404344..a3f38dbfa134e 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -58,6 +58,7 @@ import { ChevronRightIcon, CornerLeftUpIcon, FileSearchIcon, + FileTextIcon, FolderGit2Icon, FolderIcon, FolderPlusIcon, @@ -73,6 +74,7 @@ import { UserPlusIcon, } from "lucide-react"; import { openThreadFind } from "./chat/threadFindStore"; +import { requestTranscriptImport } from "./TranscriptImportDialog"; import { useDeferredValue, useEffect, @@ -244,6 +246,7 @@ import { threadRecentUse } from "@t3tools/shared/paletteSearch"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; const ADD_PROJECT_ENTRY_GROUP = "add-project"; const NEW_THREAD_IN_GROUP = "projects"; +const IMPORT_TRANSCRIPT_GROUP = "import-transcript-projects"; /** The submenu an open intent starts in; its groups resolve while rendering. */ function openIntentView(intent: CommandPaletteOpenIntent | null): CommandPaletteView | null { @@ -801,6 +804,24 @@ function OpenCommandPaletteDialog(props: { ]; })(); + const importTranscriptItems = enumerateCommandPaletteItems( + buildNewThreadInProjectItems({ + projects: pickerProjects.filter((project) => + readEnvironmentScope(project.environmentId, AuthOrchestrationOperateScope), + ), + projectGroupByTargetKey, + locationByEnvironmentId: projectEnvironmentLocationById, + valuePrefix: "import-transcript-in", + runProject: async (project) => { + requestTranscriptImport({ + environmentId: project.environmentId, + projectId: project.id, + projectName: project.displayName, + }); + }, + }), + ); + const { threadItems: allThreadItems, threadMessageItems, @@ -1190,6 +1211,20 @@ function OpenCommandPaletteDialog(props: { addonIcon: , groups: [{ value: NEW_THREAD_IN_GROUP, label: "Projects", items: projectThreadItems }], }); + + if (importTranscriptItems.length > 0) { + actionItems.push({ + kind: "submenu", + value: "action:import-transcript-in", + searchTerms: ["import", "claude code", "export", "conversation", "transcript", "txt"], + title: "Import Claude Code conversation...", + icon: , + addonIcon: , + groups: [ + { value: IMPORT_TRANSCRIPT_GROUP, label: "Projects", items: importTranscriptItems }, + ], + }); + } } if (scratchTargetEnvironmentId !== null) { diff --git a/apps/web/src/components/CommandPaletteProjectItems.tsx b/apps/web/src/components/CommandPaletteProjectItems.tsx index 90373413be4fa..6e429ac1a1369 100644 --- a/apps/web/src/components/CommandPaletteProjectItems.tsx +++ b/apps/web/src/components/CommandPaletteProjectItems.tsx @@ -29,6 +29,7 @@ interface ProjectItemsInput { readonly projectGroupByTargetKey: ReadonlyMap; readonly locationByEnvironmentId: ReadonlyMap; readonly runProject: (project: CommandPaletteProject) => Promise; + readonly valuePrefix?: string; } function projectFaviconIcon(project: Project) { @@ -69,11 +70,11 @@ export function buildProjectSearchItems(input: ProjectItemsInput) { }); } -/** Projects as targets of the "New thread in..." submenu. */ +/** Projects as targets of a submenu such as "New thread in...". */ export function buildNewThreadInProjectItems(input: ProjectItemsInput) { return buildProjectActionItems({ projects: input.projects, - valuePrefix: "new-thread-in", + valuePrefix: input.valuePrefix ?? "new-thread-in", searchTerms: (project) => { const group = input.projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); const location = input.locationByEnvironmentId.get(project.environmentId); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index de16be6d2dcfb..3e19f4f6f5c6d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -9,6 +9,7 @@ import { } from "./chat/threadContextDrag"; import { discardComposerDraft } from "../lib/discardComposerDraft"; import { requestCustomSnooze } from "./CustomSnoozeDialog"; +import { requestTranscriptImport } from "./TranscriptImportDialog"; import { useAtomValue } from "@effect/atom-react"; import * as Option from "effect/Option"; import { Atom } from "effect/reactivity"; @@ -1215,6 +1216,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve api.contextMenu.show( [ { id: "new-thread", label: "New thread" }, + ...(readEnvironmentScope(group.environmentId, AuthOrchestrationOperateScope) + ? [{ id: "import-transcript", label: "Import Claude Code conversation…" }] + : []), { id: "copy-path", label: "Copy path" }, { id: "project-settings", label: "Project settings", icon: "settings" }, ], @@ -1226,6 +1230,13 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve case "new-thread": startThreadInProjectFolder(group); return; + case "import-transcript": + requestTranscriptImport({ + environmentId: group.environmentId, + projectId: group.id, + projectName: group.displayName, + }); + return; case "copy-path": copyPathToClipboard(group.workspaceRoot, { path: group.workspaceRoot }); return; diff --git a/apps/web/src/components/TranscriptImportDialog.tsx b/apps/web/src/components/TranscriptImportDialog.tsx new file mode 100644 index 0000000000000..5de1a68e30861 --- /dev/null +++ b/apps/web/src/components/TranscriptImportDialog.tsx @@ -0,0 +1,158 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + AGENT_SESSION_TRANSCRIPT_MAX_CHARS, + type EnvironmentId, + type ProjectId, +} from "@t3tools/contracts"; +import { useNavigate } from "@tanstack/react-router"; +import { FileTextIcon } from "lucide-react"; +import { useRef, useState, type DragEvent } from "react"; +import { create } from "zustand"; + +import { agentSessionTranscriptImport } from "../state/agentSessions"; +import { useAtomCommand } from "../state/use-atom-command"; +import { buildThreadRouteParams } from "../threadRoutes"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { toastManager } from "./ui/toast"; + +interface TranscriptImportTarget { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly projectName: string; +} + +const useTarget = create<{ target: TranscriptImportTarget | null }>(() => ({ target: null })); + +export function requestTranscriptImport(target: TranscriptImportTarget) { + useTarget.setState({ target }); +} + +export function TranscriptImportDialogHost() { + const target = useTarget((state) => state.target); + return target ? : null; +} + +function TranscriptImportDialog({ target }: { readonly target: TranscriptImportTarget }) { + const navigate = useNavigate(); + const fileInputRef = useRef(null); + const [importing, setImporting] = useState(false); + const [dropTarget, setDropTarget] = useState(false); + const [error, setError] = useState(null); + const importTranscript = useAtomCommand(agentSessionTranscriptImport, { reportFailure: false }); + + const importFile = async (file: File | undefined) => { + if (file === undefined || importing) return; + // UTF-8 never uses fewer bytes than characters, so this bounds the decoded text too. + if (file.size > AGENT_SESSION_TRANSCRIPT_MAX_CHARS) { + setError("This file is larger than 8 MiB."); + return; + } + setError(null); + setImporting(true); + const result = await importTranscript({ + environmentId: target.environmentId, + input: { projectId: target.projectId, contents: await file.text() }, + }); + setImporting(false); + if (result._tag === "Success") { + toastManager.add({ + type: "success", + title: "Conversation imported", + description: `${result.value.messageCount} messages in ${target.projectName}.`, + }); + if (useTarget.getState().target !== target) return; + useTarget.setState({ target: null }); + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(target.environmentId, result.value.threadId)), + }); + return; + } + if (isAtomCommandInterrupted(result)) return; + const failure = squashAtomCommandFailure(result); + setError(failure instanceof Error ? failure.message : "The import failed."); + }; + + const onDrop = (event: DragEvent) => { + event.preventDefault(); + setDropTarget(false); + void importFile(event.dataTransfer.files[0]); + }; + + return ( + { + if (!open) useTarget.setState({ target: null }); + }} + > + + + Import Claude Code conversation + + Choose a .txt file saved with /export. It opens as a new thread in{" "} + {target.projectName}, and your next message gives the agent its history. + + + +
{ + event.preventDefault(); + setDropTarget(true); + }} + onDragOver={(event) => { + event.preventDefault(); + setDropTarget(true); + }} + onDragLeave={(event) => { + if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; + setDropTarget(false); + }} + onDrop={onDrop} + > + +

Drop the exported file here, or

+ { + void importFile(event.currentTarget.files?.[0]); + event.currentTarget.value = ""; + }} + /> + +
+ {error === null ? null :

{error}

} +
+ + + +
+
+ ); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 1207b5017100d..81c13dd2de685 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -17,6 +17,7 @@ import { APP_DISPLAY_NAME, APP_VERSION } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog"; +import { TranscriptImportDialogHost } from "../components/TranscriptImportDialog"; import { GuestShareDialogHost } from "../components/chat/GuestShareControls"; import { AddProjectToMachineDialogHost } from "../components/AddProjectToMachineDialog"; import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; @@ -168,6 +169,7 @@ function RootRouteBranch() { return ( <> + @@ -209,6 +211,7 @@ function RootRouteBranch() { + diff --git a/apps/web/src/state/agentSessions.ts b/apps/web/src/state/agentSessions.ts index 58670d4d6c8f0..15749ba3c3b99 100644 --- a/apps/web/src/state/agentSessions.ts +++ b/apps/web/src/state/agentSessions.ts @@ -17,3 +17,8 @@ export const agentSessionImport = createEnvironmentRpcCommand(connectionAtomRunt label: "environment-data:agent-sessions:import", tag: WS_METHODS.agentSessionsImport, }); + +export const agentSessionTranscriptImport = createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "environment-data:agent-sessions:import-transcript", + tag: WS_METHODS.agentSessionsImportTranscript, +}); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index fb5e12b08372b..db7cb6a3864f4 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -55,6 +55,18 @@ in a new thread's model picker to add or remove them. A regular click returns to single model. Choose a base branch and send. Each selection starts a separate thread and worktree while you stay in the new thread composer. This requires a Git project. +### Continue a Claude Code conversation + +Save the conversation with `/export` in Claude Code, then right-click a project in +the sidebar, or open the command palette, and choose **Import Claude Code +conversation**. Pick or drop the `.txt` file. It opens as a new thread in that +project with your prompts and Claude's replies, up to 200 messages. Tool output and +thinking are left out. + +Your next message starts a fresh session and gives the agent the imported history, +as a [portable handoff](./portable-handoffs.md) does. Files up to 8 MiB can be +imported, from web and desktop. + ## Pin and reorder threads Pin a thread from its menu to keep it above your active work. diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index adfad46a41b89..a5c0dd07e87b9 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -143,7 +143,11 @@ describe("command permissions", () => { ), ); - it.effect.each([WS_METHODS.scheduledTasksDelete, ORCHESTRATION_V2_WS_METHODS.stopWorkflow])( + it.effect.each([ + WS_METHODS.scheduledTasksDelete, + WS_METHODS.agentSessionsImportTranscript, + ORCHESTRATION_V2_WS_METHODS.stopWorkflow, + ])( "uses the target grant for both availability and execution of %s", (method) => Effect.scoped( diff --git a/packages/contracts/src/agentSessions.ts b/packages/contracts/src/agentSessions.ts index a60d702d9a0e3..4fbf80b84529e 100644 --- a/packages/contracts/src/agentSessions.ts +++ b/packages/contracts/src/agentSessions.ts @@ -1,5 +1,11 @@ import * as Schema from "effect/Schema"; -import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + IsoDateTime, + NonNegativeInt, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; export const AgentSessionSource = Schema.Literals(["claudeAgent", "codex"]); @@ -78,10 +84,35 @@ export const AgentSessionImportResult = Schema.Struct({ }); export type AgentSessionImportResult = typeof AgentSessionImportResult.Type; +// A text export is read whole on the client and sent once; 8 MiB covers very long sessions. +export const AGENT_SESSION_TRANSCRIPT_MAX_CHARS = 8 * 1024 * 1024; + +/** A conversation exported with Claude Code's `/export`, imported as a new thread. */ +export const AgentSessionTranscriptImportInput = Schema.Struct({ + projectId: ProjectId, + contents: Schema.String.check(Schema.isMaxLength(AGENT_SESSION_TRANSCRIPT_MAX_CHARS)), +}); +export type AgentSessionTranscriptImportInput = typeof AgentSessionTranscriptImportInput.Type; + +export const AgentSessionTranscriptImportResult = Schema.Struct({ + threadId: ThreadId, + messageCount: NonNegativeInt, +}); +export type AgentSessionTranscriptImportResult = typeof AgentSessionTranscriptImportResult.Type; + +export class AgentSessionTranscriptUnreadableError extends Schema.TaggedError()( + "AgentSessionTranscriptUnreadableError", + {}, +) { + override get message(): string { + return "The file has no prompts from an exported Claude Code conversation."; + } +} + export class AgentSessionScanError extends Schema.TaggedError()( "AgentSessionScanError", { - operation: Schema.Literals(["read-settings", "read-projects"]), + operation: Schema.Literals(["read-settings", "read-projects", "import-transcript"]), cause: Schema.Defect(), }, ) { diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index d3139f2eeff24..48253e2791ab6 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -34,6 +34,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { // Writing a guide runs a model on this environment's providers, which reading one does not. [WS_METHODS.pullRequestsGenerateReviewGuide]: AuthOrchestrationOperateScope, [WS_METHODS.projectsCreateFromThread]: AuthOrchestrationOperateScope, + [WS_METHODS.agentSessionsImportTranscript]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlCloneRepository]: AuthSourceControlWriteScope, [WS_METHODS.sourceControlPublishRepository]: AuthSourceControlWriteScope, // Signing in replaces the server's GitHub credential, as saving a token in Settings does. diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index edc7a9ab852a4..945056f387e26 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -97,6 +97,9 @@ import { AgentSessionScanInput, AgentSessionScanResult, AgentSessionScanError, + AgentSessionTranscriptImportInput, + AgentSessionTranscriptImportResult, + AgentSessionTranscriptUnreadableError, } from "./agentSessions.ts"; import { AssetAccessError, @@ -418,6 +421,7 @@ export const WS_METHODS = { filesystemGetMetadata: "filesystem.getMetadata", agentSessionsScan: "agentSessions.scan", agentSessionsImport: "agentSessions.import", + agentSessionsImportTranscript: "agentSessions.importTranscript", assetsCreateUrl: "assets.createUrl", assetsPersistChatAttachments: "assets.persistChatAttachments", attachmentsCreateUploadUrl: "attachments.createUploadUrl", @@ -1362,6 +1366,17 @@ const WsAgentSessionsImportRpc = Rpc.make(WS_METHODS.agentSessionsImport, { ]), }); +const WsAgentSessionsImportTranscriptRpc = Rpc.make(WS_METHODS.agentSessionsImportTranscript, { + payload: AgentSessionTranscriptImportInput, + success: AgentSessionTranscriptImportResult, + error: Schema.Union([ + AgentSessionImportProjectNotFoundError, + AgentSessionTranscriptUnreadableError, + AgentSessionScanError, + EnvironmentAuthorizationError, + ]), +}); + const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { payload: AssetCreateUrlInput, success: AssetCreateUrlResult, @@ -2078,6 +2093,7 @@ export const WsRpcGroup = RpcGroup.make( WsFilesystemGetMetadataRpc, WsAgentSessionsScanRpc, WsAgentSessionsImportRpc, + WsAgentSessionsImportTranscriptRpc, WsAssetsCreateUrlRpc, WsAssetsPersistChatAttachmentsRpc, WsAttachmentsCreateUploadUrlRpc, From c4f4c7ff7025ec637a8644c8e9c1b8cbdacf4a08 Mon Sep 17 00:00:00 2001 From: riyadmondol2006 Date: Fri, 30 Oct 2026 20:48:54 +0600 Subject: [PATCH 3/3] style: wrap the transcript import telemetry entry and permission test rows --- apps/server/src/telemetry/FeatureUsage.ts | 4 +++- packages/client-runtime/src/state/commandPermissions.test.ts | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/server/src/telemetry/FeatureUsage.ts b/apps/server/src/telemetry/FeatureUsage.ts index aa10b2b7ddea5..3e78d8e0b0148 100644 --- a/apps/server/src/telemetry/FeatureUsage.ts +++ b/apps/server/src/telemetry/FeatureUsage.ts @@ -68,7 +68,9 @@ const METHOD_FEATURES: Readonly FeatureUsag [WS_METHODS.scheduledTasksUpsert]: () => ({ feature: "scheduled_task.save" }), [WS_METHODS.scheduledTasksRunNow]: () => ({ feature: "scheduled_task.run_now" }), [WS_METHODS.agentSessionsImport]: () => ({ feature: "agent_session.import" }), - [WS_METHODS.agentSessionsImportTranscript]: () => ({ feature: "agent_session.import_transcript" }), + [WS_METHODS.agentSessionsImportTranscript]: () => ({ + feature: "agent_session.import_transcript", + }), [WS_METHODS.projectCloneStart]: () => ({ feature: "project.clone" }), [WS_METHODS.sourceControlPublishRepository]: () => ({ feature: "repository.publish" }), [WS_METHODS.sourceControlSignInStart]: () => ({ feature: "source_control.sign_in" }), diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index a5c0dd07e87b9..b9f9a553b7475 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -143,11 +143,12 @@ describe("command permissions", () => { ), ); - it.effect.each([ + const targetGrantMethods = [ WS_METHODS.scheduledTasksDelete, WS_METHODS.agentSessionsImportTranscript, ORCHESTRATION_V2_WS_METHODS.stopWorkflow, - ])( + ]; + it.effect.each(targetGrantMethods)( "uses the target grant for both availability and execution of %s", (method) => Effect.scoped(