Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0525e33
feat(settings): add agent-stop notification client settings
hendrillara Jun 22, 2026
a6170f0
fix(settings): restore ServerSettings tests clobbered while adding no…
hendrillara Jun 22, 2026
84c59b8
feat(web): add pure agent-stop notification decision core
hendrillara Jun 22, 2026
41aee0f
test(web): cover ready + pending-approvals transitions; drop redundan…
hendrillara Jun 22, 2026
3ab92d0
feat(web): add Web Audio notification chime
hendrillara Jun 22, 2026
2bcf3ec
feat(desktop): add IPC for native agent-stop notification + system beep
hendrillara Jun 22, 2026
e2fcc76
feat(web): observe agent-stop transitions and add notification settings
hendrillara Jun 22, 2026
ce16b78
docs(web): document the user-interrupt false-positive limitation
hendrillara Jun 23, 2026
9d295c8
docs(web): correct the interrupt-limitation mechanism and JSDoc
hendrillara Jun 23, 2026
a5db683
refactor(notifications): apply PR-review fixes (batch sound, gate web…
hendrillara Jun 23, 2026
dd5c760
style(notifications): apply formatter (vp check --fix)
hendrillara Jun 23, 2026
7cc6bf7
fix(notifications): use HostProcessPlatform; clear our lint warnings
hendrillara Jun 23, 2026
63cecbc
feat(find): add markdown→text projection for chat search
hendrillara Jun 23, 2026
6701155
feat(find): build ordered matches and reconcile active match
hendrillara Jun 23, 2026
cc3cc3d
perf(find): O(1) reverse offset mapping; fix straße comment and add c…
hendrillara Jun 23, 2026
b669934
feat(find): add pure row-location and turn-lookup helpers
hendrillara Jun 23, 2026
2f12304
feat(find): add pure offset→node mapping for highlight ranges
hendrillara Jun 23, 2026
95b9328
feat(find): register find.toggle command bound to mod+f
hendrillara Jun 23, 2026
67ee7c4
feat(find): add find bar UI and state, opened by Cmd/Ctrl+F
hendrillara Jun 23, 2026
8dc61c6
feat(find): reveal and scroll the active match across folds and work-…
hendrillara Jun 23, 2026
e114624
fix(find): reveal scroll fires on navigation only, not on every rows …
hendrillara Jun 23, 2026
0744066
feat(find): precise CSS Custom Highlight rendering with reveal-flash …
hendrillara Jun 23, 2026
9fa70c5
fix(find): clear highlights on unmount, cap current highlight to one …
hendrillara Jun 24, 2026
7649e63
fix(find): reveal collapsed long user messages holding the active match
hendrillara Jun 24, 2026
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/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
64 changes: 64 additions & 0 deletions apps/desktop/src/ipc/methods/agentNotifications.ts
Original file line number Diff line number Diff line change
@@ -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<Electron.Notification>();

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();
});
}),
});
13 changes: 13 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof listener>[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),
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const clientSettings: ClientSettings = {
dismissedProviderUpdateNotificationKeys: [],
diffIgnoreWhitespace: true,
diffWordWrap: true,
notifyOnAgentStopPopup: true,
notifyOnAgentStopSound: true,
notifyOnAgentStopSoundSource: "tone",
favorites: [],
providerModelPreferences: {},
sidebarProjectGroupingMode: "repository_path",
Expand Down
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,17 @@
"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",
"rehype-raw": "^7.0.0",
"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": {
Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/components/AgentStopNotifications.tsx
Original file line number Diff line number Diff line change
@@ -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<ReadonlyMap<string, OrchestrationSessionStatus>>(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;
}
57 changes: 56 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1136,6 +1139,7 @@ function ChatViewContent(props: ChatViewProps) {
LastInvokedScriptByProjectSchema,
);
const legendListRef = useRef<LegendListRef | null>(null);
const timelineContainerRef = useRef<HTMLDivElement>(null);
const isAtEndRef = useRef(true);
const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});
Expand Down Expand Up @@ -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,
),

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.

Find highlights lag match counter

Medium Severity

The chat find feature uses a deferred query for its match list and match count, but an immediate query for applying highlights to the DOM. This can cause the displayed highlights, match counts, and active match selection to become out of sync, particularly when typing quickly.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7649e63. Configure here.

);
};
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
Expand Down Expand Up @@ -4722,7 +4775,8 @@ function ChatViewContent(props: ChatViewProps) {
{/* Chat column */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{/* Messages Wrapper */}
<div className="relative flex min-h-0 flex-1 flex-col">
<div ref={timelineContainerRef} className="relative flex min-h-0 flex-1 flex-col">
<ChatFindBar controller={chatFind} />
{/* Messages — LegendList handles virtualization and scrolling internally */}
<MessagesTimeline
key={activeThread.id}
Expand All @@ -4746,6 +4800,7 @@ function ChatViewContent(props: ChatViewProps) {
workspaceRoot={activeWorkspaceRoot}
skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}
onIsAtEndChange={onIsAtEndChange}
activeFindMatch={chatFind.activeMatch}
/>

{/* scroll to bottom pill — shown when user has scrolled away from the bottom */}
Expand Down
Loading
Loading