diff --git a/src/features/app/components/PinnedThreadList.test.tsx b/src/features/app/components/PinnedThreadList.test.tsx
index e22709103..704f91bfa 100644
--- a/src/features/app/components/PinnedThreadList.test.tsx
+++ b/src/features/app/components/PinnedThreadList.test.tsx
@@ -101,4 +101,24 @@ describe("PinnedThreadList", () => {
true,
);
});
+
+ it("shows blue unread-style status when a pinned thread is waiting for user input", () => {
+ const { container } = render(
+ ,
+ );
+
+ const row = container.querySelector(".thread-row");
+ expect(row).toBeTruthy();
+ expect(row?.querySelector(".thread-name")?.textContent).toBe("Pinned Beta");
+ expect(row?.querySelector(".thread-status")?.className).toContain("unread");
+ expect(row?.querySelector(".thread-status")?.className).not.toContain("processing");
+ });
});
diff --git a/src/features/app/components/PinnedThreadList.tsx b/src/features/app/components/PinnedThreadList.tsx
index 19eddb0a0..e108ba7fc 100644
--- a/src/features/app/components/PinnedThreadList.tsx
+++ b/src/features/app/components/PinnedThreadList.tsx
@@ -18,6 +18,7 @@ type PinnedThreadListProps = {
activeWorkspaceId: string | null;
activeThreadId: string | null;
threadStatusById: ThreadStatusMap;
+ pendingUserInputKeys?: Set;
getThreadTime: (thread: ThreadSummary) => string | null;
isThreadPinned: (workspaceId: string, threadId: string) => boolean;
onSelectThread: (workspaceId: string, threadId: string) => void;
@@ -34,6 +35,7 @@ export function PinnedThreadList({
activeWorkspaceId,
activeThreadId,
threadStatusById,
+ pendingUserInputKeys,
getThreadTime,
isThreadPinned,
onSelectThread,
@@ -48,7 +50,12 @@ export function PinnedThreadList({
? ({ "--thread-indent": `${depth * 14}px` } as CSSProperties)
: undefined;
const status = threadStatusById[thread.id];
- const statusClass = status?.isReviewing
+ const hasPendingUserInput = Boolean(
+ pendingUserInputKeys?.has(`${workspaceId}:${thread.id}`),
+ );
+ const statusClass = hasPendingUserInput
+ ? "unread"
+ : status?.isReviewing
? "reviewing"
: status?.isProcessing
? "processing"
diff --git a/src/features/app/components/Sidebar.tsx b/src/features/app/components/Sidebar.tsx
index 73d5e7613..4174eacff 100644
--- a/src/features/app/components/Sidebar.tsx
+++ b/src/features/app/components/Sidebar.tsx
@@ -1,5 +1,6 @@
import type {
AccountSnapshot,
+ RequestUserInputRequest,
RateLimitSnapshot,
ThreadListSortKey,
ThreadSummary,
@@ -66,6 +67,7 @@ type SidebarProps = {
onRefreshAllThreads: () => void;
activeWorkspaceId: string | null;
activeThreadId: string | null;
+ userInputRequests?: RequestUserInputRequest[];
accountRateLimits: RateLimitSnapshot | null;
usageShowRemaining: boolean;
accountInfo: AccountSnapshot | null;
@@ -122,6 +124,7 @@ export const Sidebar = memo(function Sidebar({
onRefreshAllThreads,
activeWorkspaceId,
activeThreadId,
+ userInputRequests = [],
accountRateLimits,
usageShowRemaining,
accountInfo,
@@ -197,6 +200,19 @@ export const Sidebar = memo(function Sidebar({
} = getUsageLabels(accountRateLimits, usageShowRemaining);
const debouncedQuery = useDebouncedValue(searchQuery, 150);
const normalizedQuery = debouncedQuery.trim().toLowerCase();
+ const pendingUserInputKeys = useMemo(
+ () =>
+ new Set(
+ userInputRequests
+ .map((request) => {
+ const workspaceId = request.workspace_id.trim();
+ const threadId = request.params.thread_id.trim();
+ return workspaceId && threadId ? `${workspaceId}:${threadId}` : "";
+ })
+ .filter(Boolean),
+ ),
+ [userInputRequests],
+ );
const isWorkspaceMatch = useCallback(
(workspace: WorkspaceInfo) => {
@@ -484,6 +500,7 @@ export const Sidebar = memo(function Sidebar({
activeWorkspaceId={activeWorkspaceId}
activeThreadId={activeThreadId}
threadStatusById={threadStatusById}
+ pendingUserInputKeys={pendingUserInputKeys}
getThreadTime={getThreadTime}
isThreadPinned={isThreadPinned}
onSelectThread={onSelectThread}
@@ -635,6 +652,7 @@ export const Sidebar = memo(function Sidebar({
expandedWorkspaces={expandedWorkspaces}
activeWorkspaceId={activeWorkspaceId}
activeThreadId={activeThreadId}
+ pendingUserInputKeys={pendingUserInputKeys}
getThreadRows={getThreadRows}
getThreadTime={getThreadTime}
isThreadPinned={isThreadPinned}
@@ -661,6 +679,7 @@ export const Sidebar = memo(function Sidebar({
activeWorkspaceId={activeWorkspaceId}
activeThreadId={activeThreadId}
threadStatusById={threadStatusById}
+ pendingUserInputKeys={pendingUserInputKeys}
getThreadTime={getThreadTime}
isThreadPinned={isThreadPinned}
onToggleExpanded={handleToggleExpanded}
diff --git a/src/features/app/components/ThreadList.test.tsx b/src/features/app/components/ThreadList.test.tsx
index f2145979e..0451cab03 100644
--- a/src/features/app/components/ThreadList.test.tsx
+++ b/src/features/app/components/ThreadList.test.tsx
@@ -133,4 +133,23 @@ describe("ThreadList", () => {
false,
);
});
+
+ it("shows blue unread-style status when a thread is waiting for user input", () => {
+ const { container } = render(
+ ,
+ );
+
+ const row = container.querySelector(".thread-row");
+ expect(row).toBeTruthy();
+ expect(row?.querySelector(".thread-name")?.textContent).toBe("Alpha");
+ expect(row?.querySelector(".thread-status")?.className).toContain("unread");
+ expect(row?.querySelector(".thread-status")?.className).not.toContain("processing");
+ });
});
diff --git a/src/features/app/components/ThreadList.tsx b/src/features/app/components/ThreadList.tsx
index 50ce7f7d8..147b7709a 100644
--- a/src/features/app/components/ThreadList.tsx
+++ b/src/features/app/components/ThreadList.tsx
@@ -25,6 +25,7 @@ type ThreadListProps = {
activeWorkspaceId: string | null;
activeThreadId: string | null;
threadStatusById: ThreadStatusMap;
+ pendingUserInputKeys?: Set;
getThreadTime: (thread: ThreadSummary) => string | null;
isThreadPinned: (workspaceId: string, threadId: string) => boolean;
onToggleExpanded: (workspaceId: string) => void;
@@ -51,6 +52,7 @@ export function ThreadList({
activeWorkspaceId,
activeThreadId,
threadStatusById,
+ pendingUserInputKeys,
getThreadTime,
isThreadPinned,
onToggleExpanded,
@@ -66,7 +68,12 @@ export function ThreadList({
? ({ "--thread-indent": `${depth * indentUnit}px` } as CSSProperties)
: undefined;
const status = threadStatusById[thread.id];
- const statusClass = status?.isReviewing
+ const hasPendingUserInput = Boolean(
+ pendingUserInputKeys?.has(`${workspaceId}:${thread.id}`),
+ );
+ const statusClass = hasPendingUserInput
+ ? "unread"
+ : status?.isReviewing
? "reviewing"
: status?.isProcessing
? "processing"
diff --git a/src/features/app/components/WorktreeSection.tsx b/src/features/app/components/WorktreeSection.tsx
index 51695e6f3..1fb6c7919 100644
--- a/src/features/app/components/WorktreeSection.tsx
+++ b/src/features/app/components/WorktreeSection.tsx
@@ -29,6 +29,7 @@ type WorktreeSectionProps = {
expandedWorkspaces: Set;
activeWorkspaceId: string | null;
activeThreadId: string | null;
+ pendingUserInputKeys?: Set;
getThreadRows: (
threads: ThreadSummary[],
isExpanded: boolean,
@@ -64,6 +65,7 @@ export function WorktreeSection({
expandedWorkspaces,
activeWorkspaceId,
activeThreadId,
+ pendingUserInputKeys,
getThreadRows,
getThreadTime,
isThreadPinned,
@@ -136,6 +138,7 @@ export function WorktreeSection({
activeWorkspaceId={activeWorkspaceId}
activeThreadId={activeThreadId}
threadStatusById={threadStatusById}
+ pendingUserInputKeys={pendingUserInputKeys}
getThreadTime={getThreadTime}
isThreadPinned={isThreadPinned}
onToggleExpanded={onToggleExpanded}
diff --git a/src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx b/src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx
index fe84a5eb0..c4b5c2158 100644
--- a/src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx
+++ b/src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx
@@ -50,6 +50,7 @@ export function buildPrimaryNodes(options: LayoutNodesOptions): PrimaryLayoutNod
onRefreshAllThreads={options.onRefreshAllThreads}
activeWorkspaceId={options.activeWorkspaceId}
activeThreadId={options.activeThreadId}
+ userInputRequests={options.userInputRequests}
accountRateLimits={options.activeRateLimits}
usageShowRemaining={options.usageShowRemaining}
accountInfo={options.accountInfo}
diff --git a/src/features/threads/hooks/useThreadUserInputEvents.test.tsx b/src/features/threads/hooks/useThreadUserInputEvents.test.tsx
new file mode 100644
index 000000000..23cecb8af
--- /dev/null
+++ b/src/features/threads/hooks/useThreadUserInputEvents.test.tsx
@@ -0,0 +1,33 @@
+// @vitest-environment jsdom
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { useThreadUserInputEvents } from "./useThreadUserInputEvents";
+
+describe("useThreadUserInputEvents", () => {
+ it("queues request user input without clearing turn state", () => {
+ const dispatch = vi.fn();
+
+ const { result } = renderHook(() =>
+ useThreadUserInputEvents({
+ dispatch,
+ }),
+ );
+
+ const request = {
+ workspace_id: "ws-1",
+ request_id: "req-1",
+ params: {
+ thread_id: "thread-1",
+ turn_id: "turn-1",
+ item_id: "item-1",
+ questions: [],
+ },
+ };
+
+ act(() => {
+ result.current(request);
+ });
+
+ expect(dispatch).toHaveBeenCalledWith({ type: "addUserInputRequest", request });
+ });
+});
diff --git a/src/features/threads/hooks/useThreads.integration.test.tsx b/src/features/threads/hooks/useThreads.integration.test.tsx
index c3e3fa263..759ca1b75 100644
--- a/src/features/threads/hooks/useThreads.integration.test.tsx
+++ b/src/features/threads/hooks/useThreads.integration.test.tsx
@@ -8,10 +8,13 @@ import {
interruptTurn,
listThreads,
resumeThread,
+ sendUserMessage as sendUserMessageService,
setThreadName,
startReview,
+ steerTurn,
} from "@services/tauri";
import { STORAGE_KEY_DETACHED_REVIEW_LINKS } from "@threads/utils/threadStorage";
+import { useQueuedSend } from "./useQueuedSend";
import { useThreads } from "./useThreads";
type AppServerHandlers = Parameters[0];
@@ -29,6 +32,7 @@ vi.mock("@services/tauri", () => ({
respondToUserInputRequest: vi.fn(),
rememberApprovalRule: vi.fn(),
sendUserMessage: vi.fn(),
+ steerTurn: vi.fn(),
startReview: vi.fn(),
startThread: vi.fn(),
listThreads: vi.fn(),
@@ -445,6 +449,186 @@ describe("useThreads UX integration", () => {
expect(interruptMock).toHaveBeenCalledTimes(2);
});
+ it("keeps queued sends blocked while request user input is pending", async () => {
+ vi.mocked(sendUserMessageService)
+ .mockResolvedValueOnce({
+ result: { turn: { id: "turn-1" } },
+ } as Awaited>)
+ .mockResolvedValueOnce({
+ result: { turn: { id: "turn-2" } },
+ } as Awaited>);
+ const connectWorkspace = vi.fn().mockResolvedValue(undefined);
+ const clearActiveImages = vi.fn();
+
+ const { result } = renderHook(() => {
+ const threads = useThreads({
+ activeWorkspace: workspace,
+ onWorkspaceConnected: vi.fn(),
+ });
+ const threadId = threads.activeThreadId;
+ const status = threadId ? threads.threadStatusById[threadId] : undefined;
+ const queued = useQueuedSend({
+ activeThreadId: threadId,
+ activeTurnId: threadId ? threads.activeTurnIdByThread[threadId] ?? null : null,
+ isProcessing: status?.isProcessing ?? false,
+ isReviewing: status?.isReviewing ?? false,
+ steerEnabled: false,
+ appsEnabled: true,
+ activeWorkspace: workspace,
+ connectWorkspace,
+ startThreadForWorkspace: threads.startThreadForWorkspace,
+ sendUserMessage: threads.sendUserMessage,
+ sendUserMessageToThread: threads.sendUserMessageToThread,
+ startFork: threads.startFork,
+ startReview: threads.startReview,
+ startResume: threads.startResume,
+ startCompact: threads.startCompact,
+ startApps: threads.startApps,
+ startMcp: threads.startMcp,
+ startStatus: threads.startStatus,
+ clearActiveImages,
+ });
+ return { threads, queued };
+ });
+
+ expect(handlers).not.toBeNull();
+
+ act(() => {
+ result.current.threads.setActiveThreadId("thread-1");
+ });
+
+ await act(async () => {
+ await result.current.threads.sendUserMessage("Start running turn");
+ });
+
+ await waitFor(() => {
+ expect(result.current.threads.threadStatusById["thread-1"]?.isProcessing).toBe(true);
+ expect(result.current.threads.activeTurnIdByThread["thread-1"]).toBe("turn-1");
+ expect(sendUserMessageService).toHaveBeenCalledTimes(1);
+ });
+
+ await act(async () => {
+ await result.current.queued.handleSend("Queued during turn");
+ });
+
+ expect(result.current.queued.activeQueue).toHaveLength(1);
+ expect(sendUserMessageService).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ handlers?.onRequestUserInput?.({
+ workspace_id: "ws-1",
+ request_id: "request-1",
+ params: {
+ thread_id: "thread-1",
+ turn_id: "turn-1",
+ item_id: "item-1",
+ questions: [],
+ },
+ });
+ });
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(result.current.queued.activeQueue).toHaveLength(1);
+ expect(sendUserMessageService).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ handlers?.onTurnCompleted?.("ws-1", "thread-1", "turn-1");
+ });
+
+ await waitFor(() => {
+ expect(sendUserMessageService).toHaveBeenCalledTimes(2);
+ });
+ const queuedCall = vi.mocked(sendUserMessageService).mock.calls[1];
+ expect(queuedCall?.[0]).toBe("ws-1");
+ expect(queuedCall?.[1]).toBe("thread-1");
+ expect(queuedCall?.[2]).toBe("Queued during turn");
+ });
+
+ it("keeps active turn id after request user input so interrupt targets the running turn", async () => {
+ const interruptMock = vi.mocked(interruptTurn);
+ interruptMock.mockResolvedValue({ result: {} });
+
+ const { result } = renderHook(() =>
+ useThreads({
+ activeWorkspace: workspace,
+ onWorkspaceConnected: vi.fn(),
+ }),
+ );
+
+ act(() => {
+ result.current.setActiveThreadId("thread-1");
+ handlers?.onTurnStarted?.("ws-1", "thread-1", "turn-1");
+ handlers?.onRequestUserInput?.({
+ workspace_id: "ws-1",
+ request_id: "request-1",
+ params: {
+ thread_id: "thread-1",
+ turn_id: "turn-1",
+ item_id: "item-1",
+ questions: [],
+ },
+ });
+ });
+
+ await act(async () => {
+ await result.current.interruptTurn();
+ });
+
+ expect(interruptMock).toHaveBeenCalledWith("ws-1", "thread-1", "turn-1");
+ expect(interruptMock).not.toHaveBeenCalledWith("ws-1", "thread-1", "pending");
+ });
+
+ it("uses turn steer after request user input when the turn is still active", async () => {
+ vi.mocked(steerTurn).mockResolvedValue({
+ result: { turnId: "turn-1" },
+ } as Awaited>);
+ vi.mocked(sendUserMessageService).mockResolvedValue({
+ result: { turn: { id: "turn-2" } },
+ } as Awaited>);
+
+ const { result } = renderHook(() =>
+ useThreads({
+ activeWorkspace: workspace,
+ onWorkspaceConnected: vi.fn(),
+ steerEnabled: true,
+ }),
+ );
+
+ act(() => {
+ result.current.setActiveThreadId("thread-1");
+ handlers?.onTurnStarted?.("ws-1", "thread-1", "turn-1");
+ handlers?.onRequestUserInput?.({
+ workspace_id: "ws-1",
+ request_id: "request-1",
+ params: {
+ thread_id: "thread-1",
+ turn_id: "turn-1",
+ item_id: "item-1",
+ questions: [],
+ },
+ });
+ });
+
+ expect(result.current.threadStatusById["thread-1"]?.isProcessing).toBe(true);
+ expect(result.current.activeTurnIdByThread["thread-1"]).toBe("turn-1");
+
+ await act(async () => {
+ await result.current.sendUserMessage("Steer after user input");
+ });
+
+ expect(steerTurn).toHaveBeenCalledWith(
+ "ws-1",
+ "thread-1",
+ "turn-1",
+ "Steer after user input",
+ [],
+ );
+ expect(sendUserMessageService).not.toHaveBeenCalled();
+ });
+
it("links detached review thread to its parent", async () => {
vi.mocked(startReview).mockResolvedValue({
result: { reviewThreadId: "thread-review-1" },