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/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..3e78d8e0b0148 100644 --- a/apps/server/src/telemetry/FeatureUsage.ts +++ b/apps/server/src/telemetry/FeatureUsage.ts @@ -68,6 +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.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/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/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/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/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..b9f9a553b7475 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -143,7 +143,12 @@ describe("command permissions", () => { ), ); - it.effect.each([WS_METHODS.scheduledTasksDelete, ORCHESTRATION_V2_WS_METHODS.stopWorkflow])( + 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( 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, 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);