diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 2e97d1e5364..0b2f7a40a88 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -143,6 +143,12 @@ "command": "t3Code.openChat", "title": "T3 Code: Open Chat", "category": "T3 Code" + }, + { + "command": "t3Code.openInT3", + "title": "T3 Code: Open in T3", + "category": "T3 Code", + "icon": "$(link-external)" } ], "menus": { @@ -187,6 +193,7 @@ "onCommand:t3Code.clearBearerToken", "onCommand:t3Code.newThread", "onCommand:t3Code.openChat", + "onCommand:t3Code.openInT3", "onCommand:t3Code.selectThread", "onCommand:t3Code.setBearerToken", "onCommand:t3Code.showDiagnostics", diff --git a/apps/vscode/src/chatViewProvider.ts b/apps/vscode/src/chatViewProvider.ts index 5f5abcb307a..d4844fea4b5 100644 --- a/apps/vscode/src/chatViewProvider.ts +++ b/apps/vscode/src/chatViewProvider.ts @@ -25,6 +25,7 @@ import { presentTasks } from "./taskPresentation.ts"; import { presentToolCalls } from "./toolPresentation.ts"; import { presentResolvedUserInputs } from "./userInputPresentation.ts"; import { deriveContextWindowUsage } from "./usagePresentation.ts"; +import { threadWebUrl } from "./threadWebUrl.ts"; const HOST_RESOURCE_IDLE_INTERVAL_MS = 10_000; const HOST_RESOURCE_LIVE_INTERVAL_MS = 1_000; @@ -303,6 +304,22 @@ export class T3ChatViewProvider implements vscode.WebviewViewProvider, vscode.Di await this.#view?.webview.postMessage({ type: "focusComposer" }); } + async openInT3(): Promise { + const thread = this.client.activeThread; + const httpBaseUrl = this.client.httpBaseUrl; + if (thread === null || httpBaseUrl === null) { + await vscode.window.showInformationMessage("Select a T3 thread before opening it."); + return; + } + // The extension host can run remotely, where its connected server URL is + // only locally reachable. Let VS Code map that origin through its active + // SSH/tunnel/proxy, then add T3's browser-level thread selector. + const externalBase = await vscode.env.asExternalUri(vscode.Uri.parse(httpBaseUrl)); + await vscode.env.openExternal( + vscode.Uri.parse(threadWebUrl(externalBase.toString(true), thread.id)), + ); + } + async #handle(message: WebviewRequest): Promise { try { switch (message.type) { @@ -317,13 +334,7 @@ export class T3ChatViewProvider implements vscode.WebviewViewProvider, vscode.Di this.#syncHostResourcePolling(); return; case "visitT3": { - const url = this.#threadUrl(this.client.activeThread); - if (url === null) return; - // The extension host runs where the server does (extensionKind: - // workspace), so the base URL is only reachable from there. asExternalUri - // maps it to something the browser on this machine can actually open. - const external = await vscode.env.asExternalUri(vscode.Uri.parse(url)); - await vscode.env.openExternal(external); + await this.openInT3(); return; } case "archiveThread": { @@ -525,20 +536,6 @@ export class T3ChatViewProvider implements vscode.WebviewViewProvider, vscode.Di }); } - /** - * Deep link to this thread in the T3 web app, matching its - * `/$environmentId/$threadId` route. Null until a thread and a connection exist. - */ - #threadUrl(thread: OrchestrationThread | null): string | null { - const httpBaseUrl = this.client.httpBaseUrl; - const environmentId = this.client.serverConfig?.environment.environmentId; - if (thread === null || httpBaseUrl === null || environmentId === undefined) return null; - return new URL( - `/${encodeURIComponent(environmentId)}/${encodeURIComponent(thread.id)}`, - httpBaseUrl, - ).toString(); - } - #watchWorktreeVcsStatus(): void { try { this.client.watchVcsStatus(this.actions.worktreePath()); @@ -1028,7 +1025,7 @@ export class T3ChatViewProvider implements vscode.WebviewViewProvider, vscode.Di -

+

Connecting to T3 Code…
diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index d24f67fd867..8b2e72b6f51 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -454,6 +454,7 @@ export function activate(context: vscode.ExtensionContext): void { await chatView.reveal(); }), vscode.commands.registerCommand("t3Code.openChat", () => chatView.reveal()), + vscode.commands.registerCommand("t3Code.openInT3", () => chatView.openInT3()), vscode.commands.registerCommand("t3Code.setBearerToken", async () => { const token = await vscode.window.showInputBox({ prompt: "T3 Code server bearer token", diff --git a/apps/vscode/src/threadWebUrl.test.ts b/apps/vscode/src/threadWebUrl.test.ts new file mode 100644 index 00000000000..d5518db8362 --- /dev/null +++ b/apps/vscode/src/threadWebUrl.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { threadWebUrl } from "./threadWebUrl.ts"; + +describe("threadWebUrl", () => { + it("opens a thread through the connected environment web origin", () => { + expect( + threadWebUrl( + "https://t3vm.tail86038f.ts.net/api/orchestration?ignored=yes#old", + "efdbe462-3d2c-43d6-b18e-0b27b8df3836", + ), + ).toBe("https://t3vm.tail86038f.ts.net/?thread=efdbe462-3d2c-43d6-b18e-0b27b8df3836"); + }); + + it("preserves a proxy origin while replacing its route", () => { + expect(threadWebUrl("https://proxy.example.test/t3/", "thread/id")).toBe( + "https://proxy.example.test/?thread=thread%2Fid", + ); + }); +}); diff --git a/apps/vscode/src/threadWebUrl.ts b/apps/vscode/src/threadWebUrl.ts new file mode 100644 index 00000000000..f3feb483d48 --- /dev/null +++ b/apps/vscode/src/threadWebUrl.ts @@ -0,0 +1,8 @@ +export function threadWebUrl(httpBaseUrl: string, threadId: string): string { + const url = new URL(httpBaseUrl); + url.pathname = "/"; + url.search = ""; + url.searchParams.set("thread", threadId); + url.hash = ""; + return url.toString(); +}