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" },