diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index ace47c666b..7fe5444f72 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -2776,6 +2776,12 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { textContent := getTextResult(t, result) assert.Contains(t, textContent.Text, "interactive form has been shown to the user for creating a new pull request") assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") + + // github/github-mcp-server#2965: remount persistence keys off _meta.viewUUID. + require.NotNil(t, result.Meta, "deferral must include Meta for viewUUID") + viewUUID, ok := result.Meta["viewUUID"].(string) + require.True(t, ok, "viewUUID should be a string") + assert.NotEmpty(t, viewUUID) }) t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) { diff --git a/pkg/utils/result.go b/pkg/utils/result.go index 99c37602bc..d0bff31ffe 100644 --- a/pkg/utils/result.go +++ b/pkg/utils/result.go @@ -1,6 +1,12 @@ package utils //nolint:revive //TODO: figure out a better name for this package -import "github.com/modelcontextprotocol/go-sdk/mcp" +import ( + "crypto/rand" + "fmt" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) func NewToolResultText(message string) *mcp.CallToolResult { return &mcp.CallToolResult{ @@ -69,6 +75,10 @@ func NewToolResultResourceLink(message string, link *mcp.ResourceLink) *mcp.Call // result. The MCP App form will submit the operation directly when the user // clicks submit, after which a ui/update-model-context call delivers the real // outcome to the agent. +// +// A stable Meta.viewUUID is included so MCP Apps can persist view state (e.g. +// a completed success card) across host remounts via localStorage, per the +// MCP Apps "Persisting view state" pattern. func NewToolResultAwaitingFormSubmission(message string) *mcp.CallToolResult { return &mcp.CallToolResult{ Content: []mcp.Content{ @@ -81,5 +91,23 @@ func NewToolResultAwaitingFormSubmission(message string) *mcp.CallToolResult { "reason": "An interactive form is being shown to the user. The operation has not been performed.", }, IsError: true, + Meta: mcp.Meta{ + "viewUUID": newViewUUID(), + }, + } +} + +// newViewUUID returns a random UUID v4 string without pulling in an extra +// dependency. Used as the MCP Apps view persistence key. +func newViewUUID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + // Extremely unlikely. Do not return a constant key (shared storage key → + // cross-view leakage); make it unique enough for a view persistence key. + return fmt.Sprintf("view-%d", time.Now().UnixNano()) } + // UUID version 4 + RFC 4122 variant bits. + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) } diff --git a/pkg/utils/result_test.go b/pkg/utils/result_test.go new file mode 100644 index 0000000000..ba0728255b --- /dev/null +++ b/pkg/utils/result_test.go @@ -0,0 +1,63 @@ +package utils + +import ( + "encoding/json" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewToolResultAwaitingFormSubmission_IncludesViewUUID(t *testing.T) { + t.Parallel() + + result := NewToolResultAwaitingFormSubmission("showing form") + require.NotNil(t, result) + assert.True(t, result.IsError) + require.NotNil(t, result.Meta) + + viewUUID, ok := result.Meta["viewUUID"].(string) + require.True(t, ok, "viewUUID should be a string") + assert.NotEmpty(t, viewUUID) + assert.Regexp(t, `^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, viewUUID) + + status, ok := result.StructuredContent.(map[string]any)["status"] + require.True(t, ok) + assert.Equal(t, "awaiting_user_submission", status) +} + +func TestNewToolResultAwaitingFormSubmission_UniqueViewUUIDs(t *testing.T) { + t.Parallel() + + a := NewToolResultAwaitingFormSubmission("a") + b := NewToolResultAwaitingFormSubmission("b") + require.NotEqual(t, a.Meta["viewUUID"], b.Meta["viewUUID"]) +} + +// TestNewToolResultAwaitingFormSubmission_WireFormatMeta verifies the host-facing +// JSON shape: MCP Apps read result._meta.viewUUID after the host re-delivers the +// original deferral on remount (github/github-mcp-server#2965). +func TestNewToolResultAwaitingFormSubmission_WireFormatMeta(t *testing.T) { + t.Parallel() + + result := NewToolResultAwaitingFormSubmission("showing form") + raw, err := json.Marshal(result) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(raw, &payload)) + + meta, ok := payload["_meta"].(map[string]any) + require.True(t, ok, "wire JSON must include _meta; got: %s", string(raw)) + viewUUID, ok := meta["viewUUID"].(string) + require.True(t, ok, "_meta.viewUUID must be a string; got: %s", string(raw)) + assert.NotEmpty(t, viewUUID) + assert.Equal(t, true, payload["isError"]) + + // Round-trip: host re-sends the same JSON → UI must recover the same UUID. + var decoded mcp.CallToolResult + require.NoError(t, json.Unmarshal(raw, &decoded)) + require.NotNil(t, decoded.Meta) + assert.Equal(t, viewUUID, decoded.Meta["viewUUID"]) +} diff --git a/ui/package.json b/ui/package.json index 9644b72d9e..fce6540185 100644 --- a/ui/package.json +++ b/ui/package.json @@ -11,6 +11,7 @@ "build": "node scripts/build.mjs", "dev": "npm run build", "typecheck": "tsc --noEmit", + "test": "node --experimental-strip-types --test src/lib/viewState.test.ts", "clean": "rm -rf dist" }, "dependencies": { diff --git a/ui/src/apps/issue-write/App.tsx b/ui/src/apps/issue-write/App.tsx index 95a549f28b..5f05991e36 100644 --- a/ui/src/apps/issue-write/App.tsx +++ b/ui/src/apps/issue-write/App.tsx @@ -25,6 +25,7 @@ import { import { AppProvider } from "../../components/AppProvider"; import { useMcpApp } from "../../hooks/useMcpApp"; import { completedToolResult } from "../../lib/toolResult"; +import { getViewUUID, loadViewState, saveViewState } from "../../lib/viewState"; import { MarkdownEditor } from "../../components/MarkdownEditor"; interface IssueResult { @@ -449,7 +450,17 @@ function CreateIssueApp() { // null here, keeping the form for the normal deferred flow. // See github/copilot-mcp-core#1864. const resultIssue = useMemo(() => completedToolResult(toolResult), [toolResult]); - const shownIssue = successIssue ?? resultIssue; + // Restore synchronously during render so a remount never paints the + // create/update form for a completed invocation (github/github-mcp-server#2965). + const persistedIssue = useMemo(() => { + if (successIssue || resultIssue) return null; + const viewUUID = getViewUUID(toolResult); + if (!viewUUID) return null; + return loadViewState(viewUUID); + }, [successIssue, resultIssue, toolResult]); + const shownIssue = successIssue ?? resultIssue ?? persistedIssue?.result ?? null; + const shownSubmittedTitle = persistedIssue?.submittedTitle || title; + const shownSubmittedLabels = persistedIssue?.extras?.submittedLabels || selectedLabels; // Get method and issue_number from toolInput const method = (toolInput?.method as string) || "create"; @@ -1083,6 +1094,15 @@ function CreateIssueApp() { try { const issueData = JSON.parse(textContent.text as string); setSuccessIssue(issueData); + const viewUUID = getViewUUID(toolResult); + if (viewUUID) { + saveViewState(viewUUID, { + status: "completed", + result: issueData, + submittedTitle: title.trim(), + extras: { submittedLabels: selectedLabels }, + }); + } // Per the MCP Apps 2026-01-26 spec, push the created/updated issue // into the model's context so subsequent agent turns have it. void setModelContext({ @@ -1097,7 +1117,17 @@ function CreateIssueApp() { ], }); } catch { - setSuccessIssue({ title, body }); + const fallback = { title, body }; + setSuccessIssue(fallback); + const viewUUID = getViewUUID(toolResult); + if (viewUUID) { + saveViewState(viewUUID, { + status: "completed", + result: fallback, + submittedTitle: title.trim(), + extras: { submittedLabels: selectedLabels }, + }); + } } } } @@ -1123,6 +1153,7 @@ function CreateIssueApp() { fieldValues, issueFieldsByName, toolInput, + toolResult, callTool, setModelContext, ]); @@ -1228,8 +1259,8 @@ function CreateIssueApp() { issue={shownIssue} owner={owner} repo={repo} - submittedTitle={title} - submittedLabels={selectedLabels} + submittedTitle={shownSubmittedTitle} + submittedLabels={shownSubmittedLabels} isUpdate={isUpdateMode} openLink={openLink} /> diff --git a/ui/src/apps/pr-edit/App.tsx b/ui/src/apps/pr-edit/App.tsx index 5b4995affc..f1df162842 100644 --- a/ui/src/apps/pr-edit/App.tsx +++ b/ui/src/apps/pr-edit/App.tsx @@ -26,6 +26,7 @@ import { import { AppProvider } from "../../components/AppProvider"; import { useMcpApp } from "../../hooks/useMcpApp"; import { completedToolResult } from "../../lib/toolResult"; +import { getViewUUID, loadViewState, saveViewState } from "../../lib/viewState"; import { MarkdownEditor } from "../../components/MarkdownEditor"; interface PRResult { @@ -283,7 +284,16 @@ function EditPRApp() { // (awaiting_user_submission) returns null here, keeping the form for the // normal deferred flow. See github/copilot-mcp-core#1864. const resultPR = useMemo(() => completedToolResult(toolResult), [toolResult]); - const shownPR = successPR ?? resultPR; + // Restore synchronously during render so a remount never paints the edit form + // for a completed invocation (github/github-mcp-server#2965). + const persistedPR = useMemo(() => { + if (successPR || resultPR) return null; + const viewUUID = getViewUUID(toolResult); + if (!viewUUID) return null; + return loadViewState(viewUUID); + }, [successPR, resultPR, toolResult]); + const shownPR = successPR ?? resultPR ?? persistedPR?.result ?? null; + const shownSubmittedTitle = submittedTitle || persistedPR?.submittedTitle || ""; useEffect(() => { setTitle(""); @@ -487,6 +497,14 @@ function EditPRApp() { if (textContent && textContent.type === "text" && textContent.text) { const prData = JSON.parse(textContent.text); setSuccessPR(prData); + const viewUUID = getViewUUID(toolResult); + if (viewUUID) { + saveViewState(viewUUID, { + status: "completed", + result: prData, + submittedTitle: title.trim(), + }); + } void setModelContext({ structuredContent: prData, content: [ @@ -503,12 +521,12 @@ function EditPRApp() { } finally { setIsSubmitting(false); } - }, [title, body, owner, repo, pullNumber, baseBranch, initialValues, selectedReviewers, prState, isDraft, maintainerCanModify, callTool, setModelContext]); + }, [title, body, owner, repo, pullNumber, baseBranch, initialValues, selectedReviewers, prState, isDraft, maintainerCanModify, toolResult, callTool, setModelContext]); if (shownPR) { return ( - + ); } diff --git a/ui/src/apps/pr-write/App.tsx b/ui/src/apps/pr-write/App.tsx index 6975434bea..223429e1a2 100644 --- a/ui/src/apps/pr-write/App.tsx +++ b/ui/src/apps/pr-write/App.tsx @@ -28,6 +28,7 @@ import { import { AppProvider } from "../../components/AppProvider"; import { useMcpApp } from "../../hooks/useMcpApp"; import { completedToolResult } from "../../lib/toolResult"; +import { getViewUUID, loadViewState, saveViewState } from "../../lib/viewState"; import { MarkdownEditor } from "../../components/MarkdownEditor"; interface PRResult { @@ -206,7 +207,17 @@ function CreatePRApp() { // (awaiting_user_submission) returns null here, keeping the form for the // normal deferred flow. See github/copilot-mcp-core#1864. const resultPR = useMemo(() => completedToolResult(toolResult), [toolResult]); - const shownPR = successPR ?? resultPR; + // Restore synchronously during render so a remount never paints the create + // form for a completed invocation (github/github-mcp-server#2965). React + // success state and up-front (non-deferred) results take precedence. + const persistedPR = useMemo(() => { + if (successPR || resultPR) return null; + const viewUUID = getViewUUID(toolResult); + if (!viewUUID) return null; + return loadViewState(viewUUID); + }, [successPR, resultPR, toolResult]); + const shownPR = successPR ?? resultPR ?? persistedPR?.result ?? null; + const shownSubmittedTitle = submittedTitle || persistedPR?.submittedTitle || ""; // Reset all transient form/result state when toolInput changes (new invocation). // Without this, the SuccessView from a previous submit stays visible and stale @@ -431,6 +442,14 @@ function CreatePRApp() { if (textContent && textContent.type === "text" && textContent.text) { const prData = JSON.parse(textContent.text); setSuccessPR(prData); + const viewUUID = getViewUUID(toolResult); + if (viewUUID) { + saveViewState(viewUUID, { + status: "completed", + result: prData, + submittedTitle: title.trim(), + }); + } // Push the new PR into the model context so subsequent agent // turns can reference it (MCP Apps 2026-01-26 ui/update-model-context). void setModelContext({ @@ -449,12 +468,12 @@ function CreatePRApp() { } finally { setIsSubmitting(false); } - }, [title, body, owner, repo, baseBranch, headBranch, isDraft, maintainerCanModify, selectedReviewers, toolInput, callTool, setModelContext]); + }, [title, body, owner, repo, baseBranch, headBranch, isDraft, maintainerCanModify, selectedReviewers, toolInput, toolResult, callTool, setModelContext]); if (shownPR) { return ( - + ); } diff --git a/ui/src/lib/viewState.test.ts b/ui/src/lib/viewState.test.ts new file mode 100644 index 0000000000..73f920535b --- /dev/null +++ b/ui/src/lib/viewState.test.ts @@ -0,0 +1,207 @@ +/** + * Remount-persistence regression for github/github-mcp-server#2965. + * + * Simulates the MCP App lifecycle without VS Code: + * 1. Host delivers a deferred tool-result (awaiting_user_submission + _meta.viewUUID) + * 2. User submits → app saves SuccessView payload to localStorage under that UUID + * 3. Host remounts the iframe and re-delivers the *same* deferral (not the submit result) + * 4. App must restore SuccessView from localStorage — not fall back to the create form + * + * Run: node --experimental-strip-types --test src/lib/viewState.test.ts + * (from ui/, after npm ci) + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { completedToolResult } from "./toolResult.ts"; +import { + getViewUUID, + loadViewState, + saveViewState, + type CompletedViewState, +} from "./viewState.ts"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +type PRResult = { + number: number; + title: string; + html_url: string; +}; + +function installMemoryLocalStorage() { + const store = new Map(); + const localStorage = { + getItem(key: string) { + return store.has(key) ? store.get(key)! : null; + }, + setItem(key: string, value: string) { + store.set(key, value); + }, + removeItem(key: string) { + store.delete(key); + }, + clear() { + store.clear(); + }, + }; + Object.defineProperty(globalThis, "localStorage", { + value: localStorage, + configurable: true, + }); + return store; +} + +function deferredToolResult(viewUUID: string): CallToolResult { + return { + isError: true, + content: [{ type: "text", text: "An interactive form has been shown to the user." }], + structuredContent: { + status: "awaiting_user_submission", + reason: "An interactive form is being shown to the user. The operation has not been performed.", + }, + _meta: { viewUUID }, + }; +} + +/** + * Mirrors pr-write / pr-edit / issue-write shownX = successX ?? completedToolResult(toolResult) + * plus the remount restore effect keyed by viewUUID. + */ +function resolveShownResult( + toolResult: CallToolResult | null, + successFromReactState: T | null, +): { shown: T | null; source: "react" | "completed-result" | "localStorage" | "none" } { + if (successFromReactState) { + return { shown: successFromReactState, source: "react" }; + } + const fromResult = completedToolResult(toolResult); + if (fromResult) { + return { shown: fromResult, source: "completed-result" }; + } + const viewUUID = getViewUUID(toolResult); + if (viewUUID) { + const saved = loadViewState(viewUUID); + if (saved) { + return { shown: saved.result, source: "localStorage" }; + } + } + return { shown: null, source: "none" }; +} + +test("getViewUUID reads _meta.viewUUID from host-delivered deferral", () => { + const viewUUID = "11111111-2222-4333-8444-555555555555"; + assert.equal(getViewUUID(deferredToolResult(viewUUID)), viewUUID); + assert.equal(getViewUUID(null), undefined); + assert.equal(getViewUUID({ content: [], _meta: {} }), undefined); + assert.equal(getViewUUID({ content: [], _meta: { viewUUID: 123 } as never }), undefined); +}); + +test("completedToolResult treats deferral as incomplete (form stays open)", () => { + const deferral = deferredToolResult("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"); + assert.equal(completedToolResult(deferral), null); +}); + +test("#2965 remount: without persistence, SuccessView is lost (pre-fix behavior)", () => { + installMemoryLocalStorage(); + const viewUUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + const deferral = deferredToolResult(viewUUID); + const createdPR: PRResult = { + number: 42, + title: "Fix stuff", + html_url: "https://github.com/o/r/pull/42", + }; + + // In-session: React success state shows SuccessView. + let successPR: PRResult | null = createdPR; + assert.equal(resolveShownResult(deferral, successPR).source, "react"); + + // Remount: React state wiped; host re-sends deferral; no localStorage save. + successPR = null; + const afterRemount = resolveShownResult(deferral, successPR); + assert.equal(afterRemount.source, "none"); + assert.equal(afterRemount.shown, null, "BUG #2965: form would show again"); +}); + +test("#2965 remount: with viewUUID localStorage, SuccessView is restored (fix)", () => { + installMemoryLocalStorage(); + const viewUUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + const deferral = deferredToolResult(viewUUID); + const createdPR: PRResult = { + number: 42, + title: "Fix stuff", + html_url: "https://github.com/o/r/pull/42", + }; + + // Submit success → persist under deferral viewUUID (what the apps do now). + saveViewState(viewUUID, { + status: "completed", + result: createdPR, + submittedTitle: "Fix stuff", + } satisfies Omit, "savedAt">); + + // Remount: React state gone; host re-delivers same deferral. + const afterRemount = resolveShownResult(deferral, null); + assert.equal(afterRemount.source, "localStorage"); + assert.deepEqual(afterRemount.shown, createdPR); +}); + +test("saveViewState stores namespaced key with savedAt timestamp", () => { + const store = installMemoryLocalStorage(); + const viewUUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + saveViewState(viewUUID, { + status: "completed", + result: { number: 1, title: "t", html_url: "https://example.com/1" }, + }); + + const keys = [...store.keys()]; + assert.equal(keys.length, 1); + assert.ok(keys[0].startsWith("github-mcp-server:mcp-app-view:"), `unexpected key: ${keys[0]}`); + + const raw = store.get(keys[0])!; + const parsed = JSON.parse(raw); + assert.equal(typeof parsed.savedAt, "number"); + assert.ok(Date.now() - parsed.savedAt < 60_000); +}); + +test("loadViewState evicts entries older than TTL", () => { + const store = installMemoryLocalStorage(); + const viewUUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + const key = `github-mcp-server:mcp-app-view:${viewUUID}`; + const stale = { + status: "completed", + result: { number: 9, title: "old", html_url: "https://example.com/9" }, + savedAt: Date.now() - 8 * 24 * 60 * 60 * 1000, // 8 days + }; + store.set(key, JSON.stringify(stale)); + + assert.equal(loadViewState(viewUUID), null); + assert.equal(store.get(key), undefined, "expired entry should be evicted"); +}); + +test("#2965 new invocation with a different viewUUID still shows the form", () => { + installMemoryLocalStorage(); + const firstUUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + const secondUUID = "ffffffff-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + saveViewState(firstUUID, { + status: "completed", + result: { number: 1, title: "old", html_url: "https://example.com/1" }, + }); + + const newDeferral = deferredToolResult(secondUUID); + const shown = resolveShownResult(newDeferral, null); + assert.equal(shown.source, "none"); + assert.equal(shown.shown, null); +}); + +test("loadViewState rejects corrupt or incomplete payloads", () => { + const store = installMemoryLocalStorage(); + const viewUUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + const key = `github-mcp-server:mcp-app-view:${viewUUID}`; + store.set(key, "{not-json"); + assert.equal(loadViewState(viewUUID), null); + + store.set(key, JSON.stringify({ status: "pending", result: { number: 1 }, savedAt: Date.now() })); + assert.equal(loadViewState(viewUUID), null); + + store.set(key, JSON.stringify({ status: "completed", savedAt: Date.now() })); + assert.equal(loadViewState(viewUUID), null); +}); diff --git a/ui/src/lib/viewState.ts b/ui/src/lib/viewState.ts new file mode 100644 index 0000000000..cc599486f3 --- /dev/null +++ b/ui/src/lib/viewState.ts @@ -0,0 +1,67 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Completed form submission persisted so MCP Apps can remount the SuccessView + * after the host tears down and recreates the iframe (e.g. switching chat + * sessions). Keyed by CallToolResult._meta.viewUUID from the deferral result. + * + * See MCP Apps "Persisting view state" and github/github-mcp-server#2965. + */ +export interface CompletedViewState> { + status: "completed"; + result: T; + /** Title shown on SuccessView when the PR/issue payload omits it. */ + submittedTitle?: string; + extras?: TExtras; + /** Epoch ms when this entry was written (for TTL). */ + savedAt: number; +} + +/** Namespace to avoid colliding with other storage on the same origin. */ +const KEY_PREFIX = "github-mcp-server:mcp-app-view:"; +/** Completed views older than this are treated as gone and evicted. */ +const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +function storageKey(viewUUID: string): string { + return KEY_PREFIX + viewUUID; +} + +/** Reads the stable view persistence key from a tool result's `_meta`. */ +export function getViewUUID(result: CallToolResult | null | undefined): string | undefined { + const raw = result?._meta?.viewUUID; + return typeof raw === "string" && raw.length > 0 ? raw : undefined; +} + +export function loadViewState>( + viewUUID: string, +): CompletedViewState | null { + const key = storageKey(viewUUID); + try { + const saved = localStorage.getItem(key); + if (!saved) return null; + const parsed = JSON.parse(saved) as CompletedViewState; + if (parsed?.status !== "completed" || parsed.result == null) return null; + if (typeof parsed.savedAt === "number" && Date.now() - parsed.savedAt > MAX_AGE_MS) { + localStorage.removeItem(key); + return null; + } + return parsed; + } catch (err) { + console.error("Failed to load MCP App view state:", err); + return null; + } +} + +export function saveViewState>( + viewUUID: string, + state: Omit, "savedAt">, +): void { + try { + localStorage.setItem( + storageKey(viewUUID), + JSON.stringify({ ...state, savedAt: Date.now() }), + ); + } catch (err) { + console.error("Failed to save MCP App view state:", err); + } +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 209c885166..3fe9878cfb 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -11,6 +11,7 @@ "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, + "allowImportingTsExtensions": true, "types": ["node"] }, "include": ["src", "vite.config.ts"]