= [];
if (projects.length > 0) {
@@ -1453,6 +1815,60 @@ function OpenCommandPaletteDialog(props: {
addonIcon: ,
groups: [{ value: "projects", label: "Projects", items: projectThreadItems }],
});
+
+ const boundTeleport = isTeleportedOut(activeThread?.teleport)
+ ? (activeThread?.teleport ?? null)
+ : null;
+ const boundImportProject =
+ boundTeleport && activeThread
+ ? (pickerProjects.find(
+ (project) =>
+ project.id === activeThread.projectId &&
+ project.environmentId === activeThread.environmentId,
+ ) ?? null)
+ : null;
+ if (boundTeleport && boundImportProject) {
+ actionItems.push({
+ kind: "action",
+ value: "action:import-this-thread",
+ searchTerms: [
+ "import this thread",
+ "teleport in",
+ "native",
+ "cli",
+ teleportProviderLabel(boundTeleport.provider),
+ ],
+ title: "Import this thread from native CLI",
+ icon: ,
+ run: async () => {
+ await importNativeSession(boundImportProject, {
+ provider: boundTeleport.provider,
+ externalSessionId: boundTeleport.externalSessionId,
+ });
+ },
+ });
+ }
+
+ actionItems.push({
+ kind: "action",
+ value: "action:import-sessions",
+ searchTerms: [
+ "import sessions",
+ "teleport",
+ "codex",
+ "claude",
+ "opencode",
+ "grok",
+ "native",
+ "cli",
+ ],
+ title: "Import sessions...",
+ icon: ,
+ keepOpen: true,
+ run: async () => {
+ openImportSessionsFlow();
+ },
+ });
}
actionItems.push({
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index e918e7758688..7b65e95190c9 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -105,6 +105,7 @@ import { ContextWindowMeter } from "./ContextWindowMeter";
import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview";
import { basenameOfPath } from "../../pierre-icons";
import { cn, randomUUID } from "~/lib/utils";
+import { TELEPORTED_OUT_SEND_DISABLED_REASON } from "~/lib/teleport";
import { Separator } from "../ui/separator";
type ComposerCommandMenuPosition = {
@@ -3039,21 +3040,29 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onCommandKeyDown={onComposerCommandKey}
onPaste={onComposerPaste}
placeholder={
- isComposerApprovalState
- ? (activePendingApproval?.detail ?? "Resolve this approval request to continue")
- : activePendingProgress
- ? "Type your own answer, or leave this blank to use the selected option"
- : showPlanFollowUpPrompt && activeProposedPlan
- ? "Add feedback to refine the plan, or leave this blank to implement it"
- : projectSelectionRequired
- ? "Choose a project above to start a thread"
- : noProviderAvailable
- ? "Enable a provider in Settings to send a message"
- : phase === "disconnected"
- ? "Ask for follow-up changes or attach images"
- : "Ask anything, @tag files/folders, $use skills, or / for commands"
+ sendDisabledReason === TELEPORTED_OUT_SEND_DISABLED_REASON
+ ? TELEPORTED_OUT_SEND_DISABLED_REASON
+ : isComposerApprovalState
+ ? (activePendingApproval?.detail ??
+ "Resolve this approval request to continue")
+ : activePendingProgress
+ ? "Type your own answer, or leave this blank to use the selected option"
+ : showPlanFollowUpPrompt && activeProposedPlan
+ ? "Add feedback to refine the plan, or leave this blank to implement it"
+ : projectSelectionRequired
+ ? "Choose a project above to start a thread"
+ : noProviderAvailable
+ ? "Enable a provider in Settings to send a message"
+ : phase === "disconnected"
+ ? "Ask for follow-up changes or attach images"
+ : "Ask anything, @tag files/folders, $use skills, or / for commands"
+ }
+ disabled={
+ isConnecting ||
+ isComposerApprovalState ||
+ projectSelectionRequired ||
+ sendDisabledReason === TELEPORTED_OUT_SEND_DISABLED_REASON
}
- disabled={isConnecting || isComposerApprovalState || projectSelectionRequired}
/>
{showMobilePendingAnswerActions ? (
)}
+
);
diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx
index 9377dfa229a1..e603a03ecd7e 100644
--- a/apps/web/src/components/chat/DraftHeroHeadline.tsx
+++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx
@@ -1,6 +1,6 @@
import type { ScopedProjectRef } from "@t3tools/contracts";
import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
-import { FolderPlusIcon } from "lucide-react";
+import { FolderPlusIcon, ImportIcon } from "lucide-react";
import { useCallback, useMemo } from "react";
import { openCommandPalette } from "~/commandPaletteBus";
@@ -41,6 +41,17 @@ export function DraftHeroHeadline({
const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder);
const handleNewThread = useNewThreadHandler();
const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
+ const openImportSessions = useCallback(() => {
+ if (activeProjectRef === null) {
+ openCommandPalette({ open: "import-sessions" });
+ return;
+ }
+ openCommandPalette({
+ open: "import-sessions",
+ environmentId: activeProjectRef.environmentId,
+ projectId: activeProjectRef.projectId,
+ });
+ }, [activeProjectRef]);
const environmentLabelById = useMemo(
() =>
@@ -151,14 +162,26 @@ export function DraftHeroHeadline({
);
return (
-
+
+
+ {hasResolvedProject ? (
+ <>What should we build in {projectSelector}?>
+ ) : canChooseProject ? (
+ <>{projectSelector} to start>
+ ) : (
+ <>Add a project to start>
+ )}
+
{hasResolvedProject ? (
- <>What should we build in {projectSelector}?>
- ) : canChooseProject ? (
- <>{projectSelector} to start>
- ) : (
- <>Add a project to start>
- )}
-
+
+ ) : null}
+
);
}
diff --git a/apps/web/src/components/teleport/TeleportOutButton.tsx b/apps/web/src/components/teleport/TeleportOutButton.tsx
new file mode 100644
index 000000000000..9ea090fc599a
--- /dev/null
+++ b/apps/web/src/components/teleport/TeleportOutButton.tsx
@@ -0,0 +1,170 @@
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import {
+ isAtomCommandInterrupted,
+ squashAtomCommandFailure,
+} from "@t3tools/client-runtime/state/runtime";
+import { isTeleportProvider, type EnvironmentId, type ThreadId } from "@t3tools/contracts";
+import { LogOutIcon } from "lucide-react";
+import { useRef, useState } from "react";
+
+import { buildLoadingThreadFromShell } from "../ChatView.logic";
+import { isTeleportedOut, teleportFailureMessage } from "../../lib/teleport";
+import { useThread, useThreadShell } from "../../state/entities";
+import { teleportEnvironment } from "../../state/teleport";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { Button } from "../ui/button";
+import { stackedThreadToast, toastManager } from "../ui/toast";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+
+interface TeleportOutButtonProps {
+ readonly environmentId: EnvironmentId;
+ readonly threadId: ThreadId;
+ readonly isServerThread: boolean;
+ readonly cwd: string | null;
+}
+
+export function TeleportOutButton({
+ environmentId,
+ threadId,
+ isServerThread,
+ cwd,
+}: TeleportOutButtonProps) {
+ const threadRef = scopeThreadRef(environmentId, threadId);
+ const detail = useThread(threadRef);
+ const shell = useThreadShell(threadRef);
+ const exportSession = useAtomCommand(teleportEnvironment.exportSession, {
+ reportFailure: false,
+ });
+ const importSessions = useAtomCommand(teleportEnvironment.importSessions, {
+ reportFailure: false,
+ });
+ const pendingRef = useRef(false);
+ const [pending, setPending] = useState(false);
+ const thread = detail ?? (shell === null ? null : buildLoadingThreadFromShell(shell));
+ if (!isServerThread || thread === null) {
+ return null;
+ }
+
+ const teleport = thread.teleport ?? null;
+ const teleportedOut = isTeleportedOut(teleport);
+ const providerName = thread.session?.providerName;
+ const supported =
+ teleportedOut ||
+ (typeof providerName === "string" && isTeleportProvider(providerName)) ||
+ isTeleportProvider(thread.modelSelection.instanceId);
+ if (!supported) {
+ return null;
+ }
+
+ const sessionBusy = thread.session?.status === "starting" || thread.session?.status === "running";
+ const importNeedsCwd = teleportedOut && (cwd === null || cwd.length === 0);
+ const busy = pending || sessionBusy || importNeedsCwd;
+
+ return (
+
+ {
+ if (pendingRef.current || busy) {
+ return;
+ }
+ pendingRef.current = true;
+ setPending(true);
+ void (async () => {
+ try {
+ if (teleportedOut) {
+ if (teleport === null || cwd === null || cwd.length === 0) {
+ return;
+ }
+ const result = await importSessions({
+ environmentId,
+ input: {
+ projectId: thread.projectId,
+ cwd,
+ sessions: [
+ {
+ provider: teleport.provider,
+ externalSessionId: teleport.externalSessionId,
+ },
+ ],
+ },
+ });
+ if (result._tag === "Success") {
+ toastManager.add({
+ type: "success",
+ title: "Imported from native session",
+ description: teleport.nativePath,
+ });
+ return;
+ }
+ if (isAtomCommandInterrupted(result)) {
+ return;
+ }
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Could not teleport in",
+ description: teleportFailureMessage(squashAtomCommandFailure(result)),
+ }),
+ );
+ return;
+ }
+
+ const result = await exportSession({
+ environmentId,
+ input: { threadId },
+ });
+ if (result._tag === "Success") {
+ toastManager.add({
+ type: "success",
+ title: "Teleported to native session",
+ description: result.value.nativePath,
+ });
+ return;
+ }
+ if (isAtomCommandInterrupted(result)) {
+ return;
+ }
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Could not teleport out",
+ description: teleportFailureMessage(squashAtomCommandFailure(result)),
+ }),
+ );
+ } finally {
+ pendingRef.current = false;
+ setPending(false);
+ }
+ })();
+ }}
+ >
+
+
+ }
+ />
+
+ {teleportedOut
+ ? importNeedsCwd
+ ? "Import needs the project workspace path"
+ : sessionBusy
+ ? "Import is available when this provider thread is idle"
+ : "Read this thread back from the native CLI session"
+ : sessionBusy
+ ? "Teleport out is available when this provider thread is idle"
+ : "Write this idle thread to the native CLI session"}
+
+
+ );
+}
diff --git a/apps/web/src/lib/teleport.ts b/apps/web/src/lib/teleport.ts
new file mode 100644
index 000000000000..09548c3730c2
--- /dev/null
+++ b/apps/web/src/lib/teleport.ts
@@ -0,0 +1,39 @@
+import { isTeleportedOut, type TeleportProvider } from "@t3tools/contracts";
+
+export { isTeleportedOut };
+
+export const TELEPORTED_OUT_SEND_DISABLED_REASON =
+ "This thread is in the native CLI. Import it to keep chatting here.";
+
+export function teleportProviderLabel(provider: TeleportProvider): string {
+ switch (provider) {
+ case "codex":
+ return "Codex";
+ case "claudeAgent":
+ return "Claude";
+ case "opencode":
+ return "OpenCode";
+ case "grok":
+ return "Grok";
+ default: {
+ const _exhaustive: never = provider;
+ return _exhaustive;
+ }
+ }
+}
+
+export function teleportFailureMessage(error: unknown): string {
+ if (error instanceof Error && error.message.trim().length > 0) {
+ return error.message;
+ }
+ if (
+ typeof error === "object" &&
+ error !== null &&
+ "message" in error &&
+ typeof error.message === "string" &&
+ error.message.trim().length > 0
+ ) {
+ return error.message;
+ }
+ return "Teleport failed.";
+}
diff --git a/apps/web/src/state/teleport.ts b/apps/web/src/state/teleport.ts
new file mode 100644
index 000000000000..2d0802de3306
--- /dev/null
+++ b/apps/web/src/state/teleport.ts
@@ -0,0 +1,5 @@
+import { createTeleportEnvironmentAtoms } from "@t3tools/client-runtime/state/teleport";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const teleportEnvironment = createTeleportEnvironmentAtoms(connectionAtomRuntime);
diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json
index 01600af4699f..36a07d563518 100644
--- a/packages/client-runtime/package.json
+++ b/packages/client-runtime/package.json
@@ -119,6 +119,10 @@
"types": "./src/state/sourceControl.ts",
"default": "./src/state/sourceControl.ts"
},
+ "./state/teleport": {
+ "types": "./src/state/teleport.ts",
+ "default": "./src/state/teleport.ts"
+ },
"./state/terminal": {
"types": "./src/state/terminal.ts",
"default": "./src/state/terminal.ts"
diff --git a/packages/client-runtime/src/state/teleport.ts b/packages/client-runtime/src/state/teleport.ts
new file mode 100644
index 000000000000..9588a665a49b
--- /dev/null
+++ b/packages/client-runtime/src/state/teleport.ts
@@ -0,0 +1,24 @@
+import { WS_METHODS } from "@t3tools/contracts";
+import { Atom } from "effect/unstable/reactivity";
+
+import { createEnvironmentRpcCommand } from "./runtime.ts";
+import type { EnvironmentRegistry } from "../connection/registry.ts";
+
+export function createTeleportEnvironmentAtoms(
+ runtime: Atom.AtomRuntime,
+) {
+ return {
+ listSessions: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:teleport:list-sessions",
+ tag: WS_METHODS.teleportListSessions,
+ }),
+ importSessions: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:teleport:import-sessions",
+ tag: WS_METHODS.teleportImportSessions,
+ }),
+ exportSession: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:teleport:export-session",
+ tag: WS_METHODS.teleportExportSession,
+ }),
+ };
+}