Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/code/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "@features/auth/hooks/authQueries";
import { useAuthSession } from "@features/auth/hooks/useAuthSession";
import { useIsOrgAdmin } from "@features/auth/hooks/useOrgRole";
import { useMcpUiToolsSubscription } from "@features/mcp-apps/hooks/useMcpUiToolsSubscription";
import { OnboardingFlow } from "@features/onboarding/components/OnboardingFlow";
import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore";
import { Flex, Spinner, Text } from "@radix-ui/themes";
Expand Down Expand Up @@ -148,6 +149,9 @@ function App() {
}),
);

// Keep the MCP UI tool registry up to date across all sessions.
useMcpUiToolsSubscription();

// Auto-unfocus when user manually checks out to a different branch
useSubscription(
trpcReact.focus.onForeignBranchCheckout.subscriptionOptions(undefined, {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useMcpUiToolsStore } from "@features/mcp-apps/stores/mcpUiToolsStore";
import { useTRPC } from "@renderer/trpc/client";
import { useSubscription } from "@trpc/tanstack-react-query";

export function useMcpUiToolsSubscription(): void {
const trpcReact = useTRPC();
const setToolKeys = useMcpUiToolsStore((s) => s.setToolKeys);

useSubscription(
trpcReact.mcpApps.onDiscoveryComplete.subscriptionOptions(undefined, {
onData: (event) => {
setToolKeys(event.toolKeys);
},
}),
);
}
13 changes: 13 additions & 0 deletions apps/code/src/renderer/features/mcp-apps/stores/mcpUiToolsStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { create } from "zustand";

interface McpUiToolsState {
toolKeys: Set<string>;
isReady: boolean;
setToolKeys: (keys: readonly string[]) => void;
}

export const useMcpUiToolsStore = create<McpUiToolsState>((set) => ({
toolKeys: new Set<string>(),
isReady: false,
setToolKeys: (keys) => set({ toolKeys: new Set(keys), isReady: true }),
}));
180 changes: 117 additions & 63 deletions apps/code/src/renderer/features/sessions/components/ConversationView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { useMcpUiToolsStore } from "@features/mcp-apps/stores/mcpUiToolsStore";
import { parseMcpToolKey } from "@features/mcp-apps/utils/mcp-app-host-utils";
import {
type McpAppEntry,
McpAppsSidebar,
} from "@features/sessions/components/McpAppsSidebar";
import { CHAT_CONTENT_MAX_WIDTH } from "@features/sessions/constants";
import { useContextUsage } from "@features/sessions/hooks/useContextUsage";
import { useConversationSearch } from "@features/sessions/hooks/useConversationSearch";
import { useMcpAppsSidebarStore } from "@features/sessions/stores/mcpAppsSidebarStore";
import {
sessionStoreSetters,
useOptimisticItemsForTask,
Expand Down Expand Up @@ -136,24 +143,45 @@ export function ConversationView({
[conversationItems, optimisticItems, queuedItems, isCloud],
);

// Keep MCP App tool call items mounted so their iframes and bridges
// survive scrolling out of the virtualized viewport.
const mcpAppIndices = useMemo(() => {
const mcpUiToolKeys = useMcpUiToolsStore((s) => s.toolKeys);

// Single pass over items: collect indices for keepMounted (so iframes
// survive scrolling out of the virtualized viewport) and structured
// entries for the MCP apps sidebar.
const { mcpAppIndices, mcpEntries } = useMemo(() => {
const indices: number[] = [];
const entries: McpAppEntry[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type !== "session_update") continue;
const update = item.update;
if (update.sessionUpdate !== "tool_call") continue;
if (!("_meta" in update)) continue;
const meta = update._meta as
| { claudeCode?: { toolName?: string } }
| undefined;
if (meta?.claudeCode?.toolName?.startsWith("mcp__")) {
indices.push(i);
}
const fullToolName = meta?.claudeCode?.toolName;
if (!fullToolName?.startsWith("mcp__")) continue;

indices.push(i);

// Only list tools that have a registered UI (true MCP apps).
if (!mcpUiToolKeys.has(fullToolName)) continue;

const { serverName, toolName } = parseMcpToolKey(fullToolName);
entries.push({
itemIndex: i,
toolCallId: update.toolCallId,
fullToolName,
serverName,
toolName,
title: update.title,
inputPreview: buildInputPreview(update.rawInput),
status: update.status ?? null,
});
}
return indices;
}, [items]);
return { mcpAppIndices: indices, mcpEntries: entries };
}, [items, mcpUiToolKeys]);

const containerRef = useRef<HTMLDivElement>(null);
const search = useConversationSearch({ items, containerRef, listRef });
Expand Down Expand Up @@ -244,73 +272,99 @@ export function ConversationView({

const getItemKey = useCallback((item: ConversationItem) => item.id, []);

const isMcpSidebarOpen = useMcpAppsSidebarStore((s) => s.open);
const scrollToMcpItem = useCallback((itemIndex: number) => {
listRef.current?.scrollToIndex(itemIndex);
}, []);

return (
<WorkerPoolContextProvider
poolOptions={DIFFS_POOL_OPTIONS}
highlighterOptions={DIFFS_HIGHLIGHTER_OPTIONS}
>
<div ref={containerRef} className="relative flex-1">
<div
id="fullscreen-portal"
className="pointer-events-none absolute inset-0 z-20"
/>
{search.open && (
<ConversationSearchBar
ref={search.searchBarRef}
query={search.query}
currentMatch={search.currentIndex}
totalMatches={search.totalMatches}
onQueryChange={search.setQuery}
onNext={search.next}
onPrev={search.prev}
onClose={search.close}
<Flex className="h-full min-h-0 flex-1">
<div ref={containerRef} className="relative min-w-0 flex-1">
<div
id="fullscreen-portal"
className="pointer-events-none absolute inset-0 z-20"
/>
)}
{search.open && (
<ConversationSearchBar
ref={search.searchBarRef}
query={search.query}
currentMatch={search.currentIndex}
totalMatches={search.totalMatches}
onQueryChange={search.setQuery}
onNext={search.next}
onPrev={search.prev}
onClose={search.close}
/>
)}

<VirtualizedList
ref={listRef}
items={items}
getItemKey={getItemKey}
renderItem={renderItem}
onScrollStateChange={handleScrollStateChange}
keepMounted={mcpAppIndices}
className="absolute inset-0 bg-background"
itemClassName="mx-auto px-2 py-1.5"
itemStyle={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }}
footer={
<div className={compact ? "pb-1" : "pb-16"}>
<SessionFooter
task={task}
isPromptPending={isPromptPending}
promptStartedAt={promptStartedAt}
lastGenerationDuration={
lastTurnInfo?.isComplete
? Math.max(0, lastTurnInfo.durationMs - pausedDurationMs)
: null
}
lastStopReason={lastTurnInfo?.stopReason}
queuedCount={queuedMessages.length}
hasPendingPermission={pendingPermissionsCount > 0}
pausedDurationMs={pausedDurationMs}
isCompacting={isCompacting}
usage={contextUsage}
/>
</div>
}
/>
{showScrollButton && (
<Box className="absolute right-4 bottom-4 z-10">
<Button size="1" variant="solid" onClick={scrollToBottom}>
<ArrowDown size={14} weight="bold" />
Scroll to bottom
</Button>
</Box>
<VirtualizedList
ref={listRef}
items={items}
getItemKey={getItemKey}
renderItem={renderItem}
onScrollStateChange={handleScrollStateChange}
keepMounted={mcpAppIndices}
className="absolute inset-0 bg-background"
itemClassName="mx-auto px-2 py-1.5"
itemStyle={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }}
footer={
<div className={compact ? "pb-1" : "pb-16"}>
<SessionFooter
task={task}
isPromptPending={isPromptPending}
promptStartedAt={promptStartedAt}
lastGenerationDuration={
lastTurnInfo?.isComplete
? Math.max(0, lastTurnInfo.durationMs - pausedDurationMs)
: null
}
lastStopReason={lastTurnInfo?.stopReason}
queuedCount={queuedMessages.length}
hasPendingPermission={pendingPermissionsCount > 0}
pausedDurationMs={pausedDurationMs}
isCompacting={isCompacting}
usage={contextUsage}
/>
</div>
}
/>
{showScrollButton && (
<Box className="absolute right-4 bottom-4 z-10">
<Button size="1" variant="solid" onClick={scrollToBottom}>
<ArrowDown size={14} weight="bold" />
Scroll to bottom
</Button>
</Box>
)}
</div>
{isMcpSidebarOpen && (
<McpAppsSidebar entries={mcpEntries} onSelect={scrollToMcpItem} />
)}
</div>
</Flex>
</WorkerPoolContextProvider>
);
}

function buildInputPreview(rawInput: unknown): string | undefined {
if (rawInput == null || typeof rawInput !== "object") return undefined;
const entries = Object.entries(rawInput as Record<string, unknown>);
if (entries.length === 0) return undefined;
const [key, value] = entries[0];
const formatted =
typeof value === "string"
? value
: typeof value === "number" || typeof value === "boolean"
? String(value)
: JSON.stringify(value);
const compact = formatted.replace(/\s+/g, " ").trim();
const truncated = compact.length > 60 ? `${compact.slice(0, 60)}…` : compact;
return `${key}: ${truncated}`;
}

const SessionUpdateRow = memo(function SessionUpdateRow({
update,
turnContext,
Expand Down
Loading
Loading