Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions Resources/markdown-viewer/webviews-app/chunks/diffSurface.mjs

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Sources/AgentChat/AgentChatPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct AgentChatPanelView: View {
if isVisibleInUI {
AgentChatWebViewRepresentable(
controller: panel.chatViewController,
theme: AgentSessionWebTheme.resolve(appearance: appearance),
onRequestPanelFocus: onRequestPanelFocus
)
.id(panel.id)
Expand All @@ -34,14 +35,17 @@ struct AgentChatPanelView: View {
/// owns the controller's lifetime.
private struct AgentChatWebViewRepresentable: NSViewControllerRepresentable {
let controller: AgentChatWebViewController
let theme: AgentSessionWebTheme
let onRequestPanelFocus: () -> Void

func makeNSViewController(context: Context) -> AgentChatWebViewController {
controller.onPointerDown = onRequestPanelFocus
controller.apply(theme: theme)
return controller
}

func updateNSViewController(_ nsViewController: AgentChatWebViewController, context: Context) {
nsViewController.onPointerDown = onRequestPanelFocus
nsViewController.apply(theme: theme)
}
}
43 changes: 43 additions & 0 deletions Sources/AgentChat/AgentChatWebViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ final class AgentChatWebViewController: NSViewController, WKScriptMessageHandler
/// The resolved target session for this presentation.
private var resolution: AgentChatTranscriptResolver.Resolution?

/// Terminal-derived theme tokens, the same set the agent-session surface
/// receives (``AgentSessionWebTheme``). Delivered to the page in the
/// `chat.init` reply (race-free initial paint) and pushed on change via
/// `window.cmuxAgentChatBridge.applyTheme`.
private var theme: AgentSessionWebTheme?

private var webView: AgentSessionWebView?
private var daemonClient: AgentDaemonClient?
private var subscriptionId: String?
Expand All @@ -41,6 +47,34 @@ final class AgentChatWebViewController: NSViewController, WKScriptMessageHandler
loadSurface()
}

/// Applies the terminal theme for the hosting panel. Idempotent per token
/// set; pushes to the live page only when the tokens actually changed
/// (mirrors AgentSessionWebRendererCoordinator's theme update path).
func apply(theme: AgentSessionWebTheme) {
guard theme != self.theme else { return }
self.theme = theme
pushThemeToPage()
}

private func pushThemeToPage() {
guard let webView, let theme,
let data = try? JSONSerialization.data(withJSONObject: theme.dictionary),
let json = String(data: data, encoding: .utf8) else {
return
}
// No-ops harmlessly before the page module installs the bridge; the
// page then pulls the current tokens through its `chat.init` reply.
webView.evaluateJavaScript("window.cmuxAgentChatBridge?.applyTheme(\(json));") { _, error in
#if DEBUG
if let error {
cmuxDebugLog("agentChat.web.theme.failed error=\(error.localizedDescription)")
}
#else
_ = error
#endif
}
}

/// Terminates the daemon child without reloading the page; called when
/// the hosting panel closes for good.
func teardownForClose() {
Expand Down Expand Up @@ -93,6 +127,12 @@ final class AgentChatWebViewController: NSViewController, WKScriptMessageHandler
// hook and the first-click-focus setting; nothing in it is
// session-specific.
let webView = AgentSessionWebView(frame: .zero, configuration: configuration)
// The page paints the theme's pageBackground itself; with a
// transparent terminal background the token is "transparent" and the
// window backdrop must show through, exactly like the agent-session
// surface (AgentSessionWebRendererCoordinator does the same).
webView.setValue(false, forKey: "drawsBackground")
webView.underPageBackgroundColor = .clear
webView.onPointerDown = onPointerDown
webView.navigationDelegate = self
webView.allowsBackForwardNavigationGestures = false
Expand Down Expand Up @@ -210,6 +250,9 @@ final class AgentChatWebViewController: NSViewController, WKScriptMessageHandler
if let session = sessionRefPayload() {
payload["session"] = session
}
if let theme {
payload["theme"] = theme.dictionary
}
return payload
}

Expand Down
14 changes: 14 additions & 0 deletions webviews/src/agent-chat/labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ export const agentChatLabels = {
diffFallbackTitle: "File change",
diffSourceTruncated: "Change too large to diff fully; later lines not shown",

// In-conversation search.
searchOpen: "Search",
searchPlaceholder: "Search conversation",
searchNoMatches: "No matches",
searchNextMatch: "Next match",
searchPreviousMatch: "Previous match",
searchClose: "Close search",
searchFilterToggle: "Only show matches",

// Pending-request banner.
waitingForInput: "Agent is waiting for your input",
waitingForPermission: "Agent is waiting for permission",
Expand Down Expand Up @@ -99,3 +108,8 @@ export function exitCodeLabel(code: number): string {
export function moreLinesNotShownLabel(count: number): string {
return count === 1 ? "1 more line not shown" : `${count} more lines not shown`;
}

/** Search bar match counter, 1-based ("2/14"). */
export function matchCounterLabel(current: number, total: number): string {
return `${current}/${total}`;
}
7 changes: 7 additions & 0 deletions webviews/src/agent-chat/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,13 @@ export interface AgentChatInitResult {
daemon_status: "ready" | "unavailable";
/** Human-readable detail when unavailable. */
daemon_detail?: string;
/**
* Terminal-derived theme tokens, the agent-session bridge's camelCase
* AgentSessionTheme shape (Sources/Panels/AgentSessionWebTheme.swift).
* Absent in dev/mock mode; the surface keeps its system-scheme CSS fallback.
* Validated by agent-chat/theme.ts before applying, hence `unknown`.
*/
theme?: unknown;
}

export type AgentChatBridgeInbound =
Expand Down
148 changes: 136 additions & 12 deletions webviews/src/agent-chat/react/AgentChatApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,17 @@ import {
type ConversationState,
} from "../conversationStore";
import { agentChatLabels } from "../labels";
import {
clampCursor,
computeMatches,
initialSearchUIState,
normalizeSearchQuery,
reduceSearchUI,
} from "../search";
import { applyAgentChatTheme } from "../theme";
import { providerDisplayName, sessionDisplayTitle } from "./display";
import { ItemRow, PendingRequestBanner, TurnSeparator } from "./rows";
import { SearchBar, useSearchHotkey } from "./SearchBar";

/** Distance from the bottom (px) still treated as "at the bottom". */
const FOLLOW_THRESHOLD_PX = 24;
Expand Down Expand Up @@ -59,6 +68,10 @@ function useAgentChatConnection(
if (cancelled) {
return;
}
// Terminal theme tokens ride the init reply so the first paint is
// already themed; later appearance changes arrive as
// `cmuxAgentChatBridge.applyTheme` pushes (bridge.ts).
applyAgentChatTheme(result.theme);
dispatch({ type: "init", result });
try {
await bridge.subscribe();
Expand Down Expand Up @@ -88,21 +101,72 @@ export function AgentChatApp({ createBridge }: { createBridge?: BridgeFactory })
initialConversationState,
);
useAgentChatConnection(dispatch, createBridge);

// Search is fully derived from (items, search UI state): the match list and
// cursor are recomputed per render, never stored. An open bar with an empty
// query leaves the timeline untouched.
const [searchUI, dispatchSearch] = useReducer(reduceSearchUI, undefined, initialSearchUIState);
const activeQuery = searchUI.open ? normalizeSearchQuery(searchUI.query) : "";
const matches = computeMatches(state.items, activeQuery);
const cursor = clampCursor(searchUI.cursor, matches.length);
const currentMatchId = matches.length > 0 ? state.items[matches[cursor]].id : null;

const openSearch = () => dispatchSearch({ type: "open" });
useSearchHotkey(openSearch);
Comment on lines +114 to +115

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 openSearch is recreated as a new arrow function on every render, so useSearchHotkey's [onOpen] dependency changes every render and the effect runs cleanup+setup on every AgentChatApp render. This directly contradicts the hook's documented invariant ("attaches exactly one keydown listener for the component's lifetime"). During streaming (frequent renders) the listener is re-attached on every incoming event. Wrapping in useCallback with an empty dependency array is safe because dispatchSearch from useReducer is guaranteed stable.

Suggested change
const openSearch = () => dispatchSearch({ type: "open" });
useSearchHotkey(openSearch);
const openSearch = useCallback(() => dispatchSearch({ type: "open" }), []);
useSearchHotkey(openSearch);

const stepSearch = (direction: 1 | -1) => {
if (matches.length === 0) {
return;
}
// Compute the destination with the reducer's own arithmetic so the
// imperative scroll targets exactly the row the next render marks current.
const next = (cursor + direction + matches.length) % matches.length;
dispatchSearch({ type: "step", direction, matchCount: matches.length });
const target = state.items[matches[next]];
document
.querySelector(`[data-item-id="${CSS.escape(target.id)}"]`)
?.scrollIntoView({ block: "center" });
};

return (
<div className="agent-chat-shell">
<HeaderStrip state={state} />
<HeaderStrip state={state} onOpenSearch={openSearch} />
{searchUI.open ? (
<SearchBar
openCount={searchUI.openCount}
query={searchUI.query}
filterMode={searchUI.filterMode}
cursor={cursor}
matchCount={matches.length}
onQueryChange={(query) => dispatchSearch({ type: "set-query", query })}
onStep={stepSearch}
onToggleFilter={() => dispatchSearch({ type: "toggle-filter" })}
onClose={() => dispatchSearch({ type: "close" })}
/>
) : null}
{state.daemonStatus === "unavailable" && state.items.length > 0 ? (
<DaemonBanner detail={state.daemonDetail} />
) : null}
{state.pendingRequests.map((request) => (
<PendingRequestBanner key={request.id} request={request} />
))}
<TimelineBody state={state} />
<TimelineBody
state={state}
searchQuery={activeQuery}
matchedIndexes={matches}
currentMatchId={currentMatchId}
filterMode={searchUI.filterMode}
/>
</div>
);
}

function HeaderStrip({ state }: { state: ConversationState }) {
function HeaderStrip({
state,
onOpenSearch,
}: {
state: ConversationState;
onOpenSearch: () => void;
}) {
const session = state.session;
return (
<header className="agent-chat-header">
Expand All @@ -113,6 +177,15 @@ function HeaderStrip({ state }: { state: ConversationState }) {
<span className="agent-chat-header-title">{sessionDisplayTitle(session)}</span>
{session?.cwd ? <span className="agent-chat-header-cwd">{session.cwd}</span> : null}
</span>
<button
type="button"
className="agent-chat-header-search"
title={agentChatLabels.searchOpen}
aria-label={agentChatLabels.searchOpen}
onClick={onOpenSearch}
>
</button>
<span
className={`agent-chat-daemon-status is-${state.daemonStatus}`}
title={state.daemonStatus === "unavailable" ? (state.daemonDetail ?? undefined) : undefined}
Expand All @@ -135,7 +208,22 @@ function DaemonBanner({ detail }: { detail: string | null }) {
);
}

function TimelineBody({ state }: { state: ConversationState }) {
type SearchRenderProps = {
/** Normalized active query; "" when search is closed or blank. */
searchQuery: string;
/** Indexes into state.items of the matching items, timeline order. */
matchedIndexes: number[];
currentMatchId: string | null;
filterMode: boolean;
};

function TimelineBody({
state,
searchQuery,
matchedIndexes,
currentMatchId,
filterMode,
}: { state: ConversationState } & SearchRenderProps) {
if (state.phase === "connecting") {
return (
<EmptyState
Expand Down Expand Up @@ -181,7 +269,15 @@ function TimelineBody({ state }: { state: ConversationState }) {
/>
);
}
return <Timeline state={state} />;
return (
<Timeline
state={state}
searchQuery={searchQuery}
matchedIndexes={matchedIndexes}
currentMatchId={currentMatchId}
filterMode={filterMode}
/>
);
}

function EmptyState({ title, detail }: { title: string; detail: string }) {
Expand All @@ -193,7 +289,14 @@ function EmptyState({ title, detail }: { title: string; detail: string }) {
);
}

function Timeline({ state }: { state: ConversationState }) {
function Timeline({
state,
searchQuery,
matchedIndexes,
currentMatchId,
filterMode,
}: { state: ConversationState } & SearchRenderProps) {
const searchActive = searchQuery !== "";
// Auto-follow is fully derived: `unfollowedAtSeq === null` means "stick to
// the bottom". Scroll events flip it (leaving the bottom unfollows, reaching
// the bottom re-follows), and the seq recorded at unfollow time tells us
Expand Down Expand Up @@ -227,7 +330,9 @@ function Timeline({ state }: { state: ConversationState }) {
};

const anchorRef = (node: HTMLDivElement | null) => {
if (node && following) {
// An active search pauses auto-follow so new events don't yank the
// viewport away from the inspected match; clearing the query restores it.
if (node && following && !searchActive) {
scrollToBottom(node.parentElement);
}
};
Expand All @@ -239,14 +344,33 @@ function Timeline({ state }: { state: ConversationState }) {
const firstRealBoundary =
state.turnStarts.length > 0 ? Math.min(...state.turnStarts) : Number.POSITIVE_INFINITY;
const provider = state.session?.provider ?? null;
const matchedIndexSet = new Set(matchedIndexes);
const filtering = searchActive && filterMode;
const rows: ReactNode[] = [];
state.items.forEach((item, index) => {
const isBoundary =
index >= firstRealBoundary ? realBoundaries.has(index) : item.type === "user_message";
if (isBoundary && index > 0) {
rows.push(<TurnSeparator key={`turn-${item.id}`} />);
const isMatch = searchActive && matchedIndexSet.has(index);
if (filtering && !isMatch) {
return;
}
rows.push(<ItemRow key={item.id} item={item} provider={provider} />);
// Turn separators are an unfiltered-timeline concept; the filtered view
// is a flat match list.
if (!filtering) {
const isBoundary =
index >= firstRealBoundary ? realBoundaries.has(index) : item.type === "user_message";
if (isBoundary && index > 0) {
rows.push(<TurnSeparator key={`turn-${item.id}`} />);
}
}
rows.push(
<ItemRow
key={item.id}
item={item}
provider={provider}
searchQuery={isMatch ? searchQuery : ""}
isSearchMatch={isMatch}
isCurrentSearchMatch={isMatch && item.id === currentMatchId}
/>,
);
});

return (
Expand Down
Loading
Loading