diff --git a/src/routes/Settings/SettingsLayout.tsx b/src/routes/Settings/SettingsLayout.tsx index bd9a21c652..498e5e7501 100644 --- a/src/routes/Settings/SettingsLayout.tsx +++ b/src/routes/Settings/SettingsLayout.tsx @@ -53,15 +53,25 @@ const AGENT_ITEM: SidebarItem = { testId: "settings-nav-agent", }; +const VSCODE_ITEM: SidebarItem = { + to: "/settings/vscode-extension", + label: "VS Code Extension", + icon: "Download", + testId: "settings-nav-vscode-extension", +}; + export function SettingsLayout() { const router = useRouter(); const componentSearchEnabled = useFlagValue("component-search-v2"); const aiAssistantEnabled = useFlagValue("ai-assistant"); const tangentShellEnabled = useTangentEnabled(); - const sidebarItems = - componentSearchEnabled || aiAssistantEnabled || tangentShellEnabled - ? [...SIDEBAR_ITEMS, AGENT_ITEM] - : SIDEBAR_ITEMS; + const sidebarItems = [ + ...SIDEBAR_ITEMS, + ...(componentSearchEnabled || aiAssistantEnabled || tangentShellEnabled + ? [AGENT_ITEM] + : []), + ...(tangentShellEnabled ? [VSCODE_ITEM] : []), + ]; const handleGoBack = () => { router.history.back(); diff --git a/src/routes/Settings/sections/VsCodeExtensionSettings.tsx b/src/routes/Settings/sections/VsCodeExtensionSettings.tsx new file mode 100644 index 0000000000..2cb41abf30 --- /dev/null +++ b/src/routes/Settings/sections/VsCodeExtensionSettings.tsx @@ -0,0 +1,129 @@ +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import { CopyValueButton } from "@/routes/v2/shared/components/CopyValueButton"; + +interface ExtensionManifest { + version: string; + filename: string; + url: string; +} + +function isExtensionManifest(value: unknown): value is ExtensionManifest { + return ( + typeof value === "object" && + value !== null && + "version" in value && + typeof value.version === "string" && + "filename" in value && + typeof value.filename === "string" && + "url" in value && + typeof value.url === "string" + ); +} + +const STABLE_VSIX_PATH = "/extension/tangent-local-agent.vsix"; +const DEFAULT_VSIX_FILENAME = "tangent-local-agent.vsix"; + +function CommandBlock({ command }: { command: string }) { + return ( + + + {command} + + command} /> + + ); +} + +export function VsCodeExtensionSettings() { + const [manifest, setManifest] = useState(null); + const search = window.location.search; + + useEffect(() => { + let cancelled = false; + fetch(`/extension/latest.json${search}`) + .then((response) => (response.ok ? response.json() : null)) + .then((data) => { + if (!cancelled) { + setManifest(isExtensionManifest(data) ? data : null); + } + }) + .catch(() => { + if (!cancelled) setManifest(null); + }); + return () => { + cancelled = true; + }; + }, [search]); + + const origin = window.location.origin; + const downloadUrl = manifest + ? `${origin}${manifest.url}${search}` + : `${STABLE_VSIX_PATH}${search}`; + const vsixFilename = manifest?.filename ?? DEFAULT_VSIX_FILENAME; + + return ( + + + + VS Code Extension: Manual install and upgrade + + + Download the package and install it from the editor. Re-running either + path always pulls the latest version. + + + + + + + Cursor + Visual Studio Code + + + + + + + + +
    +
  1. + Open the Extensions view, then its “…” menu. +
  2. +
  3. + + Choose “Install from VSIX…” and pick the downloaded file. + +
  4. +
  5. + Reload or restart the editor to activate it. +
  6. +
+
+
+ ); +} diff --git a/src/routes/appRoutes.ts b/src/routes/appRoutes.ts index 320e4567de..252722d083 100644 --- a/src/routes/appRoutes.ts +++ b/src/routes/appRoutes.ts @@ -41,6 +41,7 @@ export const APP_ROUTES = { SETTINGS_PREFERENCES: `${SETTINGS_PATH}/preferences`, SETTINGS_BETA_FEATURES: `${SETTINGS_PATH}/beta-features`, SETTINGS_AGENT: `${SETTINGS_PATH}/agent`, + SETTINGS_VSCODE: `${SETTINGS_PATH}/vscode-extension`, SETTINGS_SECRETS: `${SETTINGS_PATH}/secrets`, SETTINGS_SECRETS_ADD: `${SETTINGS_PATH}/secrets/add`, SETTINGS_SECRETS_REPLACE: `${SETTINGS_PATH}/secrets/$secretId/replace`, diff --git a/src/routes/router.ts b/src/routes/router.ts index 412e2cda48..728300e386 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -51,6 +51,7 @@ import { BackendSettings } from "./Settings/sections/BackendSettings"; import { BetaFeaturesSettings } from "./Settings/sections/BetaFeaturesSettings"; import { PreferencesSettings } from "./Settings/sections/PreferencesSettings"; import { SecretsSettings } from "./Settings/sections/SecretsSettings"; +import { VsCodeExtensionSettings } from "./Settings/sections/VsCodeExtensionSettings"; import { SettingsLayout } from "./Settings/SettingsLayout"; import { CompareView } from "./v2/pages/CompareView/CompareView"; import { PipelineFoldersPage } from "./v2/pages/PipelineFolders/PipelineFoldersPage"; @@ -258,6 +259,17 @@ const settingsAgentRoute = createRoute({ }, }); +const settingsVsCodeRoute = createRoute({ + getParentRoute: () => settingsLayoutRoute, + path: "/vscode-extension", + component: VsCodeExtensionSettings, + beforeLoad: () => { + if (!isTangentEnabled()) { + throw redirect({ to: APP_ROUTES.SETTINGS_BACKEND }); + } + }, +}); + const settingsSecretsRoute = createRoute({ getParentRoute: () => settingsLayoutRoute, path: "/secrets", @@ -326,6 +338,7 @@ const settingsRouteTree = settingsLayoutRoute.addChildren([ settingsPreferencesRoute, settingsBetaFeaturesRoute, settingsAgentRoute, + settingsVsCodeRoute, secretsRouteTree, ]); diff --git a/src/routes/v2/pages/Tangent/components/LocalEnvIndicator.test.tsx b/src/routes/v2/pages/Tangent/components/LocalEnvIndicator.test.tsx new file mode 100644 index 0000000000..ec0ae66679 --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/LocalEnvIndicator.test.tsx @@ -0,0 +1,216 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useProjectLocalEnv } from "@/routes/v2/pages/Tangent/hooks/useProjectLocalEnv"; +import { useProjectSessions } from "@/routes/v2/pages/Tangent/hooks/useProjectSessions"; +import { useTangentBaseUrl } from "@/routes/v2/pages/Tangent/hooks/useTangentBaseUrl"; +import { API_URL } from "@/utils/constants"; + +import { LocalEnvIndicator } from "./LocalEnvIndicator"; + +vi.mock("@tanstack/react-router", async (importOriginal) => ({ + ...(await importOriginal()), + Link: ({ to, children }: { to: string; children: ReactNode }) => ( + {children} + ), +})); + +vi.mock("@/routes/v2/pages/Tangent/context/TangentProjectContext", () => ({ + useTangentProject: () => ({ + projectId: "project-1", + activeSessionId: "session-1", + }), +})); + +vi.mock("@/services/projects/useProjects", () => ({ + useProject: () => ({ data: { name: "My Project" } }), +})); + +vi.mock("@/routes/v2/pages/Tangent/hooks/useTangentBaseUrl", () => ({ + useTangentBaseUrl: vi.fn(), +})); + +vi.mock("@/routes/v2/pages/Tangent/hooks/useProjectSessions", () => ({ + useProjectSessions: vi.fn(), +})); + +vi.mock("@/routes/v2/pages/Tangent/hooks/useProjectLocalEnv", () => ({ + useProjectLocalEnv: vi.fn(), +})); + +const SHELL_URL = "http://localhost:9005"; + +function mockBaseUrl(baseUrl: string | null) { + vi.mocked(useTangentBaseUrl).mockReturnValue({ + baseUrl, + localAddress: null, + isLoading: false, + isError: false, + }); + vi.mocked(useProjectSessions).mockReturnValue({ + sessions: [ + { + resourceId: "resource-1", + sessionId: "session-1", + name: null, + createdAt: new Date(), + }, + ], + isLoading: false, + isAttaching: false, + attachSession: vi.fn(), + detachSession: vi.fn(), + renameSession: vi.fn(), + }); +} + +type EnvironmentSpec = { + environmentId: string; + metadata?: Record; +}; + +function mockLocalEnvironments(environments: Array) { + const localEnvironments = environments.map((environment) => { + const spec = + typeof environment === "string" + ? { environmentId: environment } + : environment; + return { + scoped: false, + toolCount: 7, + subagentCount: 0, + ...spec, + }; + }); + vi.mocked(useProjectLocalEnv).mockReturnValue({ + localEnvironments, + isLocalEnvConnected: localEnvironments.length > 0, + }); +} + +describe("LocalEnvIndicator", () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + it("offers to connect an editor when no local environment is connected", () => { + mockBaseUrl(SHELL_URL); + mockLocalEnvironments([]); + + render(); + + expect( + screen.getByRole("button", { name: "Connect editor" }), + ).toBeInTheDocument(); + expect(screen.queryByLabelText("Local environment connected")).toBeNull(); + }); + + it("shows the connected window name on the badge", () => { + mockBaseUrl(SHELL_URL); + mockLocalEnvironments([ + { + environmentId: "session-1:vscode-abc", + metadata: { windowName: "my-workspace" }, + }, + ]); + + render(); + + expect( + screen.getByRole("button", { name: "Local environment connected" }), + ).toHaveTextContent("Local env · my-workspace"); + }); + + it("falls back to the host id when no window name is advertised", () => { + mockBaseUrl(SHELL_URL); + mockLocalEnvironments(["session-1:vscode-abc"]); + + render(); + + expect( + screen.getByRole("button", { name: "Local environment connected" }), + ).toHaveTextContent("Local env · vscode-abc"); + }); + + it("reveals host details in a popover", async () => { + const user = userEvent.setup(); + mockBaseUrl(SHELL_URL); + mockLocalEnvironments([ + { + environmentId: "session-1:vscode-abc", + metadata: { + windowName: "my-workspace", + workspacePath: "/home/me/proj", + editor: "Cursor", + }, + }, + ]); + + render(); + await user.click( + screen.getByRole("button", { name: "Local environment connected" }), + ); + + expect(await screen.findByText("/home/me/proj")).toBeInTheDocument(); + expect(screen.getByText("Cursor")).toBeInTheDocument(); + }); + + it("summarizes the count when several windows are connected", () => { + mockBaseUrl(SHELL_URL); + mockLocalEnvironments([ + { + environmentId: "session-1:vscode-abc", + metadata: { windowName: "first" }, + }, + { + environmentId: "session-1:vscode-def", + metadata: { windowName: "second" }, + }, + ]); + + render(); + + expect( + screen.getByRole("button", { name: "Local environment connected" }), + ).toHaveTextContent("Local env (2)"); + }); + + it("links to the extension's connect flow for VS Code and Cursor", async () => { + const user = userEvent.setup(); + mockBaseUrl(SHELL_URL); + mockLocalEnvironments([]); + + render(); + await user.click(screen.getByRole("button", { name: "Connect editor" })); + + const apiUrl = encodeURIComponent(API_URL || window.location.origin); + const vscodeLink = await screen.findByRole("link", { + name: "Connect in VS Code", + }); + expect(vscodeLink).toHaveAttribute( + "href", + `vscode://tangent.tangent-local-agent/connect?url=http%3A%2F%2Flocalhost%3A9005&apiUrl=${apiUrl}&projectId=project-1&projectName=My+Project`, + ); + + await user.click(screen.getByRole("button", { name: "More editors" })); + + const cursorLink = await screen.findByRole("menuitem", { + name: "Connect in Cursor", + }); + expect(cursorLink).toHaveAttribute( + "href", + `cursor://tangent.tangent-local-agent/connect?url=http%3A%2F%2Flocalhost%3A9005&apiUrl=${apiUrl}&projectId=project-1&projectName=My+Project`, + ); + }); + + it("renders nothing until Tangent's location is known", () => { + mockBaseUrl(null); + mockLocalEnvironments([]); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/routes/v2/pages/Tangent/components/LocalEnvIndicator.tsx b/src/routes/v2/pages/Tangent/components/LocalEnvIndicator.tsx new file mode 100644 index 0000000000..2d875921ed --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/LocalEnvIndicator.tsx @@ -0,0 +1,266 @@ +import { Link } from "@tanstack/react-router"; +import { observer } from "mobx-react-lite"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Paragraph, Text } from "@/components/ui/typography"; +import { APP_ROUTES } from "@/routes/router"; +import { useTangentProject } from "@/routes/v2/pages/Tangent/context/TangentProjectContext"; +import { useProjectLocalEnv } from "@/routes/v2/pages/Tangent/hooks/useProjectLocalEnv"; +import { useProjectSessions } from "@/routes/v2/pages/Tangent/hooks/useProjectSessions"; +import { useTangentBaseUrl } from "@/routes/v2/pages/Tangent/hooks/useTangentBaseUrl"; +import type { TangentEnvironment } from "@/routes/v2/pages/Tangent/services/listTangentEnvironments"; +import { + buildCursorConnectUrl, + buildVscodeConnectUrl, + type ConnectUrlParams, +} from "@/routes/v2/pages/Tangent/services/vscodeConnect"; +import { useProject } from "@/services/projects/useProjects"; +import { API_URL } from "@/utils/constants"; +import { tracking } from "@/utils/tracking"; + +const CONNECT_STEPS = [ + "Open a connect link below and confirm the Tangent URL.", + "Enter the remote environment token when the editor asks for it.", +]; + +function hostLabel(environment: TangentEnvironment): string { + const separator = environment.environmentId.indexOf(":"); + return separator === -1 + ? environment.environmentId + : environment.environmentId.slice(separator + 1); +} + +function environmentLabel(environment: TangentEnvironment): string { + return environment.metadata?.windowName ?? hostLabel(environment); +} + +export const LocalEnvIndicator = observer(function LocalEnvIndicator() { + const store = useTangentProject(); + const { baseUrl } = useTangentBaseUrl(store.projectId); + const { sessions } = useProjectSessions(store.projectId); + const { data: project } = useProject(store.projectId); + const { localEnvironments, isLocalEnvConnected } = useProjectLocalEnv( + baseUrl, + sessions.map((session) => session.sessionId), + ); + + if (!baseUrl) return null; + + const connectParams = { + shellUrl: baseUrl, + apiUrl: API_URL || window.location.origin, + projectId: store.projectId, + projectName: project?.name, + }; + + return ( + + {isLocalEnvConnected && ( + + )} + + + ); +}); + +function LocalEnvConnectedBadge({ + environments, +}: { + environments: TangentEnvironment[]; +}) { + const [single] = environments; + const label = + environments.length === 1 + ? `Local env · ${environmentLabel(single)}` + : `Local env (${environments.length})`; + + return ( + + + + + + + + + {environments.map((environment) => ( + + ))} + + + + ); +} + +function EnvironmentDetails({ + environment, +}: { + environment: TangentEnvironment; +}) { + const { metadata, owner, toolCount, subagentCount } = environment; + + return ( + + + {environmentLabel(environment)} + + + {metadata?.workspacePath && ( + + )} + {metadata?.editor && ( + + )} + {owner && } + + + + + ); +} + +function EnvironmentDetailRow({ + label, + value, +}: { + label: string; + value: string; +}) { + return ( + + + {label} + + {value} + + ); +} + +function ConnectEditorPopover({ + connectParams, + isConnected, +}: { + connectParams: ConnectUrlParams; + isConnected: boolean; +}) { + return ( + + + {isConnected ? ( + + ) : ( + + )} + + + + + + Use your machine as a local environment + + + The editor extension lets agents in this project run sub-agents + and file tools in your open workspace folder. + + +
    +
  1. + + + Install the Tangent Local Agent extension in VS Code or + Cursor. + + +
  2. + {CONNECT_STEPS.map((step) => ( +
  3. + {step} +
  4. + ))} +
+ + + + + + + + + + + Connect in Cursor + + + + + +
+
+
+ ); +} diff --git a/src/routes/v2/pages/Tangent/components/ProjectHeader.test.tsx b/src/routes/v2/pages/Tangent/components/ProjectHeader.test.tsx index f7b4be18f4..24930f41ed 100644 --- a/src/routes/v2/pages/Tangent/components/ProjectHeader.test.tsx +++ b/src/routes/v2/pages/Tangent/components/ProjectHeader.test.tsx @@ -27,6 +27,10 @@ vi.mock("@/components/Project/useShareProjectAction", () => ({ useShareProjectAction: () => share, })); +vi.mock("./LocalEnvIndicator", () => ({ + LocalEnvIndicator: () => null, +})); + vi.mock("@tanstack/react-router", () => ({ Link: ({ children, diff --git a/src/routes/v2/pages/Tangent/components/ProjectHeader.tsx b/src/routes/v2/pages/Tangent/components/ProjectHeader.tsx index 84968f88b0..5b01ca37b3 100644 --- a/src/routes/v2/pages/Tangent/components/ProjectHeader.tsx +++ b/src/routes/v2/pages/Tangent/components/ProjectHeader.tsx @@ -13,6 +13,8 @@ import type { Project } from "@/services/projects/types"; import { useProject } from "@/services/projects/useProjects"; import { tracking } from "@/utils/tracking"; +import { LocalEnvIndicator } from "./LocalEnvIndicator"; + export function ProjectHeader() { const store = useTangentProject(); const { data: project } = useProject(store.projectId); @@ -97,6 +99,8 @@ function ProjectHeaderBar({ project }: { project: Project }) { + + ); } diff --git a/src/routes/v2/pages/Tangent/context/TangentProjectContext.tsx b/src/routes/v2/pages/Tangent/context/TangentProjectContext.tsx index 78b867aa48..cba846d8ef 100644 --- a/src/routes/v2/pages/Tangent/context/TangentProjectContext.tsx +++ b/src/routes/v2/pages/Tangent/context/TangentProjectContext.tsx @@ -7,8 +7,11 @@ import { useRequiredContext, } from "@/hooks/useRequiredContext"; import useToastNotification from "@/hooks/useToastNotification"; +import { useBackfillSessionTags } from "@/routes/v2/pages/Tangent/hooks/useBackfillSessionTags"; import { usePrepareProjectArrival } from "@/routes/v2/pages/Tangent/hooks/usePrepareProjectArrival"; import { useProjectSessions } from "@/routes/v2/pages/Tangent/hooks/useProjectSessions"; +import { useRemoteEnvAuthToken } from "@/routes/v2/pages/Tangent/hooks/useRemoteEnvAuthToken"; +import { useTangentBaseUrl } from "@/routes/v2/pages/Tangent/hooks/useTangentBaseUrl"; import { TangentProjectStore } from "@/routes/v2/pages/Tangent/store/TangentProjectStore"; import { observeWorkareaPipelineDrafts } from "@/routes/v2/pages/Tangent/workarea/observeWorkareaPipelineDrafts"; import { usePipelineStorage } from "@/services/pipelineStorage/PipelineStorageProvider"; @@ -77,6 +80,15 @@ export function TangentProjectProvider({ store.setDefaultSessionId(defaultSessionId); }, [store, defaultSessionId]); + const { baseUrl } = useTangentBaseUrl(projectId); + const authToken = useRemoteEnvAuthToken(); + useBackfillSessionTags({ + projectId, + baseUrl, + authToken, + sessionIds: sessions.map((session) => session.sessionId), + }); + usePrepareProjectArrival(store, { projectId, sessionCount: sessions.length, diff --git a/src/routes/v2/pages/Tangent/hooks/useBackfillSessionTags.ts b/src/routes/v2/pages/Tangent/hooks/useBackfillSessionTags.ts new file mode 100644 index 0000000000..1751ea342e --- /dev/null +++ b/src/routes/v2/pages/Tangent/hooks/useBackfillSessionTags.ts @@ -0,0 +1,69 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; + +import { ensureSessionTag } from "@/routes/v2/pages/Tangent/services/sessionTags"; +import { projectSelector } from "@/services/projects/projectSelector"; + +interface BackfillSessionTagsParams { + projectId: string; + baseUrl: string | null; + authToken: string | undefined; + sessionIds: string[]; +} + +/** + * Backfills the project's selector tag onto every session that predates session + * tagging, so a selector-scoped host (a VS Code window bound to the project) can + * reach them. Runs on mount and whenever the session list changes; a ref of + * already-ensured ids keeps re-renders from re-reading, and each session is + * ensured independently so one failure does not stop the rest. + */ +export function useBackfillSessionTags({ + projectId, + baseUrl, + authToken, + sessionIds, +}: BackfillSessionTagsParams): void { + const queryClient = useQueryClient(); + const ensured = useRef(new Set()); + + useEffect(() => { + if (!baseUrl || !authToken || sessionIds.length === 0) return; + + let cancelled = false; + const tag = projectSelector(projectId); + + const run = async () => { + let changed = false; + for (const sessionId of sessionIds) { + if (ensured.current.has(sessionId)) continue; + try { + const updated = await ensureSessionTag({ + baseUrl, + sessionId, + authToken, + tag, + }); + ensured.current.add(sessionId); + if (updated) changed = true; + } catch (error) { + console.error( + `Failed to backfill project tag for session ${sessionId}`, + error, + ); + } + } + if (changed && !cancelled) { + void queryClient.invalidateQueries({ + queryKey: ["tangent", "environments", baseUrl], + }); + } + }; + + void run(); + + return () => { + cancelled = true; + }; + }, [projectId, baseUrl, authToken, sessionIds, queryClient]); +} diff --git a/src/routes/v2/pages/Tangent/hooks/useProjectLocalEnv.ts b/src/routes/v2/pages/Tangent/hooks/useProjectLocalEnv.ts new file mode 100644 index 0000000000..b52a234aa5 --- /dev/null +++ b/src/routes/v2/pages/Tangent/hooks/useProjectLocalEnv.ts @@ -0,0 +1,85 @@ +import { useQueries, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { io } from "socket.io-client"; + +import { useRemoteEnvAuthToken } from "@/routes/v2/pages/Tangent/hooks/useRemoteEnvAuthToken"; +import { + listTangentEnvironments, + type TangentEnvironment, +} from "@/routes/v2/pages/Tangent/services/listTangentEnvironments"; +import { getTangentSocketConfig } from "@/routes/v2/pages/Tangent/services/socketConfig"; +import { isVscodeEnvironment } from "@/routes/v2/pages/Tangent/services/vscodeConnect"; + +const ENVIRONMENT_UPDATE_EVENT = "environment:update"; + +const ENVIRONMENTS_QUERY_SCOPE = ["tangent", "environments"] as const; + +interface ProjectLocalEnv { + localEnvironments: TangentEnvironment[]; + isLocalEnvConnected: boolean; +} + +function environmentsQueryKey(baseUrl: string | null, sessionId: string) { + return [...ENVIRONMENTS_QUERY_SCOPE, baseUrl, sessionId] as const; +} + +/** + * Whether a VS Code window is connected to the project's Shell. The extension + * connects as one generic host eligible for every session, but a session's own + * browser host masks it from that session's list, so this reads every project + * session and unions the VS Code environments found. Tangent broadcasts + * `environment:update` to every client on any host change, so one socket + * refetches the whole set. + */ +export function useProjectLocalEnv( + baseUrl: string | null, + sessionIds: string[], +): ProjectLocalEnv { + const queryClient = useQueryClient(); + const authToken = useRemoteEnvAuthToken(); + + const results = useQueries({ + queries: sessionIds.map((sessionId) => ({ + queryKey: environmentsQueryKey(baseUrl, sessionId), + queryFn: () => { + if (!baseUrl) return []; + return listTangentEnvironments({ baseUrl, sessionId, authToken }); + }, + enabled: Boolean(baseUrl), + })), + }); + + useEffect(() => { + if (!baseUrl) return; + + const { socketUrl, socketPath } = getTangentSocketConfig(baseUrl); + const socket = io(socketUrl, { + path: socketPath, + auth: authToken ? { token: authToken } : {}, + }); + socket.on(ENVIRONMENT_UPDATE_EVENT, () => { + void queryClient.invalidateQueries({ + queryKey: [...ENVIRONMENTS_QUERY_SCOPE, baseUrl], + }); + }); + + return () => { + socket.disconnect(); + }; + }, [baseUrl, authToken, queryClient]); + + const byId = new Map(); + for (const result of results) { + for (const environment of result.data ?? []) { + if (isVscodeEnvironment(environment)) { + byId.set(environment.environmentId, environment); + } + } + } + const localEnvironments = [...byId.values()]; + + return { + localEnvironments, + isLocalEnvConnected: localEnvironments.length > 0, + }; +} diff --git a/src/routes/v2/pages/Tangent/services/listTangentEnvironments.test.ts b/src/routes/v2/pages/Tangent/services/listTangentEnvironments.test.ts new file mode 100644 index 0000000000..3d7ac55a64 --- /dev/null +++ b/src/routes/v2/pages/Tangent/services/listTangentEnvironments.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { listTangentEnvironments } from "./listTangentEnvironments"; + +function mockFetch(response: { + ok: boolean; + status?: number; + statusText?: string; + json?: () => Promise; +}) { + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ + ok: response.ok, + status: response.status ?? 200, + statusText: response.statusText ?? "OK", + json: response.json ?? (async () => ({})), + })); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +describe("listTangentEnvironments", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("requests the session's environments with the bearer token", async () => { + const fetchMock = mockFetch({ + ok: true, + json: async () => ({ environments: [] }), + }); + + await listTangentEnvironments({ + baseUrl: "http://tangent/", + sessionId: "s1", + authToken: "jwt", + }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("http://tangent/api/sessions/s1/environments"); + expect(init?.headers).toEqual({ Authorization: "Bearer jwt" }); + }); + + it("parses environments and drops malformed entries", async () => { + mockFetch({ + ok: true, + json: async () => ({ + environments: [ + { + environmentId: "s1:vscode-abc", + owner: "me@example.com", + scoped: true, + toolCount: 3, + subagentCount: 1, + }, + { environmentId: "" }, + "not-an-environment", + ], + }), + }); + + const result = await listTangentEnvironments({ + baseUrl: "http://tangent", + sessionId: "s1", + }); + + expect(result).toEqual([ + { + environmentId: "s1:vscode-abc", + owner: "me@example.com", + scoped: true, + toolCount: 3, + subagentCount: 1, + }, + ]); + }); + + it("keeps string metadata pairs and drops non-string values", async () => { + mockFetch({ + ok: true, + json: async () => ({ + environments: [ + { + environmentId: "s1:vscode-abc", + scoped: false, + toolCount: 0, + subagentCount: 0, + metadata: { + windowName: "my-workspace", + workspacePath: "/home/me/proj", + editor: "Cursor", + portCount: 3, + }, + }, + ], + }), + }); + + const [environment] = await listTangentEnvironments({ + baseUrl: "http://tangent", + sessionId: "s1", + }); + + expect(environment.metadata).toEqual({ + windowName: "my-workspace", + workspacePath: "/home/me/proj", + editor: "Cursor", + }); + }); + + it("omits metadata when none is advertised", async () => { + mockFetch({ + ok: true, + json: async () => ({ + environments: [ + { + environmentId: "s1:vscode-abc", + scoped: false, + toolCount: 0, + subagentCount: 0, + }, + ], + }), + }); + + const [environment] = await listTangentEnvironments({ + baseUrl: "http://tangent", + sessionId: "s1", + }); + + expect(environment.metadata).toBeUndefined(); + }); + + it("throws on a malformed response body", async () => { + mockFetch({ ok: true, json: async () => ({ nope: true }) }); + + await expect( + listTangentEnvironments({ baseUrl: "http://tangent", sessionId: "s1" }), + ).rejects.toThrow(/malformed/); + }); + + it("throws on a non-ok response", async () => { + mockFetch({ ok: false, status: 404, statusText: "Not Found" }); + + await expect( + listTangentEnvironments({ baseUrl: "http://tangent", sessionId: "s1" }), + ).rejects.toThrow(/404/); + }); +}); diff --git a/src/routes/v2/pages/Tangent/services/listTangentEnvironments.ts b/src/routes/v2/pages/Tangent/services/listTangentEnvironments.ts new file mode 100644 index 0000000000..3fd46f70b0 --- /dev/null +++ b/src/routes/v2/pages/Tangent/services/listTangentEnvironments.ts @@ -0,0 +1,82 @@ +import { isRecord } from "@/utils/typeGuards"; + +/** + * Mirrors Tangent's `RemoteEnvironmentInfo`: a connected host (a VS Code + * window, an embed host) that can run a session's remote sub-agents and tools. + */ +export interface TangentEnvironment { + environmentId: string; + sessionId?: string; + owner?: string; + scoped: boolean; + toolCount: number; + subagentCount: number; + metadata?: Record; +} + +interface ListTangentEnvironmentsParams { + baseUrl: string; + sessionId: string; + authToken?: string; +} + +function parseMetadata(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined; + const entries = Object.entries(value).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function parseEnvironment(value: unknown): TangentEnvironment | null { + if (!isRecord(value)) return null; + const { + environmentId, + sessionId, + owner, + scoped, + toolCount, + subagentCount, + metadata, + } = value; + if (typeof environmentId !== "string" || environmentId.length === 0) { + return null; + } + const parsedMetadata = parseMetadata(metadata); + return { + environmentId, + ...(typeof sessionId === "string" ? { sessionId } : {}), + ...(typeof owner === "string" ? { owner } : {}), + scoped: scoped === true, + toolCount: typeof toolCount === "number" ? toolCount : 0, + subagentCount: typeof subagentCount === "number" ? subagentCount : 0, + ...(parsedMetadata ? { metadata: parsedMetadata } : {}), + }; +} + +function parseEnvironmentsResponse(value: unknown): TangentEnvironment[] { + if (!isRecord(value) || !Array.isArray(value.environments)) { + throw new Error("Tangent environments response was malformed."); + } + return value.environments + .map(parseEnvironment) + .filter((environment) => environment !== null); +} + +export async function listTangentEnvironments({ + baseUrl, + sessionId, + authToken, +}: ListTangentEnvironmentsParams): Promise { + const url = `${baseUrl.replace(/\/+$/, "")}/api/sessions/${encodeURIComponent(sessionId)}/environments`; + const headers: Record = {}; + if (authToken) headers.Authorization = `Bearer ${authToken}`; + + const response = await fetch(url, { headers }); + if (!response.ok) { + throw new Error( + `Failed to list Tangent environments (${response.status} ${response.statusText}).`, + ); + } + return parseEnvironmentsResponse(await response.json()); +} diff --git a/src/routes/v2/pages/Tangent/services/sessionTags.test.ts b/src/routes/v2/pages/Tangent/services/sessionTags.test.ts new file mode 100644 index 0000000000..a0ec6dd9a4 --- /dev/null +++ b/src/routes/v2/pages/Tangent/services/sessionTags.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + ensureSessionTag, + getTangentSessionTags, + replaceTangentSessionTags, +} from "./sessionTags"; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("ensureSessionTag", () => { + it("skips the write when the tag is already present", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + jsonResponse({ session: { tags: ["projectId:p1", "other"] } }), + ); + + const changed = await ensureSessionTag({ + baseUrl: "http://shell.test", + sessionId: "session-a", + authToken: "token", + tag: "projectId:p1", + }); + + expect(changed).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("adds the tag to the existing set when missing, preserving others", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(jsonResponse({ session: { tags: ["other"] } })) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + + const changed = await ensureSessionTag({ + baseUrl: "http://shell.test/", + sessionId: "session-a", + authToken: "token", + tag: "projectId:p1", + }); + + expect(changed).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const [url, init] = fetchMock.mock.calls[1]; + expect(url).toBe("http://shell.test/api/sessions/session-a"); + expect(init?.method).toBe("PATCH"); + expect(JSON.parse(String(init?.body))).toEqual({ + tags: ["other", "projectId:p1"], + }); + const headers = init?.headers as Record; + expect(headers.Authorization).toBe("Bearer token"); + }); + + it("treats a session with no tags as empty and writes the tag", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(jsonResponse({ session: {} })) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + + const changed = await ensureSessionTag({ + baseUrl: "http://shell.test", + sessionId: "session-a", + tag: "projectId:p1", + }); + + expect(changed).toBe(true); + }); +}); + +describe("getTangentSessionTags", () => { + it("reads string tags and omits the bearer header when unauthenticated", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(jsonResponse({ session: { tags: ["a", "b"] } })); + + const tags = await getTangentSessionTags({ + baseUrl: "http://shell.test", + sessionId: "session-a", + }); + + expect(tags).toEqual(["a", "b"]); + const headers = fetchMock.mock.calls[0][1]?.headers as Record< + string, + string + >; + expect(headers.Authorization).toBeUndefined(); + }); + + it("throws when the response is not ok", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(null, { status: 404, statusText: "Not Found" }), + ); + + await expect( + getTangentSessionTags({ + baseUrl: "http://shell.test", + sessionId: "missing", + }), + ).rejects.toThrow(/Failed to read Tangent session/); + }); +}); + +describe("replaceTangentSessionTags", () => { + it("PATCHes the provided tags with a bearer header", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(null, { status: 200 })); + + await replaceTangentSessionTags({ + baseUrl: "http://shell.test", + sessionId: "session-a", + authToken: "token", + tags: ["x"], + }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("http://shell.test/api/sessions/session-a"); + expect(init?.method).toBe("PATCH"); + expect(JSON.parse(String(init?.body))).toEqual({ tags: ["x"] }); + const headers = init?.headers as Record; + expect(headers.Authorization).toBe("Bearer token"); + }); + + it("throws when the response is not ok", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(null, { status: 500, statusText: "Server Error" }), + ); + + await expect( + replaceTangentSessionTags({ + baseUrl: "http://shell.test", + sessionId: "session-a", + tags: ["x"], + }), + ).rejects.toThrow(/Failed to update Tangent session tags/); + }); +}); diff --git a/src/routes/v2/pages/Tangent/services/sessionTags.ts b/src/routes/v2/pages/Tangent/services/sessionTags.ts new file mode 100644 index 0000000000..c0829138da --- /dev/null +++ b/src/routes/v2/pages/Tangent/services/sessionTags.ts @@ -0,0 +1,81 @@ +import { isRecord } from "@/utils/typeGuards"; + +interface SessionTagParams { + baseUrl: string; + sessionId: string; + authToken?: string; +} + +function sessionUrl(baseUrl: string, sessionId: string): string { + return `${baseUrl.replace(/\/+$/, "")}/api/sessions/${encodeURIComponent(sessionId)}`; +} + +function authHeaders(authToken?: string): Record { + return authToken ? { Authorization: `Bearer ${authToken}` } : {}; +} + +function parseSessionTags(value: unknown): string[] { + if (!isRecord(value) || !isRecord(value.session)) { + throw new Error("Tangent session response was malformed."); + } + const { tags } = value.session; + if (!Array.isArray(tags)) return []; + return tags.filter((tag): tag is string => typeof tag === "string"); +} + +export async function getTangentSessionTags({ + baseUrl, + sessionId, + authToken, +}: SessionTagParams): Promise { + const response = await fetch(sessionUrl(baseUrl, sessionId), { + headers: authHeaders(authToken), + }); + if (!response.ok) { + throw new Error( + `Failed to read Tangent session (${response.status} ${response.statusText}).`, + ); + } + return parseSessionTags(await response.json()); +} + +export async function replaceTangentSessionTags({ + baseUrl, + sessionId, + authToken, + tags, +}: SessionTagParams & { tags: string[] }): Promise { + const response = await fetch(sessionUrl(baseUrl, sessionId), { + method: "PATCH", + headers: { "Content-Type": "application/json", ...authHeaders(authToken) }, + body: JSON.stringify({ tags }), + }); + if (!response.ok) { + throw new Error( + `Failed to update Tangent session tags (${response.status} ${response.statusText}).`, + ); + } +} + +/** + * Ensures `tag` is among the session's tags, adding it to the existing set when + * missing. Returns whether a write was needed, so a caller can refresh only when + * routing actually changed. The server replaces the whole array, so the union is + * sent to preserve any tags this client does not know about. + */ +export async function ensureSessionTag({ + baseUrl, + sessionId, + authToken, + tag, +}: SessionTagParams & { tag: string }): Promise { + const tags = await getTangentSessionTags({ baseUrl, sessionId, authToken }); + if (tags.includes(tag)) return false; + await replaceTangentSessionTags({ + baseUrl, + sessionId, + authToken, + tags: [...tags, tag], + }); + return true; +} diff --git a/src/routes/v2/pages/Tangent/services/vscodeConnect.test.ts b/src/routes/v2/pages/Tangent/services/vscodeConnect.test.ts new file mode 100644 index 0000000000..9784690080 --- /dev/null +++ b/src/routes/v2/pages/Tangent/services/vscodeConnect.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import type { TangentEnvironment } from "./listTangentEnvironments"; +import { + buildCursorConnectUrl, + buildVscodeConnectUrl, + isVscodeEnvironment, +} from "./vscodeConnect"; + +function environment(environmentId: string): TangentEnvironment { + return { environmentId, scoped: true, toolCount: 0, subagentCount: 0 }; +} + +describe("isVscodeEnvironment", () => { + it("matches a VS Code host attached to a session", () => { + expect(isVscodeEnvironment(environment("s1:vscode-abc123"))).toBe(true); + }); + + it("matches an unscoped VS Code dev host", () => { + expect(isVscodeEnvironment(environment("vscode-abc123"))).toBe(true); + }); + + it("ignores the browser's own workarea and tab hosts", () => { + expect(isVscodeEnvironment(environment("s1:workarea"))).toBe(false); + expect(isVscodeEnvironment(environment("s1:tab-7"))).toBe(false); + expect(isVscodeEnvironment(environment("tangle-ui-x1y2z3"))).toBe(false); + }); +}); + +describe("buildVscodeConnectUrl", () => { + it("builds the extension's connect deep link with an encoded shell url", () => { + expect( + buildVscodeConnectUrl({ shellUrl: "https://oasis.example/tangent" }), + ).toBe( + "vscode://tangent.tangent-local-agent/connect?url=https%3A%2F%2Foasis.example%2Ftangent", + ); + }); + + it("appends the api url and project so the editor applies them immediately", () => { + expect( + buildVscodeConnectUrl({ + shellUrl: "https://oasis.example/tangent", + apiUrl: "https://api.example", + projectId: "p1", + projectName: "My Project", + }), + ).toBe( + "vscode://tangent.tangent-local-agent/connect?url=https%3A%2F%2Foasis.example%2Ftangent&apiUrl=https%3A%2F%2Fapi.example&projectId=p1&projectName=My+Project", + ); + }); +}); + +describe("buildCursorConnectUrl", () => { + it("builds the extension's connect deep link under the cursor scheme", () => { + expect( + buildCursorConnectUrl({ shellUrl: "https://oasis.example/tangent" }), + ).toBe( + "cursor://tangent.tangent-local-agent/connect?url=https%3A%2F%2Foasis.example%2Ftangent", + ); + }); +}); diff --git a/src/routes/v2/pages/Tangent/services/vscodeConnect.ts b/src/routes/v2/pages/Tangent/services/vscodeConnect.ts new file mode 100644 index 0000000000..f3708d2c38 --- /dev/null +++ b/src/routes/v2/pages/Tangent/services/vscodeConnect.ts @@ -0,0 +1,37 @@ +import type { TangentEnvironment } from "@/routes/v2/pages/Tangent/services/listTangentEnvironments"; + +const EXTENSION_ID = "tangent.tangent-local-agent"; + +// The extension connects as `vscode-`, prefixed with `:` +// once it is attached to a session. +const VSCODE_ENVIRONMENT_MARKER = "vscode-"; + +export function isVscodeEnvironment(environment: TangentEnvironment): boolean { + return environment.environmentId.includes(VSCODE_ENVIRONMENT_MARKER); +} + +export interface ConnectUrlParams { + shellUrl: string; + apiUrl?: string; + projectId?: string; + projectName?: string; +} + +function buildConnectUrl( + scheme: "vscode" | "cursor", + { shellUrl, apiUrl, projectId, projectName }: ConnectUrlParams, +): string { + const query = new URLSearchParams({ url: shellUrl }); + if (apiUrl) query.set("apiUrl", apiUrl); + if (projectId) query.set("projectId", projectId); + if (projectName) query.set("projectName", projectName); + return `${scheme}://${EXTENSION_ID}/connect?${query.toString()}`; +} + +export function buildVscodeConnectUrl(params: ConnectUrlParams): string { + return buildConnectUrl("vscode", params); +} + +export function buildCursorConnectUrl(params: ConnectUrlParams): string { + return buildConnectUrl("cursor", params); +} diff --git a/src/routes/v2/pages/Tangent/store/TangentProjectStore.test.ts b/src/routes/v2/pages/Tangent/store/TangentProjectStore.test.ts index 016efdc6bb..29422f6425 100644 --- a/src/routes/v2/pages/Tangent/store/TangentProjectStore.test.ts +++ b/src/routes/v2/pages/Tangent/store/TangentProjectStore.test.ts @@ -466,6 +466,7 @@ describe("TangentProjectStore.startSession", () => { }, rootConfigSeed("project-1").resource, ], + tags: ["projectId:project-1"], env: { TANGLE_ROOT_CONFIG: "{{uploadsPath}}/tangle-deploy-root-config.yaml", }, @@ -476,6 +477,18 @@ describe("TangentProjectStore.startSession", () => { }); }); + it("tags the session with the project selector so project hosts serve it", async () => { + const store = new TangentProjectStore("project-42"); + const io = makeSessionIo(null); + store.setSessionIo(io); + + await store.startSession(); + + expect(io.newSession.mock.calls[0][2].tags).toEqual([ + "projectId:project-42", + ]); + }); + /** The naming brief is seeded whether or not the project says anything. */ it("still seeds a session in a project with no instructions", async () => { const store = new TangentProjectStore("project-1"); diff --git a/src/routes/v2/pages/Tangent/store/TangentProjectStore.ts b/src/routes/v2/pages/Tangent/store/TangentProjectStore.ts index 28e739ac81..d75b8354e7 100644 --- a/src/routes/v2/pages/Tangent/store/TangentProjectStore.ts +++ b/src/routes/v2/pages/Tangent/store/TangentProjectStore.ts @@ -25,6 +25,7 @@ import type { import { resolveChatEntity } from "@/routes/v2/shared/components/AiChat/components/resolveChatEntity"; import { navigateToEntity } from "@/routes/v2/shared/store/focus.actions"; import type { SharedUIStore } from "@/routes/v2/shared/store/SharedStoreContext"; +import { projectSelector } from "@/services/projects/projectSelector"; import { idIdentity, sameTarget } from "@/services/projects/resourceTarget"; import { getErrorMessage } from "@/utils/string"; @@ -71,6 +72,7 @@ export interface TangentSessionIo { env?: Record; model?: string; thinkingDepth?: ThinkingLevel; + tags?: string[]; }, ) => Promise<{ sessionId: string }>; attachSession: (sessionId: string, name?: string) => Promise<{ id: string }>; @@ -233,6 +235,7 @@ export class TangentProjectStore { env: rootConfig.env, model: options?.model, thinkingDepth: options?.thinkingDepth, + tags: [projectSelector(this.projectId)], }); const resource = await io.attachSession(sessionId, options?.name); runInAction(() => { diff --git a/src/services/projects/projectSelector.ts b/src/services/projects/projectSelector.ts new file mode 100644 index 0000000000..aed1331dc3 --- /dev/null +++ b/src/services/projects/projectSelector.ts @@ -0,0 +1,9 @@ +const PROJECT_SELECTOR_PREFIX = "projectId"; + +/** + * The selector tag a session carries so a selector-scoped remote-env host + * minted with the same `projectId:` serves every session in the project. + */ +export function projectSelector(projectId: string): string { + return `${PROJECT_SELECTOR_PREFIX}:${projectId}`; +}