diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 180e44e52d9..f4a7209cf8c 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,6 +1,7 @@ import * as Effect from "effect/Effect"; import * as DesktopIpc from "./DesktopIpc.ts"; +import { playSystemSound, showAgentNotification } from "./methods/agentNotifications.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; import { clearConnectionCatalog, @@ -70,6 +71,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTailscaleServeEnabled); yield* ipc.handle(getAdvertisedEndpoints); + yield* ipc.handle(showAgentNotification); + yield* ipc.handle(playSystemSound); + yield* ipc.handle(pickFolder); yield* ipc.handle(confirm); yield* ipc.handle(setTheme); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index cc2a92ca8fd..7f76491d656 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -68,3 +68,6 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; +export const SHOW_AGENT_NOTIFICATION_CHANNEL = "desktop:show-agent-notification"; +export const PLAY_SYSTEM_SOUND_CHANNEL = "desktop:play-system-sound"; +export const AGENT_NOTIFICATION_CLICKED_CHANNEL = "desktop:agent-notification-clicked"; diff --git a/apps/desktop/src/ipc/methods/agentNotifications.ts b/apps/desktop/src/ipc/methods/agentNotifications.ts new file mode 100644 index 00000000000..03e5034cae7 --- /dev/null +++ b/apps/desktop/src/ipc/methods/agentNotifications.ts @@ -0,0 +1,64 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Electron from "electron"; + +import { AgentNotificationRequestSchema } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { ElectronWindow } from "../../electron/ElectronWindow.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +// Retain references so notifications are not garbage-collected before the user +// clicks them (Electron does not keep them alive on its own). +const activeNotifications = new Set(); + +export const showAgentNotification = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SHOW_AGENT_NOTIFICATION_CHANNEL, + payload: AgentNotificationRequestSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.agentNotifications.show")(function* (request) { + const electronWindow = yield* ElectronWindow; + const targetWindow = Option.getOrNull(yield* electronWindow.currentMainOrFirst); + const isDarwin = (yield* HostProcessPlatform) === "darwin"; + + yield* Effect.sync(() => { + const notification = new Electron.Notification({ + title: request.title, + body: request.body, + silent: true, + }); + activeNotifications.add(notification); + notification.on("close", () => activeNotifications.delete(notification)); + notification.on("click", () => { + try { + activeNotifications.delete(notification); + if (targetWindow === null || targetWindow.isDestroyed()) return; + if (targetWindow.isMinimized()) targetWindow.restore(); + if (!targetWindow.isVisible()) targetWindow.show(); + if (isDarwin) Electron.app.focus({ steal: true }); + targetWindow.focus(); + targetWindow.webContents.send(IpcChannels.AGENT_NOTIFICATION_CLICKED_CHANNEL, { + threadId: request.threadId, + environmentId: request.environmentId, + }); + } catch (error) { + // @effect-diagnostics-next-line globalConsole:off - Electron click callback runs outside the Effect runtime. + console.error("agentNotifications click handler failed", error); + } + }); + notification.show(); + }); + }), +}); + +export const playSystemSound = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PLAY_SYSTEM_SOUND_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.agentNotifications.beep")(function* () { + yield* Effect.sync(() => { + Electron.shell.beep(); + }); + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 6f126f41334..2cb80156ca9 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -111,6 +111,19 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, + showAgentNotification: (request) => + ipcRenderer.invoke(IpcChannels.SHOW_AGENT_NOTIFICATION_CHANNEL, request), + playSystemSound: () => ipcRenderer.invoke(IpcChannels.PLAY_SYSTEM_SOUND_CHANNEL), + onAgentNotificationClicked: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, payload: unknown) => { + if (typeof payload !== "object" || payload === null) return; + listener(payload as Parameters[0]); + }; + ipcRenderer.on(IpcChannels.AGENT_NOTIFICATION_CLICKED_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.AGENT_NOTIFICATION_CLICKED_CHANNEL, wrappedListener); + }; + }, getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), setUpdateChannel: (channel) => ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 3584d6a21e4..51e5083d5a4 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -19,6 +19,9 @@ const clientSettings: ClientSettings = { dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, + notifyOnAgentStopPopup: true, + notifyOnAgentStopSound: true, + notifyOnAgentStopSoundSource: "tone", favorites: [], providerModelPreferences: {}, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/web/package.json b/apps/web/package.json index 632e2d14395..000d855a06a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,6 +38,7 @@ "jose": "catalog:", "lexical": "^0.41.0", "lucide-react": "^0.564.0", + "mdast-util-to-string": "^4.0.0", "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", @@ -45,7 +46,9 @@ "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", "tailwind-merge": "^3.4.0", + "unified": "^11.0.5", "zustand": "^5.0.11" }, "devDependencies": { diff --git a/apps/web/src/components/AgentStopNotifications.tsx b/apps/web/src/components/AgentStopNotifications.tsx new file mode 100644 index 00000000000..772a70e1a3c --- /dev/null +++ b/apps/web/src/components/AgentStopNotifications.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef } from "react"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import type { OrchestrationSessionStatus } from "@t3tools/contracts"; + +import { useClientSettings } from "~/hooks/useSettings"; +import { decideAgentStopNotifications } from "~/lib/agentStopNotifications"; +import { playNotificationTone } from "~/lib/notificationSound"; +import { useProjects, useThreadShells } from "~/state/entities"; + +/** + * App-global observer that watches every thread's session status and emits a + * native notification + sound when an agent stops working. Renders nothing. + */ +export function AgentStopNotifications(): null { + const threads = useThreadShells(); + const projects = useProjects(); + const popup = useClientSettings((s) => s.notifyOnAgentStopPopup); + const sound = useClientSettings((s) => s.notifyOnAgentStopSound); + const soundSource = useClientSettings((s) => s.notifyOnAgentStopSoundSource); + const activeThreadId = (useParams({ strict: false }) as { threadId?: string }).threadId ?? null; + const navigate = useNavigate(); + + const prevStatusesRef = useRef>(new Map()); + + useEffect(() => { + const isAppFocused = typeof document !== "undefined" ? document.hasFocus() : false; + const { notifications, nextStatuses } = decideAgentStopNotifications({ + prevStatuses: prevStatusesRef.current, + threads, + projects, + settings: { popup, sound, soundSource }, + activeThreadId, + isAppFocused, + }); + prevStatusesRef.current = nextStatuses; + + for (const notification of notifications) { + if (popup) { + void window.desktopBridge + ?.showAgentNotification({ + title: notification.title, + body: notification.body, + threadId: notification.threadId, + environmentId: notification.environmentId, + }) + ?.catch((error: unknown) => console.warn("showAgentNotification failed", error)); + } + } + if (sound && notifications.length > 0) { + if (soundSource === "system") { + void window.desktopBridge + ?.playSystemSound() + ?.catch((error: unknown) => console.warn("playSystemSound failed", error)); + } else { + playNotificationTone(); + } + } + }, [threads, projects, popup, sound, soundSource, activeThreadId]); + + useEffect(() => { + const subscribe = window.desktopBridge?.onAgentNotificationClicked; + if (typeof subscribe !== "function") return; + const unsubscribe = subscribe(({ threadId, environmentId }) => { + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + }); + }); + return () => unsubscribe?.(); + }, [navigate]); + + return null; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cf5bb9de5e9..49fd6ca6cdc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -200,6 +200,9 @@ import { resolveEffectiveEnvMode } from "./BranchToolbar.logic"; import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { ChatFindBar } from "./chat/ChatFindBar"; +import { useChatFind } from "./chat/useChatFind"; +import { applyFindHighlights, clearFindHighlights } from "./chat/chatFindHighlight"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, @@ -1136,6 +1139,7 @@ function ChatViewContent(props: ChatViewProps) { LastInvokedScriptByProjectSchema, ); const legendListRef = useRef(null); + const timelineContainerRef = useRef(null); const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); @@ -2080,6 +2084,55 @@ function ChatViewContent(props: ChatViewProps) { }), ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const chatFind = useChatFind({ + timelineEntries, + keybindings, + isTerminalFocused: () => getTerminalFocusOwner() !== null, + terminalOpen: Boolean(terminalUiState.terminalOpen), + }); + + // Close find bar and clear highlights when the user switches threads. + useEffect(() => { + chatFind.close(); + }, [activeThread?.id]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally reacts to thread id only + + // Re-apply CSS Custom Highlight ranges whenever find state or the rendered + // DOM changes; also observe DOM mutations so highlights survive streaming. + useEffect(() => { + const container = timelineContainerRef.current; + if (!container || !chatFind.open) { + clearFindHighlights(); + return; + } + let frame = 0; + const reapply = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => + applyFindHighlights( + container, + chatFind.query, + { caseSensitive: chatFind.caseSensitive }, + chatFind.matches, + chatFind.activeMatch, + ), + ); + }; + reapply(); + const observer = new MutationObserver(reapply); + observer.observe(container, { childList: true, subtree: true, characterData: true }); + return () => { + observer.disconnect(); + cancelAnimationFrame(frame); + clearFindHighlights(); + }; + }, [ + chatFind.open, + chatFind.query, + chatFind.caseSensitive, + chatFind.matches, + chatFind.activeMatch, + ]); + const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); // Prefer an instance-id match so a custom Codex instance (e.g. // `codex_personal`) surfaces its own status/message in the banner rather @@ -4722,7 +4775,8 @@ function ChatViewContent(props: ChatViewProps) { {/* Chat column */}
{/* Messages Wrapper */} -
+
+ {/* Messages — LegendList handles virtualization and scrolling internally */} {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} diff --git a/apps/web/src/components/chat/ChatFindBar.tsx b/apps/web/src/components/chat/ChatFindBar.tsx new file mode 100644 index 00000000000..0ac909ed0c7 --- /dev/null +++ b/apps/web/src/components/chat/ChatFindBar.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef } from "react"; +import { ChevronDownIcon, ChevronUpIcon, SearchIcon, XIcon } from "lucide-react"; + +import { cn } from "../../lib/utils"; +import type { ChatFindController } from "./useChatFind"; + +export function ChatFindBar({ controller }: { controller: ChatFindController }) { + const { open, query, caseSensitive, matches, currentIndex } = controller; + const inputRef = useRef(null); + + useEffect(() => { + if (open) { + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [open, controller.openNonce]); + + if (!open) return null; + + const total = matches.length; + const countLabel = total === 0 ? (query ? "No results" : "") : `${currentIndex + 1}/${total}`; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.nativeEvent.isComposing) return; // IME guard + if (event.key === "Enter") { + event.preventDefault(); + if (event.shiftKey) controller.prev(); + else controller.next(); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + controller.close(); + } + }; + + return ( +
+ + controller.setQuery(event.target.value)} + onKeyDown={onKeyDown} + placeholder="Find in conversation" + aria-label="Find in conversation" + className="w-48 bg-transparent text-sm outline-none placeholder:text-muted-foreground/50" + /> + + {countLabel} + + + + + +
+ ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 88cbecb9bec..c9166834e42 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -106,6 +106,8 @@ import { parseReviewCommentMessageSegments, type ReviewCommentContext, } from "../../reviewCommentContext"; +import { locateRowForEntry, findTurnIdForEntry } from "./messagesTimelineReveal"; +import type { Match } from "./chatSearch"; // --------------------------------------------------------------------------- // Context — shared state consumed by every row component via Context. @@ -127,6 +129,7 @@ interface TimelineRowSharedState { onImageExpand: (preview: ExpandedImagePreview) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; + findRevealEntryId: string | null; } interface TimelineRowActivityState { @@ -166,6 +169,7 @@ interface MessagesTimelineProps { workspaceRoot: string | undefined; skills?: ReadonlyArray>; onIsAtEndChange: (isAtEnd: boolean) => void; + activeFindMatch?: Match | null; } // --------------------------------------------------------------------------- @@ -193,6 +197,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, onIsAtEndChange, + activeFindMatch, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); @@ -285,6 +290,60 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const rows = useStableRows(rawRows); + // "Latest value" refs so PRIMARY effect can read rows/timelineEntries without + // depending on them (standard pattern — avoids re-firing on every streaming update). + const rowsRef = useRef(rows); + rowsRef.current = rows; + const timelineEntriesRef = useRef(timelineEntries); + timelineEntriesRef.current = timelineEntries; + + const pendingScrollEntryRef = useRef<{ + entryId: string; + kind: "message" | "work" | "proposed-plan"; + } | null>(null); + + // PRIMARY: fires only when the active match changes (not on every streaming rows update). + useEffect(() => { + if (!activeFindMatch) { + pendingScrollEntryRef.current = null; + return; + } + const kind = activeFindMatch.entryKind; + const located = locateRowForEntry(rowsRef.current, activeFindMatch.entryId, kind); + if (located === null) { + // Folded: expand its turn; SECONDARY resolves the scroll once rows reflect it. + const turnId = findTurnIdForEntry(timelineEntriesRef.current, activeFindMatch.entryId); + if (turnId) { + pendingScrollEntryRef.current = { entryId: activeFindMatch.entryId, kind }; + setExpandedTurnIds((existing) => { + if (existing.has(turnId)) return existing; + const next = new Set(existing); + next.add(turnId); + return next; + }); + } + return; + } + pendingScrollEntryRef.current = null; + const item = rowsRef.current[located]; + if (item) void listRef.current?.scrollToItem?.({ item, animated: true, viewPosition: 0.3 }); + }, [activeFindMatch]); // eslint-disable-line react-hooks/exhaustive-deps -- read rows/timelineEntries via refs so streaming row updates don't re-scroll + + // SECONDARY: resolve a pending scroll after a fold expansion materializes the row. + useEffect(() => { + const pending = pendingScrollEntryRef.current; + if (!pending) return; + const located = locateRowForEntry(rows, pending.entryId, pending.kind); + if (located === null) return; + pendingScrollEntryRef.current = null; + const item = rows[located]; + if (item) { + window.requestAnimationFrame(() => { + void listRef.current?.scrollToItem?.({ item, animated: true, viewPosition: 0.3 }); + }); + } + }, [rows, listRef]); + const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); if (state) { @@ -324,6 +383,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, onOpenTurnDiff, onToggleTurnFold, + findRevealEntryId: activeFindMatch?.entryId ?? null, }), [ timestampFormat, @@ -337,6 +397,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, onOpenTurnDiff, onToggleTurnFold, + activeFindMatch?.entryId, ], ); const activityState = useMemo( @@ -510,9 +571,13 @@ function UserTimelineRow({ row }: { row: Extract
-
+
}> @@ -564,7 +629,7 @@ function TurnFoldTimelineRow({ row }: { row: Extract +