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
+
+
+
+
+
+
+
+
+
+ -
+ Open the Extensions view, then its “…” menu.
+
+ -
+
+ Choose “Install from VSIX…” and pick the downloaded file.
+
+
+ -
+ Reload or restart the editor to activate it.
+
+
+
+
+ );
+}
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.
+
+
+
+ -
+
+
+ Install the Tangent Local Agent extension in VS Code or
+ Cursor.
+
+
+
+ {CONNECT_STEPS.map((step) => (
+ -
+ {step}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ 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}`;
+}