diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 2a953132992c..ba8ff2cfeaf4 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -1,9 +1,13 @@ +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; + // Tiny event bus allowing components to programmatically open the command palette // without owning its React state. const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; export interface CommandPaletteOpenDetail { - readonly open?: "add-project" | "new-thread-in"; + readonly open?: "add-project" | "new-thread-in" | "import-sessions"; + readonly environmentId?: EnvironmentId; + readonly projectId?: ProjectId; } export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 5c026c94a138..1623ad8b61b7 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -168,6 +168,46 @@ describe("buildLoadingThreadFromShell", () => { checkpoints: [], }); }); + + it("preserves shell teleport presence while detail is still loading", () => { + const teleport = { + presence: "native" as const, + provider: "grok" as const, + externalSessionId: "session-1", + nativePath: "/tmp/native", + lastSyncedAt: now, + }; + const shell = { + environmentId, + id: threadId, + projectId, + title: "Loading thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + session: null, + latestUserMessageAt: now, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + teleport, + } satisfies ThreadShell; + + expect(buildLoadingThreadFromShell(shell).teleport).toEqual(teleport); + }); }); describe("resolveThreadMetadataUpdateForNextTurn", () => { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1f00c177c307..1306d5ecece6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -21,6 +21,7 @@ import { ProviderDriverKind, RuntimeMode, TerminalOpenInput, + isTeleportedOut, } from "@t3tools/contracts"; import { connectionStatusTitle, @@ -166,6 +167,7 @@ import { WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; +import { TELEPORTED_OUT_SEND_DISABLED_REASON } from "~/lib/teleport"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -4894,6 +4896,16 @@ function ChatViewContent(props: ChatViewProps) { notifyDirectAnnotationAttached(); return; } + if (isTeleportedOut(activeThread.teleport)) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Thread is in the native CLI", + description: TELEPORTED_OUT_SEND_DISABLED_REASON, + }), + ); + return; + } if (activeEnvironmentUnavailable) { toastManager.add( stackedThreadToast({ @@ -6349,7 +6361,13 @@ function ChatViewContent(props: ChatViewProps) { phase={phase} isConnecting={isConnecting} isSendBusy={isSendBusy} - sendDisabledReason={threadDetailLoading ? "Messages loading" : null} + sendDisabledReason={ + threadDetailLoading + ? "Messages loading" + : isTeleportedOut(activeThread?.teleport) + ? TELEPORTED_OUT_SEND_DISABLED_REASON + : null + } isPreparingWorktree={isPreparingWorktree} environmentUnavailable={activeEnvironmentUnavailableState} activePendingApproval={activePendingApproval} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 06dabc5e8490..e7f7371f3678 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -60,6 +60,26 @@ describe("reduceCommandPaletteUiState", () => { mode: "command", openIntent: { kind: "new-thread-in" }, }); + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenImportSessions" })).toEqual({ + open: true, + mode: "command", + openIntent: { kind: "import-sessions" }, + }); + expect( + reduceCommandPaletteUiState(filesOpen, { + _tag: "OpenImportSessions", + environmentId: EnvironmentId.make("environment-local"), + projectId: ProjectId.make("project-1"), + }), + ).toEqual({ + open: true, + mode: "command", + openIntent: { + kind: "import-sessions", + environmentId: EnvironmentId.make("environment-local"), + projectId: ProjectId.make("project-1"), + }, + }); }); it("resets to command mode for dialog-driven opens and closes", () => { diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 07e0e520d84e..67fa7ed49b7a 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,6 +1,8 @@ import { + type EnvironmentId, type FilesystemBrowseEntry, type KeybindingCommand, + type ProjectId, THREAD_JUMP_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; @@ -23,9 +25,14 @@ export const ADDON_ICON_CLASS = "size-4"; */ export type SearchOverlayMode = "command" | "files" | "content"; -export interface CommandPaletteOpenIntent { - readonly kind: "add-project" | "new-thread-in"; -} +export type CommandPaletteOpenIntent = + | { readonly kind: "add-project" } + | { readonly kind: "new-thread-in" } + | { + readonly kind: "import-sessions"; + readonly environmentId?: EnvironmentId; + readonly projectId?: ProjectId; + }; export interface CommandPaletteUiState { readonly open: boolean; @@ -38,6 +45,11 @@ export type CommandPaletteUiAction = | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode } | { readonly _tag: "OpenAddProject" } | { readonly _tag: "OpenNewThreadIn" } + | { + readonly _tag: "OpenImportSessions"; + readonly environmentId?: EnvironmentId; + readonly projectId?: ProjectId; + } | { readonly _tag: "ClearOpenIntent" }; export function reduceCommandPaletteUiState( @@ -59,8 +71,22 @@ export function reduceCommandPaletteUiState( return { open: true, mode: "command", openIntent: { kind: "add-project" } }; case "OpenNewThreadIn": return { open: true, mode: "command", openIntent: { kind: "new-thread-in" } }; + case "OpenImportSessions": + return { + open: true, + mode: "command", + openIntent: { + kind: "import-sessions", + ...(action.environmentId === undefined ? {} : { environmentId: action.environmentId }), + ...(action.projectId === undefined ? {} : { projectId: action.projectId }), + }, + }; case "ClearOpenIntent": return state.openIntent ? { ...state, openIntent: null } : state; + default: { + const _exhaustive: never = action; + return _exhaustive; + } } } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 48471accb995..521a387b23e1 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -33,7 +33,9 @@ import { FileSearchIcon, FolderIcon, FolderPlusIcon, + ImportIcon, LinkIcon, + LoaderIcon, MessageSquareIcon, PaletteIcon, SettingsIcon, @@ -83,6 +85,7 @@ import { resolveProjectPathForDispatch, } from "../lib/projectPaths"; import { onOpenCommandPalette } from "../commandPaletteBus"; +import { formatRelativeTimeLabel } from "../timestampFormat"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; @@ -115,6 +118,73 @@ import { reduceCommandPaletteUiState, type SearchOverlayMode, } from "./CommandPalette.logic"; + +function nativeSessionsPaletteView( + projectTitle: string, + items: CommandPaletteActionItem[], +): CommandPaletteView { + return { + addonIcon: , + groups: [ + { + value: "native-sessions", + label: `Sessions in ${projectTitle}`, + items, + }, + ], + }; +} + +function importSessionsStatusItem(input: { + readonly value: string; + readonly title: string; + readonly description?: string; + readonly icon: ReactNode; +}): CommandPaletteActionItem { + return { + kind: "action", + value: input.value, + searchTerms: [], + title: input.title, + ...(input.description === undefined ? {} : { description: input.description }), + icon: input.icon, + disabled: true, + run: async () => undefined, + }; +} + +function importSessionsLoadingView(projectTitle: string): CommandPaletteView { + return nativeSessionsPaletteView(projectTitle, [ + importSessionsStatusItem({ + value: "import-sessions-loading", + title: "Looking for native sessions…", + icon: , + }), + ]); +} + +function importIntoProjectPaletteView( + items: ReadonlyArray, +): CommandPaletteView { + return { + addonIcon: , + groups: [ + { + value: "projects", + label: "Import into project", + items: enumerateCommandPaletteItems(items), + }, + ], + }; +} + +function toInstalledPaletteView(view: CommandPaletteView): CommandPaletteView { + return { + addonIcon: view.addonIcon, + groups: view.groups, + ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), + }; +} import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; @@ -126,7 +196,9 @@ import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog" import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; import { ThreadCommandSubtitle } from "./ThreadCommandSubtitle"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; +import { isTeleportedOut, teleportFailureMessage, teleportProviderLabel } from "../lib/teleport"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; +import { teleportEnvironment } from "../state/teleport"; import { deriveProviderInstanceEntries, resolveDefaultProviderModelSelection, @@ -393,6 +465,15 @@ export function CommandPalette({ children }: { children: ReactNode }) { ); const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); + const openImportSessions = useCallback( + (input?: { readonly environmentId?: EnvironmentId; readonly projectId?: ProjectId }) => + dispatch({ + _tag: "OpenImportSessions", + ...(input?.environmentId === undefined ? {} : { environmentId: input.environmentId }), + ...(input?.projectId === undefined ? {} : { projectId: input.projectId }), + }), + [], + ); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { theme, themeHalves, resolvedTheme } = useTheme(); @@ -463,15 +544,33 @@ export function CommandPalette({ children }: { children: ReactNode }) { useEffect( () => onOpenCommandPalette((detail) => { - if (detail.open === "new-thread-in") { - openNewThreadIn(); - } else if (detail.open === "add-project") { - openAddProject(); - } else { + const open = detail.open; + if (open === undefined) { setOpen(true); + return; + } + switch (open) { + case "new-thread-in": + openNewThreadIn(); + return; + case "add-project": + openAddProject(); + return; + case "import-sessions": + openImportSessions({ + ...(detail.environmentId === undefined + ? {} + : { environmentId: detail.environmentId }), + ...(detail.projectId === undefined ? {} : { projectId: detail.projectId }), + }); + return; + default: { + const _exhaustive: never = open; + return _exhaustive; + } } }), - [openAddProject, openNewThreadIn, setOpen], + [openAddProject, openImportSessions, openNewThreadIn, setOpen], ); return ( @@ -578,6 +677,12 @@ function OpenCommandPaletteDialog(props: { const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { reportFailure: false, }); + const listTeleportSessions = useAtomCommand(teleportEnvironment.listSessions, { + reportFailure: false, + }); + const importTeleportSessions = useAtomCommand(teleportEnvironment.importSessions, { + reportFailure: false, + }); const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -631,6 +736,8 @@ function OpenCommandPaletteDialog(props: { browseNavigationRef.current = createBrowseNavigationCoordinator(); } const browseNavigation = browseNavigationRef.current; + const teleportImportPendingRef = useRef(false); + const importListGenerationRef = useRef(0); const [addProjectEnvironmentId, setAddProjectEnvironmentId] = useState( null, ); @@ -1084,14 +1191,31 @@ function OpenCommandPaletteDialog(props: { const pushPaletteView = useCallback( (view: CommandPaletteView): void => { browseNavigation.invalidate(); - setViewStack((previousViews) => [ - ...previousViews, - { - addonIcon: view.addonIcon, - groups: view.groups, - ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), - }, - ]); + setViewStack((previousViews) => [...previousViews, toInstalledPaletteView(view)]); + setHighlightedItemValue(null); + setQuery(view.initialQuery ?? ""); + }, + [browseNavigation], + ); + + const replacePaletteView = useCallback( + (view: CommandPaletteView): void => { + browseNavigation.invalidate(); + setAddProjectCloneFlow(null); + setViewStack([toInstalledPaletteView(view)]); + setHighlightedItemValue(null); + setQuery(view.initialQuery ?? ""); + }, + [browseNavigation], + ); + + const replaceTopPaletteView = useCallback( + (view: CommandPaletteView): void => { + browseNavigation.invalidate(); + setViewStack((previousViews) => { + const nextView = toInstalledPaletteView(view); + return previousViews.length === 0 ? [nextView] : [...previousViews.slice(0, -1), nextView]; + }); setHighlightedItemValue(null); setQuery(view.initialQuery ?? ""); }, @@ -1126,6 +1250,186 @@ function OpenCommandPaletteDialog(props: { } } + const importNativeSession = useCallback( + async ( + project: Project, + session: { + readonly provider: "codex" | "claudeAgent" | "opencode" | "grok"; + readonly externalSessionId: string; + }, + ): Promise => { + if (teleportImportPendingRef.current) { + return; + } + teleportImportPendingRef.current = true; + try { + const result = await importTeleportSessions({ + environmentId: project.environmentId, + input: { + projectId: project.id, + cwd: project.workspaceRoot, + sessions: [ + { + provider: session.provider, + externalSessionId: session.externalSessionId, + }, + ], + }, + }); + if (result._tag === "Success") { + const imported = result.value.imported[0]; + if (imported) { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(project.environmentId, imported.threadId), + ), + }); + toastManager.add({ + type: "success", + title: imported.updatedInPlace + ? "Updated thread from native session" + : "Imported native session", + }); + } + setOpen(false); + return; + } + if (isAtomCommandInterrupted(result)) { + return; + } + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not import session", + description: teleportFailureMessage(squashAtomCommandFailure(result)), + }), + ); + } finally { + teleportImportPendingRef.current = false; + } + }, + [importTeleportSessions, navigate, setOpen], + ); + + const loadNativeSessionsIntoView = useCallback( + async (project: Project): Promise => { + const generation = (importListGenerationRef.current += 1); + const result = await listTeleportSessions({ + environmentId: project.environmentId, + input: { cwd: project.workspaceRoot }, + }); + if (generation !== importListGenerationRef.current) { + return; + } + if (result._tag !== "Success") { + if (isAtomCommandInterrupted(result)) { + return; + } + replaceTopPaletteView( + nativeSessionsPaletteView(project.title, [ + importSessionsStatusItem({ + value: "import-sessions-error", + title: "Could not list native sessions", + description: teleportFailureMessage(squashAtomCommandFailure(result)), + icon: , + }), + ]), + ); + return; + } + if (result.value.sessions.length === 0) { + replaceTopPaletteView( + nativeSessionsPaletteView(project.title, [ + importSessionsStatusItem({ + value: "import-sessions-empty", + title: "No native sessions in this project", + description: project.workspaceRoot, + icon: , + }), + ]), + ); + return; + } + replaceTopPaletteView( + nativeSessionsPaletteView( + project.title, + result.value.sessions.map((session) => ({ + kind: "action" as const, + value: `import-session:${session.provider}:${session.externalSessionId}`, + searchTerms: [ + session.title ?? "", + session.externalSessionId, + teleportProviderLabel(session.provider), + session.cwd, + ], + title: session.title ?? session.externalSessionId, + description: [ + teleportProviderLabel(session.provider), + session.updatedAt ? formatRelativeTimeLabel(session.updatedAt) : null, + ] + .filter((part): part is string => part !== null) + .join(" · "), + icon: , + run: async () => { + await importNativeSession(project, session); + }, + })), + ), + ); + }, + [importNativeSession, listTeleportSessions, replaceTopPaletteView], + ); + + const openImportSessionsForProject = useCallback( + (project: Project): void => { + pushPaletteView(importSessionsLoadingView(project.title)); + void loadNativeSessionsIntoView(project); + }, + [loadNativeSessionsIntoView, pushPaletteView], + ); + + const importProjectItems = useMemo( + () => + pickerProjects.map((project) => ({ + kind: "action" as const, + value: `import-sessions:${project.environmentId}:${project.id}`, + searchTerms: [project.title, project.workspaceRoot, "import", "teleport"], + title: project.title, + description: project.workspaceRoot, + icon: projectFavicon(project), + keepOpen: true, + run: async () => { + openImportSessionsForProject(project); + }, + })), + [openImportSessionsForProject, pickerProjects], + ); + + const openImportSessionsFlow = useCallback(() => { + if (importProjectItems.length === 0) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "No projects available", + description: "Add a project before importing native sessions.", + }), + ); + return; + } + const currentPrefix = + currentProjectEnvironmentId && currentProjectId + ? `import-sessions:${currentProjectEnvironmentId}:${currentProjectId}` + : null; + const prioritized = currentPrefix + ? [ + ...importProjectItems.filter((item) => item.value === currentPrefix), + ...importProjectItems.filter((item) => item.value !== currentPrefix), + ] + : importProjectItems; + pushPaletteView(importIntoProjectPaletteView(prioritized)); + }, [currentProjectEnvironmentId, currentProjectId, importProjectItems, pushPaletteView]); + const startAddProjectBrowse = useCallback( async (environmentId: EnvironmentId): Promise => { const initialQuery = getAddProjectInitialQueryForEnvironment(environmentId); @@ -1414,6 +1718,64 @@ function OpenCommandPaletteDialog(props: { pushPaletteView, ]); + useLayoutEffect(() => { + if (openIntent?.kind !== "import-sessions") { + return; + } + const environmentId = openIntent.environmentId; + const projectId = openIntent.projectId; + clearOpenIntent(); + browseNavigation.invalidate(); + setAddProjectCloneFlow(null); + setQuery(""); + if (environmentId !== undefined && projectId !== undefined) { + const project = pickerProjects.find( + (candidate) => candidate.environmentId === environmentId && candidate.id === projectId, + ); + if (project) { + replacePaletteView(importSessionsLoadingView(project.title)); + void loadNativeSessionsIntoView(project); + return; + } + } + if (importProjectItems.length === 0) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "No projects available", + description: "Add a project before importing native sessions.", + }), + ); + setOpen(false); + return; + } + const currentPrefix = + currentProjectEnvironmentId && currentProjectId + ? `import-sessions:${currentProjectEnvironmentId}:${currentProjectId}` + : null; + replacePaletteView( + importIntoProjectPaletteView( + currentPrefix + ? [ + ...importProjectItems.filter((item) => item.value === currentPrefix), + ...importProjectItems.filter((item) => item.value !== currentPrefix), + ] + : importProjectItems, + ), + ); + }, [ + browseNavigation, + clearOpenIntent, + currentProjectEnvironmentId, + currentProjectId, + importProjectItems, + loadNativeSessionsIntoView, + openIntent, + pickerProjects, + replacePaletteView, + setOpen, + ]); + const actionItems: Array = []; 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, + }), + }; +}