From 1b7e99d1b502d67d8727b8afcd6533088be09258 Mon Sep 17 00:00:00 2001 From: sky <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:14:21 +0000 Subject: [PATCH 1/2] fix: show and stop stalled commands --- .../task/[taskId]/Messages.client.test.tsx | 14 ++ .../app/(sandbox)/task/[taskId]/Messages.tsx | 26 +++ .../acp-protocol-service.client.test.ts | 40 +++++ .../hooks/services/acp-protocol-service.ts | 3 + .../messages/acp/AcpCommandOutputMessage.tsx | 92 ++++++++-- .../[taskId]/messages/acp/AcpMessageItem.tsx | 15 ++ .../AcpCommandOutputMessage.client.test.tsx | 159 +++++++++++++++++- .../task/[taskId]/messages/acp/types.ts | 2 + .../trpc/commands/sandbox-session/index.ts | 60 +++++++ .../sandbox-session/send-prompt.test.ts | 31 ++++ apps/web/src/trpc/routers/_app.ts | 7 + 11 files changed, 438 insertions(+), 11 deletions(-) diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx index 2545394cc..74a872a33 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx @@ -41,9 +41,23 @@ vi.mock('./hooks', () => ({ messages: sandboxMessagesState.messages, }), useSandboxHistoryReady: () => true, + useSandboxConnectionStatus: () => ({ + connected: true, + hasConnectedOnce: true, + }), useSandboxTaskPhase: () => taskPhaseState.phase, })); +vi.mock('@tanstack/react-query', () => ({ + useMutation: () => ({ isPending: false, mutate: vi.fn() }), +})); + +vi.mock('@/trpc/client', () => ({ + useTRPCClient: () => ({ + sandboxSession: { abortTurn: { mutate: vi.fn() } }, + }), +})); + vi.mock('@/hooks/useNarrationMode', () => ({ useNarrationMode: () => ({ enabled: narrationModeState.enabled, diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Messages.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Messages.tsx index d9cac0010..0dfba7bbb 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Messages.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Messages.tsx @@ -9,6 +9,8 @@ import { useState, type MutableRefObject, } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; import type { ScrollToBottom } from 'use-stick-to-bottom'; import { @@ -28,10 +30,12 @@ import { import { useNarrationMode } from '@/hooks/useNarrationMode'; import { Lightbulb, Skeleton } from '@/components/system'; import { cn } from '@/lib/utils'; +import { useTRPCClient } from '@/trpc/client'; import { useSandboxMessages, useSandboxHistoryReady, + useSandboxConnectionStatus, useSandboxTaskPhase, type TaskSession, } from './hooks'; @@ -199,6 +203,19 @@ const MessagesBase = ({ const { messages } = useSandboxMessages(); const historyReady = useSandboxHistoryReady(); const taskPhase = useSandboxTaskPhase(); + const { connected, hasConnectedOnce } = useSandboxConnectionStatus(); + const trpcClient = useTRPCClient(); + const [abortRequested, setAbortRequested] = useState(false); + const abortTurn = useMutation({ + mutationFn: () => + trpcClient.sandboxSession.abortTurn.mutate({ taskId: session.taskId }), + onSuccess: () => { + setAbortRequested(true); + }, + onError: (error) => { + toast.error(error.message || 'Unable to stop the running command.'); + }, + }); const { enabled: narrationModeEnabled } = useNarrationMode(); const [suppressedMessageIds, setSuppressedMessageIds] = useState>( () => new Set(), @@ -219,6 +236,10 @@ const MessagesBase = ({ setSuppressedMessageIds(new Set()); }, [session.taskId]); + useEffect(() => { + setAbortRequested(false); + }, [session.taskId, taskPhase]); + const suppressMessage = useCallback((messageId: string) => { setSuppressedMessageIds((prev) => { if (prev.has(messageId)) { @@ -380,6 +401,11 @@ const MessagesBase = ({ abortTurn.mutate()} showSubagentPayload={showInternalMessages} > {block.childBlocks?.length diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/__tests__/acp-protocol-service.client.test.ts b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/__tests__/acp-protocol-service.client.test.ts index 55a580f84..b33008f44 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/__tests__/acp-protocol-service.client.test.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/__tests__/acp-protocol-service.client.test.ts @@ -47,6 +47,28 @@ function toolCallUpdate(sequence: number): AcpMessage { }; } +function toolCall(sequence: number): AcpMessage { + return { + id: `opencode-server:${sequence}`, + ts: 1000 + sequence, + eventType: ACP_ENVELOPE_EVENT_TYPES.ToolCall, + role: 'tool', + kind: 'tool_call', + contentBlocks: [], + metadata: { + sessionId: 'session-opencode', + turnId: 'message-opencode', + }, + payload: { + toolCallId: 'tool-call-1', + kind: 'execute', + title: 'Run command', + command: 'git push', + status: 'in_progress', + }, + }; +} + function subagentActivityUpdate( sequence: number, lastMessage: string, @@ -72,6 +94,24 @@ function subagentActivityUpdate( } describe('AcpProtocolService', () => { + it('preserves a tool call start time across later updates', () => { + const service = new AcpProtocolService(); + let messages = service.applyOutputEvent([], toolCall(1))!.acpMessages; + + messages = service.applyOutputEvent( + messages, + toolCallUpdate(20), + )!.acpMessages; + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + ts: 1020, + startedAt: 1001, + kind: 'tool_result', + partial: true, + }); + }); + it('continues a partial assistant stream after active stream state is rebuilt', () => { const service = new AcpProtocolService(); let messages = service.applyOutputEvent( diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/acp-protocol-service.ts b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/acp-protocol-service.ts index 6731bbb8d..793e475ec 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/acp-protocol-service.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/services/acp-protocol-service.ts @@ -339,6 +339,7 @@ export function toAcpUiMessage( return { ...base, + startedAt: normalized.ts, role: 'tool', kind: 'tool_call', text: toolCallPayload.title ?? normalized.text, @@ -1000,6 +1001,7 @@ export class AcpProtocolService { next[existingIndex] = { ...toolResultMessage, + startedAt: existing.startedAt ?? existing.ts, text: incomingText || existing.text, data: mergeToolResultPayload(existing, toolResultMessage), previousTs: existing.previousTs, @@ -1559,6 +1561,7 @@ export class AcpProtocolService { kind: 'tool_result', partial: isRunning || !isTerminal, ts: event.ts, + startedAt: existing.startedAt ?? existing.ts, text: extractToolResultText(event.payload) || candidate.text || diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.tsx index adeb906c1..9130907ad 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.tsx @@ -1,5 +1,10 @@ +'use client'; + +import { useEffect, useState } from 'react'; + import { sanitizeSandboxPathString } from '@/lib'; import { cn } from '@/lib/utils'; +import { Button, SquareIcon } from '@/components/system'; import { CodeBlock, @@ -20,13 +25,41 @@ interface AcpCommandOutputMessageProps { msg: AcpToolCallUiMessage | AcpToolResultUiMessage; ts?: number; status?: AcpCommandStatus; + connected?: boolean; + connectionWasEstablished?: boolean; + canAbort?: boolean; + abortPending?: boolean; + onAbort?: () => void; +} + +function formatCommandDuration(durationMs: number): string { + const totalSeconds = Math.max(1, Math.floor(Math.max(0, durationMs) / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + } + + if (minutes > 0) { + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + } + + return `${totalSeconds}s`; } export const AcpCommandOutputMessage = ({ msg, ts, status = null, + connected = true, + connectionWasEstablished = false, + canAbort = false, + abortPending = false, + onAbort, }: AcpCommandOutputMessageProps) => { + const [now, setNow] = useState(() => Date.now()); const cmd = msg.kind === 'tool_result' ? { @@ -51,16 +84,42 @@ export const AcpCommandOutputMessage = ({ status === 'in_progress' || (status === null && !isExitCodePresent); const isFailed = status === 'failed' || (isExitCodePresent && cmd.exitCode !== 0); + const isDisconnected = isPending && connectionWasEstablished && !connected; + const startedAt = msg.startedAt ?? msg.ts; + const duration = + isPending || msg.startedAt !== undefined + ? formatCommandDuration((isPending ? now : msg.ts) - startedAt) + : null; + const quietDuration = formatCommandDuration(now - msg.ts); + + useEffect(() => { + if (!isPending || isDisconnected) { + return; + } + + const intervalId = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(intervalId); + }, [isDisconnected, isPending]); - const statusText = isPending - ? null - : isExitCodePresent - ? cmd.exitCode === 0 - ? null - : `exit ${cmd.exitCode}` - : status === 'failed' - ? 'failed' - : null; + const statusText = isDisconnected + ? 'last known running · connection lost' + : isPending + ? now - msg.ts >= 15_000 + ? `running ${duration} · last update ${quietDuration} ago` + : `running ${duration}` + : isExitCodePresent + ? cmd.exitCode === 0 + ? duration + ? `completed in ${duration}` + : null + : duration + ? `exit ${cmd.exitCode} · ${duration}` + : `exit ${cmd.exitCode}` + : status === 'failed' + ? duration + ? `failed · ${duration}` + : 'failed' + : null; return ( @@ -81,7 +140,7 @@ export const AcpCommandOutputMessage = ({ @@ -94,6 +153,19 @@ export const AcpCommandOutputMessage = ({ → {statusText} )} + {isPending && canAbort && onAbort && ( + + )} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx index ddc9bdcf1..4d22e2804 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx @@ -12,6 +12,11 @@ import { AcpUnknownMessage } from './AcpUnknownMessage'; interface AcpMessageItemProps { msg: AcpUiMessage; onSuppress?: (messageId: string) => void; + commandConnected?: boolean; + commandConnectionWasEstablished?: boolean; + canAbortCommand?: boolean; + commandAbortPending?: boolean; + onAbortCommand?: () => void; showSubagentPayload?: boolean; children?: ReactNode; } @@ -19,6 +24,11 @@ interface AcpMessageItemProps { function AcpMessageItemBase({ msg, onSuppress, + commandConnected, + commandConnectionWasEstablished, + canAbortCommand, + commandAbortPending, + onAbortCommand, showSubagentPayload = false, children, }: AcpMessageItemProps) { @@ -36,6 +46,11 @@ function AcpMessageItemBase({ msg={msg} ts={msg.ts} status={msg.data.status} + connected={commandConnected} + connectionWasEstablished={commandConnectionWasEstablished} + canAbort={canAbortCommand} + abortPending={commandAbortPending} + onAbort={onAbortCommand} /> ) : ( diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpCommandOutputMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpCommandOutputMessage.client.test.tsx index 4f6f9bd35..554ab6262 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpCommandOutputMessage.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpCommandOutputMessage.client.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import type { ReactNode } from 'react'; import type { AcpToolCallUiMessage, AcpToolResultUiMessage } from '../types'; @@ -50,6 +50,10 @@ describe('AcpCommandOutputMessage', () => { codeBlockCommandSpy.mockClear(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it('renders command headers without expandable raw output', () => { const msg: AcpToolResultUiMessage = { ...baseMsg, @@ -201,4 +205,157 @@ describe('AcpCommandOutputMessage', () => { }), ); }); + + it('shows elapsed and quiet time while a command is running', () => { + vi.useFakeTimers(); + vi.setSystemTime(31_000); + const msg: AcpToolCallUiMessage = { + ...baseMsg, + ts: 11_000, + startedAt: 1_000, + kind: 'tool_call', + data: { + toolCallId: 'tool-call-1', + kind: 'execute', + title: null, + isExecute: true, + isRead: false, + isMcp: false, + mcpServerName: null, + mcpToolName: null, + command: 'git push', + status: 'in_progress', + }, + }; + + render(); + + expect( + screen.getByText('→ running 30s · last update 20s ago'), + ).toBeInTheDocument(); + }); + + it('marks a pending command as last-known when the live connection is lost', () => { + const msg: AcpToolCallUiMessage = { + ...baseMsg, + kind: 'tool_call', + data: { + toolCallId: 'tool-call-1', + kind: 'execute', + title: null, + isExecute: true, + isRead: false, + isMcp: false, + mcpServerName: null, + mcpToolName: null, + command: 'git push', + status: 'in_progress', + }, + }; + + render( + , + ); + + expect( + screen.getByText('→ last known running · connection lost'), + ).toBeInTheDocument(); + expect(codeBlockCommandSpy).toHaveBeenCalledWith( + expect.objectContaining({ spinner: false }), + ); + }); + + it('shows the final command duration', () => { + const msg: AcpToolResultUiMessage = { + ...baseMsg, + ts: 6_000, + startedAt: 1_000, + kind: 'tool_result', + data: { + toolCallId: 'tool-call-1', + kind: 'execute', + title: null, + isExecute: true, + isMcp: false, + mcpServerName: null, + mcpToolName: null, + command: 'git push', + exitCode: 0, + output: '', + status: 'completed', + }, + }; + + render(); + + expect(screen.getByText('→ completed in 5s')).toBeInTheDocument(); + }); + + it('offers an abort action for the active command', () => { + const onAbort = vi.fn(); + const msg: AcpToolCallUiMessage = { + ...baseMsg, + kind: 'tool_call', + data: { + toolCallId: 'tool-call-1', + kind: 'execute', + title: null, + isExecute: true, + isRead: false, + isMcp: false, + mcpServerName: null, + mcpToolName: null, + command: 'git push', + status: 'in_progress', + }, + }; + + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Abort' })); + expect(onAbort).toHaveBeenCalledTimes(1); + }); + + it('keeps the abort action disabled while the turn is stopping', () => { + const msg: AcpToolCallUiMessage = { + ...baseMsg, + kind: 'tool_call', + data: { + toolCallId: 'tool-call-1', + kind: 'execute', + title: null, + isExecute: true, + isRead: false, + isMcp: false, + mcpServerName: null, + mcpToolName: null, + command: 'git push', + status: 'in_progress', + }, + }; + + render( + , + ); + + expect(screen.getByRole('button', { name: 'Stopping...' })).toBeDisabled(); + }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/types.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/types.ts index 37d77218b..2351fd494 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/types.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/types.ts @@ -10,6 +10,8 @@ import type { interface AcpUiMessageBase { id: string; ts: number; + /** First event timestamp for a tool call; `ts` continues to track its latest update. */ + startedAt?: number; role: Exclude; partial: boolean; isTurnCompletion?: boolean; diff --git a/apps/web/src/trpc/commands/sandbox-session/index.ts b/apps/web/src/trpc/commands/sandbox-session/index.ts index 3b81fe7d5..cc8467c39 100644 --- a/apps/web/src/trpc/commands/sandbox-session/index.ts +++ b/apps/web/src/trpc/commands/sandbox-session/index.ts @@ -62,6 +62,7 @@ import { const SANDBOX_PROMPT_TOKEN_TIMEOUT_MS = 15 * 60 * 1000; const SANDBOX_PROMPT_TIMEOUT_MS = 30_000; +const SANDBOX_ABORT_TIMEOUT_MS = 30_000; const SANDBOX_RPC_HEALTHCHECK_TIMEOUT_MS = 5_000; const requestUserInputAnswersSchema = z.record( z.object({ @@ -395,6 +396,65 @@ export async function sendSandboxPromptCommand( } } +export async function abortSandboxTurnCommand( + auth: UserAuthSuccess, + input: { taskId: string }, +) { + const { taskRun } = await getResolvedSandboxTaskRunByTaskId(auth, input); + + if (!taskRun.sandboxServerUrl) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'The task no longer has a live sandbox to stop.', + }); + } + + const authToken = await createRunToken({ + runId: taskRun.id, + userId: auth.userId, + timeoutMs: SANDBOX_PROMPT_TOKEN_TIMEOUT_MS, + }); + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + SANDBOX_ABORT_TIMEOUT_MS, + ); + + try { + const client = createSandboxServerRpcClient({ + links: [ + httpBatchLink({ + url: `${taskRun.sandboxServerUrl}/trpc`, + transformer: superjson, + headers: () => ({ Authorization: `Bearer ${authToken}` }), + fetch: (url, init) => + fetch(url, { ...init, signal: controller.signal }), + }), + ], + }); + + await client.commands.cancelTask.mutate({ + cancelledBy: { + name: getAuthenticatedPromptUserName(auth), + source: 'web', + }, + }); + + return { success: true as const }; + } catch (error) { + if (error instanceof TRPCClientError) { + throw new TRPCError({ + code: 'BAD_GATEWAY', + message: error.message, + }); + } + + throw error; + } finally { + clearTimeout(timeoutId); + } +} + export async function answerSandboxUserInputRequestCommand( auth: UserAuthSuccess, input: z.input, diff --git a/apps/web/src/trpc/commands/sandbox-session/send-prompt.test.ts b/apps/web/src/trpc/commands/sandbox-session/send-prompt.test.ts index 54fffa22a..5d5512f1f 100644 --- a/apps/web/src/trpc/commands/sandbox-session/send-prompt.test.ts +++ b/apps/web/src/trpc/commands/sandbox-session/send-prompt.test.ts @@ -1,12 +1,14 @@ const { mockCreateRunToken, mockSendPromptMutate, + mockCancelTaskMutate, mockClaimOutOfBandContext, mockSetLatestUserMessageForReplyQuote, mockClearLatestUserMessageForReplyQuoteIfId, } = vi.hoisted(() => ({ mockCreateRunToken: vi.fn(), mockSendPromptMutate: vi.fn(), + mockCancelTaskMutate: vi.fn(), mockClaimOutOfBandContext: vi.fn(), mockSetLatestUserMessageForReplyQuote: vi.fn(), mockClearLatestUserMessageForReplyQuoteIfId: vi.fn(), @@ -43,6 +45,9 @@ vi.mock('@trpc/client', async () => { ...actual, createTRPCProxyClient: vi.fn(() => ({ commands: { + cancelTask: { + mutate: mockCancelTaskMutate, + }, sendPrompt: { mutate: mockSendPromptMutate, }, @@ -75,6 +80,7 @@ import { RunStatus } from '@roomote/types'; import type { UserAuthSuccess } from '@/types'; import { + abortSandboxTurnCommand, sendSandboxPromptCommand, sendSandboxPromptInputSchema, } from './index'; @@ -118,6 +124,7 @@ describe('sendSandboxPromptCommand', () => { ); mockCreateRunToken.mockResolvedValue('run-token'); mockSendPromptMutate.mockResolvedValue({ success: true }); + mockCancelTaskMutate.mockResolvedValue({ success: true }); mockClaimOutOfBandContext.mockResolvedValue(null); mockSetLatestUserMessageForReplyQuote.mockResolvedValue({ id: 'discord-quote-1', @@ -127,6 +134,30 @@ describe('sendSandboxPromptCommand', () => { mockClearLatestUserMessageForReplyQuoteIfId.mockResolvedValue(true); }); + it('aborts the active turn through the server-side sandbox client', async () => { + const user = await userFactory.create({ name: 'DB User' }); + const task = await taskFactory.create({ initiatorUserId: user.id }); + + await runFactory.create({ + actingUserId: user.id, + taskId: task.id, + status: RunStatus.Running, + sandboxServerUrl: 'http://sandbox.example.test', + result: {}, + }); + + await expect( + abortSandboxTurnCommand( + buildMockAuth({ userId: user.id, name: 'Test User' }), + { taskId: task.id }, + ), + ).resolves.toEqual({ success: true }); + + expect(mockCancelTaskMutate).toHaveBeenCalledWith({ + cancelledBy: { name: 'Test User', source: 'web' }, + }); + }); + afterEach(() => { vi.unstubAllGlobals(); }); diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 958b0180c..ed2d42dda 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -180,6 +180,7 @@ import { restoreTaskRunSnapshotCommand, } from '../commands/snapshots'; import { + abortSandboxTurnCommand, answerSandboxUserInputRequestCommand, answerSandboxUserInputRequestInputSchema, getSandboxSessionByTaskIdCommand, @@ -1836,6 +1837,12 @@ export const appRouter = createRouter({ answerSandboxUserInputRequestCommand(auth, input), ), + abortTurn: protectedProcedure + .input(z.object({ taskId: z.string() })) + .mutation(({ ctx: { auth }, input }) => + abortSandboxTurnCommand(auth, input), + ), + takeOverBrowserControl: protectedProcedure .input( z.object({ From 479694ff5e35cebfc5e8b502d841b2f6df02d3f6 Mon Sep 17 00:00:00 2001 From: sky <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:57:42 +0000 Subject: [PATCH 2/2] feat: add configurable command output --- .../task/[taskId]/Messages.client.test.tsx | 4 + .../app/(sandbox)/task/[taskId]/Messages.tsx | 4 + .../acp/AcpCommandOutputMessage.stories.tsx | 5 +- .../messages/acp/AcpCommandOutputMessage.tsx | 22 ++-- .../acp/AcpGroupedToolMessage.stories.tsx | 102 ++++++++++++++++++ .../messages/acp/AcpGroupedToolMessage.tsx | 4 + .../[taskId]/messages/acp/AcpMessageItem.tsx | 5 +- .../[taskId]/messages/acp/AcpToolDetails.tsx | 20 +++- .../AcpCommandOutputMessage.client.test.tsx | 90 ++++++++++++++-- .../AcpGroupedToolMessage.client.test.tsx | 48 +++++++++ .../__tests__/AcpMessageItem.client.test.tsx | 16 +++ .../__tests__/AcpToolDetails.client.test.tsx | 36 +++++++ .../messages/acp/tool-detail-visibility.ts | 22 ++-- .../ai-elements/code-block.client.test.tsx | 11 ++ .../src/components/ai-elements/code-block.tsx | 3 +- .../settings/UserPreferencesSection.test.tsx | 58 +++++++--- .../settings/UserPreferencesSection.tsx | 25 +++++ .../usePersonalPreferences.client.test.tsx | 9 ++ apps/web/src/hooks/usePersonalPreferences.ts | 42 ++++---- .../useShowCommandOutput.client.test.tsx | 47 ++++++++ apps/web/src/hooks/useShowCommandOutput.ts | 26 +++++ .../src/trpc/commands/preferences/index.ts | 31 +++--- .../preferences/personal-preferences.test.ts | 39 +++++++ apps/web/src/trpc/routers/_app.ts | 4 +- apps/web/src/types/preferences.ts | 2 + 25 files changed, 594 insertions(+), 81 deletions(-) create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.stories.tsx create mode 100644 apps/web/src/hooks/useShowCommandOutput.client.test.tsx create mode 100644 apps/web/src/hooks/useShowCommandOutput.ts create mode 100644 apps/web/src/trpc/commands/preferences/personal-preferences.test.ts diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx index 74a872a33..15e99938f 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx @@ -67,6 +67,10 @@ vi.mock('@/hooks/useNarrationMode', () => ({ }), })); +vi.mock('@/hooks/useShowCommandOutput', () => ({ + useShowCommandOutput: () => ({ enabled: false }), +})); + vi.mock('./messages/index', () => ({ SleepWakeMessages: () =>
Sleep rows
, })); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Messages.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Messages.tsx index 0dfba7bbb..453e1294e 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Messages.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Messages.tsx @@ -28,6 +28,7 @@ import { type MessageUiOptions, } from '@/components/ai-elements/message-ui-options'; import { useNarrationMode } from '@/hooks/useNarrationMode'; +import { useShowCommandOutput } from '@/hooks/useShowCommandOutput'; import { Lightbulb, Skeleton } from '@/components/system'; import { cn } from '@/lib/utils'; import { useTRPCClient } from '@/trpc/client'; @@ -217,6 +218,7 @@ const MessagesBase = ({ }, }); const { enabled: narrationModeEnabled } = useNarrationMode(); + const { enabled: commandOutputVisible } = useShowCommandOutput(); const [suppressedMessageIds, setSuppressedMessageIds] = useState>( () => new Set(), ); @@ -374,6 +376,7 @@ const MessagesBase = ({ ); @@ -406,6 +409,7 @@ const MessagesBase = ({ canAbortCommand={taskPhase === 'running'} commandAbortPending={abortTurn.isPending || abortRequested} onAbortCommand={() => abortTurn.mutate()} + commandOutputVisible={commandOutputVisible} showSubagentPayload={showInternalMessages} > {block.childBlocks?.length diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.stories.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.stories.tsx index 7494a516e..d286f55ea 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.stories.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.stories.tsx @@ -16,6 +16,9 @@ const meta: Meta = { layout: 'padded', }, tags: ['autodocs'], + args: { + showOutput: true, + }, decorators: [ (Story) => (
@@ -37,7 +40,7 @@ const toolResultMsg = ( ): AcpToolResultUiMessage => ({ id: 'story-msg', - ts: 0, + ts: Date.now(), role: 'assistant', partial: false, sessionId: null, diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.tsx index 9130907ad..373164020 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.tsx @@ -30,6 +30,7 @@ interface AcpCommandOutputMessageProps { canAbort?: boolean; abortPending?: boolean; onAbort?: () => void; + showOutput?: boolean; } function formatCommandDuration(durationMs: number): string { @@ -58,6 +59,7 @@ export const AcpCommandOutputMessage = ({ canAbort = false, abortPending = false, onAbort, + showOutput = false, }: AcpCommandOutputMessageProps) => { const [now, setNow] = useState(() => Date.now()); const cmd = @@ -78,6 +80,8 @@ export const AcpCommandOutputMessage = ({ : undefined; const output = sanitizeSandboxPathString(cmd.text); + const hasOutput = output.trim().length > 0; + const outputVisible = showOutput && hasOutput; const isExitCodePresent = cmd.exitCode !== undefined; const isPending = @@ -128,14 +132,14 @@ export const AcpCommandOutputMessage = ({ code={output} language="bash" variant="compact" - collapsible={false} - defaultCollapsed={false} - forceDark={true} - renderContent={false} - maxHeight={undefined} + collapsible={outputVisible} + defaultCollapsed={!isPending} + renderContent={outputVisible} + maxHeight={240} + highlight={false} command={command ?? ''} showCommandCopy - showOutputCopy={false} + showOutputCopy={outputVisible} > @@ -160,7 +164,11 @@ export const AcpCommandOutputMessage = ({ size="xs" className="mt-0.5 h-6 shrink-0 gap-1 px-1.5 font-sans text-[11px]" disabled={abortPending} - onClick={onAbort} + onClick={(event) => { + event.stopPropagation(); + onAbort(); + }} + onKeyDown={(event) => event.stopPropagation()} > {abortPending ? 'Stopping...' : 'Abort'} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.stories.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.stories.tsx new file mode 100644 index 000000000..b05bc8570 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.stories.tsx @@ -0,0 +1,102 @@ +'use client'; + +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; + +import { AcpGroupedToolMessage } from './AcpGroupedToolMessage'; +import type { GroupedToolCallRenderBlock } from './render-blocks'; +import type { AcpToolResultUiMessage } from './types'; + +const startedAt = Date.now(); + +function commandResult( + id: string, + command: string, + output: string, + status: 'completed' | 'in_progress', +): AcpToolResultUiMessage { + return { + id, + ts: startedAt, + startedAt, + role: 'tool', + partial: status === 'in_progress', + sessionId: 'storybook-session', + updateType: 'roomote_runtime.tool_result', + kind: 'tool_result', + text: output, + data: { + toolCallId: id, + kind: 'execute_command', + title: command, + isExecute: true, + isMcp: false, + mcpServerName: null, + mcpToolName: null, + command, + exitCode: status === 'completed' ? 0 : null, + output, + status, + }, + }; +} + +const group: GroupedToolCallRenderBlock = { + kind: 'tool_group', + id: 'grouped-commands', + ts: startedAt, + action: 'Running', + objectSummary: '2 commands', + groupKey: 'execute:storybook', + displayKind: 'execute', + items: [ + { + objectLabel: 'pnpm install', + groupKey: 'execute:storybook', + displayKind: 'execute', + stepKind: null, + msg: commandResult( + 'install-command', + 'pnpm install', + 'Packages: +1842\nDone in 12.4s', + 'completed', + ), + }, + { + objectLabel: 'pnpm check-types', + groupKey: 'execute:storybook', + displayKind: 'execute', + stepKind: null, + msg: commandResult( + 'typecheck-command', + 'pnpm check-types', + 'Packages in scope: 29\nRunning check-types in 29 packages...', + 'in_progress', + ), + }, + ], +}; + +const meta: Meta = { + title: 'Surfaces/Task Workspace/ACP/GroupedToolMessage', + component: AcpGroupedToolMessage, + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const CommandOutputEnabled: Story = { + args: { + group, + showCommandOutput: true, + }, +}; diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.tsx index 607db3dba..db03f7b3e 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.tsx @@ -31,6 +31,7 @@ import type { interface AcpGroupedToolMessageProps { group: GroupedToolCallRenderBlock; showSubagentPayload?: boolean; + showCommandOutput?: boolean; } const GROUPED_TOOL_ITEM_MAX_HEIGHT = 200; @@ -38,6 +39,7 @@ const GROUPED_TOOL_ITEM_MAX_HEIGHT = 200; export function AcpGroupedToolMessage({ group, showSubagentPayload = false, + showCommandOutput = false, }: AcpGroupedToolMessageProps) { const anchorId = messageAnchorId(group.ts); const objectSummary = sanitizeSandboxPathString(group.objectSummary); @@ -94,6 +96,7 @@ export function AcpGroupedToolMessage({ ); const showItemDetails = !hidesExpandedToolResult(item.msg, { showSubagentPayload, + showCommandOutput, }); return ( @@ -110,6 +113,7 @@ export function AcpGroupedToolMessage({ msg={item.msg} maxHeight={GROUPED_TOOL_ITEM_MAX_HEIGHT} showSubagentPayload={showSubagentPayload} + showCommandOutput={showCommandOutput} /> ) : null} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx index 4d22e2804..82104ffea 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx @@ -17,6 +17,7 @@ interface AcpMessageItemProps { canAbortCommand?: boolean; commandAbortPending?: boolean; onAbortCommand?: () => void; + commandOutputVisible?: boolean; showSubagentPayload?: boolean; children?: ReactNode; } @@ -29,6 +30,7 @@ function AcpMessageItemBase({ canAbortCommand, commandAbortPending, onAbortCommand, + commandOutputVisible, showSubagentPayload = false, children, }: AcpMessageItemProps) { @@ -41,7 +43,7 @@ function AcpMessageItemBase({ return ; case 'tool_call': case 'tool_result': { - return msg.data.kind === 'execute' ? ( + return msg.data.isExecute ? ( ) : ( diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolDetails.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolDetails.tsx index 62416be0a..9ec65a864 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolDetails.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolDetails.tsx @@ -27,21 +27,33 @@ interface AcpToolDetailsProps { msg: AcpToolCallUiMessage | AcpToolResultUiMessage; maxHeight?: number; showSubagentPayload?: boolean; + showCommandOutput?: boolean; } export function AcpToolDetails({ msg, maxHeight = 400, showSubagentPayload = false, + showCommandOutput = false, }: AcpToolDetailsProps) { - if (hidesExpandedToolResult(msg, { showSubagentPayload })) { + if ( + hidesExpandedToolResult(msg, { + showSubagentPayload, + showCommandOutput, + }) + ) { return null; } const sanitizedToolData = sanitizeSandboxPathsForDisplay(msg.data); - const sanitizedText = msg.text - ? sanitizeSandboxPathString(msg.text) - : msg.text; + const displayText = + msg.text || + (msg.kind === 'tool_result' && msg.data.isExecute + ? msg.data.output + : undefined); + const sanitizedText = displayText + ? sanitizeSandboxPathString(displayText) + : displayText; const isSubagent = isSubagentToolPayload(msg.data); const subagentPrompt = getSubagentPrompt(msg); const subagentLastMessage = getSubagentLastMessage(msg); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpCommandOutputMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpCommandOutputMessage.client.test.tsx index 554ab6262..e0ce0b191 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpCommandOutputMessage.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpCommandOutputMessage.client.test.tsx @@ -54,7 +54,7 @@ describe('AcpCommandOutputMessage', () => { vi.useRealTimers(); }); - it('renders command headers without expandable raw output', () => { + it('renders completed command output in a bounded collapsed viewer', () => { const msg: AcpToolResultUiMessage = { ...baseMsg, text: '$ pnpm test\nPASS src/example.test.ts', @@ -74,19 +74,21 @@ describe('AcpCommandOutputMessage', () => { }, }; - render(); + render(); expect(codeBlockSpy).toHaveBeenCalledTimes(1); expect(codeBlockSpy).toHaveBeenCalledWith( expect.objectContaining({ - collapsible: false, - defaultCollapsed: false, - renderContent: false, - maxHeight: undefined, + collapsible: true, + defaultCollapsed: true, + renderContent: true, + maxHeight: 240, + highlight: false, showCommandCopy: true, - showOutputCopy: false, + showOutputCopy: true, }), ); + expect(codeBlockSpy.mock.calls[0]?.[0]).not.toHaveProperty('forceDark'); }); it('keeps command output blocks non-collapsible even when there is no output to show', () => { @@ -108,7 +110,9 @@ describe('AcpCommandOutputMessage', () => { }, }; - render(); + render( + , + ); expect(codeBlockSpy).toHaveBeenCalledTimes(1); expect(codeBlockSpy).toHaveBeenCalledWith( @@ -116,7 +120,7 @@ describe('AcpCommandOutputMessage', () => { collapsible: false, defaultCollapsed: false, renderContent: false, - maxHeight: undefined, + maxHeight: 240, showOutputCopy: false, }), ); @@ -235,6 +239,74 @@ describe('AcpCommandOutputMessage', () => { ).toBeInTheDocument(); }); + it('opens live command output as soon as content arrives', () => { + const msg: AcpToolResultUiMessage = { + ...baseMsg, + text: 'Counting objects: 42%\nCompressing objects: 12%', + kind: 'tool_result', + data: { + toolCallId: 'tool-call-1', + kind: 'execute', + title: null, + isExecute: true, + isMcp: false, + mcpServerName: null, + mcpToolName: null, + command: 'git push', + exitCode: null, + output: 'Counting objects: 42%\nCompressing objects: 12%', + status: 'in_progress', + }, + }; + + render( + , + ); + + expect(codeBlockSpy).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'Counting objects: 42%\nCompressing objects: 12%', + collapsible: true, + defaultCollapsed: false, + renderContent: true, + maxHeight: 240, + highlight: false, + showOutputCopy: true, + }), + ); + }); + + it('keeps command output hidden when the preference is disabled', () => { + const msg: AcpToolResultUiMessage = { + ...baseMsg, + text: 'Counting objects: 42%', + kind: 'tool_result', + data: { + toolCallId: 'tool-call-1', + kind: 'execute', + title: null, + isExecute: true, + isMcp: false, + mcpServerName: null, + mcpToolName: null, + command: 'git push', + exitCode: null, + output: 'Counting objects: 42%', + status: 'in_progress', + }, + }; + + render(); + + expect(codeBlockSpy).toHaveBeenCalledWith( + expect.objectContaining({ + collapsible: false, + renderContent: false, + showOutputCopy: false, + }), + ); + }); + it('marks a pending command as last-known when the live connection is lost', () => { const msg: AcpToolCallUiMessage = { ...baseMsg, diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx index 6985d2161..b3eb9b220 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { AcpGroupedToolMessage } from '../AcpGroupedToolMessage'; import type { GroupedToolCallRenderBlock } from '../render-blocks'; +import type { AcpToolResultUiMessage } from '../types'; const codeBlockSpy = vi.fn(); @@ -115,6 +116,34 @@ function buildGroup(): GroupedToolCallRenderBlock { }; } +function buildExecuteGroup(): GroupedToolCallRenderBlock { + const group = buildGroup(); + + return { + ...group, + action: 'Running', + objectSummary: '2 commands', + displayKind: 'execute', + items: group.items.map((item, index) => ({ + ...item, + objectLabel: `command ${index + 1}`, + displayKind: 'execute', + stepKind: null, + msg: { + ...item.msg, + text: `output ${index + 1}`, + data: { + ...item.msg.data, + kind: 'execute_command', + isExecute: true, + command: `echo ${index + 1}`, + output: `output ${index + 1}`, + }, + } as AcpToolResultUiMessage, + })), + }; +} + describe('AcpGroupedToolMessage', () => { beforeEach(() => { codeBlockSpy.mockClear(); @@ -131,4 +160,23 @@ describe('AcpGroupedToolMessage', () => { expect(codeBlockSpy).not.toHaveBeenCalled(); }); + + it('shows grouped command output only when the preference is enabled', () => { + const group = buildExecuteGroup(); + const { rerender } = render(); + + expect(codeBlockSpy).not.toHaveBeenCalled(); + + rerender(); + + expect(codeBlockSpy).toHaveBeenCalledTimes(2); + expect(codeBlockSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ code: 'output 1' }), + ); + expect(codeBlockSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ code: 'output 2' }), + ); + }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpMessageItem.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpMessageItem.client.test.tsx index 05a310a41..0aa73e239 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpMessageItem.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpMessageItem.client.test.tsx @@ -92,6 +92,22 @@ describe('AcpMessageItem tool routing', () => { expect(toolMessageSpy).not.toHaveBeenCalled(); }); + it('renders alternate execute command kinds as command output', () => { + const msg = buildToolResult('execute_command', { + isExecute: true, + command: 'pnpm test', + exitCode: 0, + }); + + render(); + + expect(screen.getByText('command output')).toBeInTheDocument(); + expect(commandOutputSpy).toHaveBeenCalledWith( + expect.objectContaining({ msg }), + ); + expect(toolMessageSpy).not.toHaveBeenCalled(); + }); + it.each(['read', 'search', 'mcp', 'subagent'])( 'renders %s tools as standard tool rows', (kind) => { diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx index 56f18cf1f..07255ce4b 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx @@ -218,6 +218,42 @@ describe('AcpToolDetails', () => { ); }); + it('shows execute output when command output visibility is enabled', () => { + const msg = { + ...buildMessage({ + kind: 'execute_command', + title: 'Run command', + isExecute: true, + command: 'pnpm test', + output: 'Tests passed', + }), + text: '', + }; + + render(); + + expect(codeBlockSpy).toHaveBeenCalledWith( + expect.objectContaining({ code: 'Tests passed' }), + ); + expect(toolInputSpy).not.toHaveBeenCalled(); + }); + + it('hides execute output when command output visibility is disabled', () => { + const msg = buildMessage({ + kind: 'execute', + title: 'Run command', + isExecute: true, + command: 'pnpm test', + output: 'Tests passed', + }); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + expect(codeBlockSpy).not.toHaveBeenCalled(); + expect(toolInputSpy).not.toHaveBeenCalled(); + }); + it('hides expanded details for Roomote Slack lifecycle tools', () => { const { container } = render( ; + if (isInternalDebugToolCallMessage(msg)) { + return true; + } + + if ( + msg.data.kind === 'execute' || + msg.data.kind === 'execute_command' || + data.isExecute === true + ) { + return options?.showCommandOutput !== true; + } + if (isSubagentToolPayload(msg.data)) { if (options?.showSubagentPayload === true) { return false; @@ -81,12 +94,5 @@ export function hidesExpandedToolResult( ); } - return ( - isInternalDebugToolCallMessage(msg) || - msg.data.kind === 'read' || - msg.data.kind === 'execute' || - msg.data.kind === 'execute_command' || - data.isRead === true || - data.isExecute === true - ); + return msg.data.kind === 'read' || data.isRead === true; } diff --git a/apps/web/src/components/ai-elements/code-block.client.test.tsx b/apps/web/src/components/ai-elements/code-block.client.test.tsx index 737ce59e7..d09985f1b 100644 --- a/apps/web/src/components/ai-elements/code-block.client.test.tsx +++ b/apps/web/src/components/ai-elements/code-block.client.test.tsx @@ -11,6 +11,17 @@ const LONG_COMMAND = 'gh pr checks 1219 --repo Roomote/example-app --watch --required --interval 5 --json state,name,link'; describe('CodeBlock', () => { + it('uses theme colors for compact output', () => { + const { container } = render( + , + ); + + const output = container.querySelector('[data-language="bash"] > div'); + + expect(output).toHaveClass('bg-muted/50', 'text-foreground'); + expect(output).not.toHaveClass('bg-zinc-800', 'dark'); + }); + it('does not reserve collapsed header space for an invisible copy button', () => { render( div]:overflow-visible', )} style={{ diff --git a/apps/web/src/components/settings/UserPreferencesSection.test.tsx b/apps/web/src/components/settings/UserPreferencesSection.test.tsx index 6d0a893fa..8a879b781 100644 --- a/apps/web/src/components/settings/UserPreferencesSection.test.tsx +++ b/apps/web/src/components/settings/UserPreferencesSection.test.tsx @@ -3,20 +3,28 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; type PersonalColorTheme = 'light' | 'dark' | 'system'; -const { colorThemeState, narrationModeState } = vi.hoisted(() => ({ - colorThemeState: { - colorTheme: 'system' as PersonalColorTheme, - isLoading: false, - isUpdating: false, - setColorTheme: vi.fn(), - }, - narrationModeState: { - enabled: false, - isLoading: false, - isUpdating: false, - setEnabled: vi.fn(), - }, -})); +const { colorThemeState, narrationModeState, commandOutputState } = vi.hoisted( + () => ({ + colorThemeState: { + colorTheme: 'system' as PersonalColorTheme, + isLoading: false, + isUpdating: false, + setColorTheme: vi.fn(), + }, + narrationModeState: { + enabled: false, + isLoading: false, + isUpdating: false, + setEnabled: vi.fn(), + }, + commandOutputState: { + enabled: false, + isLoading: false, + isUpdating: false, + setEnabled: vi.fn(), + }, + }), +); vi.mock('@/hooks/useColorTheme', () => ({ useColorTheme: () => colorThemeState, @@ -26,6 +34,10 @@ vi.mock('@/hooks/useNarrationMode', () => ({ useNarrationMode: () => narrationModeState, })); +vi.mock('@/hooks/useShowCommandOutput', () => ({ + useShowCommandOutput: () => commandOutputState, +})); + vi.mock('@/components/system', () => ({ Label: ({ children, @@ -108,11 +120,15 @@ describe('UserPreferencesSection', () => { narrationModeState.enabled = false; narrationModeState.isLoading = false; narrationModeState.isUpdating = false; + commandOutputState.enabled = false; + commandOutputState.isLoading = false; + commandOutputState.isUpdating = false; }); it('renders user preference controls with the current state', () => { colorThemeState.colorTheme = 'dark' as PersonalColorTheme; narrationModeState.enabled = true; + commandOutputState.enabled = true; render(); @@ -126,16 +142,22 @@ describe('UserPreferencesSection', () => { ), ).toBeInTheDocument(); expect(screen.getByLabelText('Toggle narration mode')).toBeChecked(); + expect(screen.getByText('Show command output')).toHaveClass( + 'font-semibold', + ); + expect(screen.getByLabelText('Toggle command output')).toBeChecked(); }); it('disables controls while the corresponding preference is loading or updating', () => { colorThemeState.isLoading = true; narrationModeState.isUpdating = true; + commandOutputState.isLoading = true; render(); expect(screen.getByLabelText('Color theme')).toBeDisabled(); expect(screen.getByLabelText('Toggle narration mode')).toBeDisabled(); + expect(screen.getByLabelText('Toggle command output')).toBeDisabled(); }); it('updates the color theme immediately when a different option is selected', () => { @@ -156,6 +178,14 @@ describe('UserPreferencesSection', () => { expect(narrationModeState.setEnabled).toHaveBeenCalledWith(true); }); + it('updates command output visibility immediately when the switch changes', () => { + render(); + + fireEvent.click(screen.getByLabelText('Toggle command output')); + + expect(commandOutputState.setEnabled).toHaveBeenCalledWith(true); + }); + it('renders theme choices in a dropdown', () => { render(); diff --git a/apps/web/src/components/settings/UserPreferencesSection.tsx b/apps/web/src/components/settings/UserPreferencesSection.tsx index a5b59edcd..d22cd8250 100644 --- a/apps/web/src/components/settings/UserPreferencesSection.tsx +++ b/apps/web/src/components/settings/UserPreferencesSection.tsx @@ -2,6 +2,7 @@ import { useColorTheme } from '@/hooks/useColorTheme'; import { useNarrationMode } from '@/hooks/useNarrationMode'; +import { useShowCommandOutput } from '@/hooks/useShowCommandOutput'; import type { PersonalColorTheme } from '@/types/preferences'; import { @@ -34,6 +35,12 @@ export function UserPreferencesSection() { setColorTheme, } = useColorTheme(); const { enabled, isLoading, isUpdating, setEnabled } = useNarrationMode(); + const { + enabled: commandOutputEnabled, + isLoading: isCommandOutputLoading, + isUpdating: isCommandOutputUpdating, + setEnabled: setCommandOutputEnabled, + } = useShowCommandOutput(); const isThemeDisabled = isThemeLoading || isThemeUpdating; return ( @@ -84,6 +91,24 @@ export function UserPreferencesSection() {

+ +
+ +
+

+ Show command output +

+

+ Display expandable command output in task conversations. Narration + mode still hides command activity. +

+
+
); diff --git a/apps/web/src/hooks/usePersonalPreferences.client.test.tsx b/apps/web/src/hooks/usePersonalPreferences.client.test.tsx index 230795267..de8ca701c 100644 --- a/apps/web/src/hooks/usePersonalPreferences.client.test.tsx +++ b/apps/web/src/hooks/usePersonalPreferences.client.test.tsx @@ -143,6 +143,7 @@ describe('usePersonalPreferences', () => { expect(result.current.preferences).toEqual({ colorTheme: 'system', narrationMode: false, + showCommandOutput: false, }); }); @@ -154,6 +155,14 @@ describe('usePersonalPreferences', () => { expect(mutateMock).toHaveBeenCalledWith({ colorTheme: 'dark' }); }); + it('updates command output visibility independently', () => { + const { result } = renderHook(() => usePersonalPreferences()); + + result.current.setPreferences({ showCommandOutput: true }); + + expect(mutateMock).toHaveBeenCalledWith({ showCommandOutput: true }); + }); + it('optimistically updates and revalidates the preferences cache', async () => { renderHook(() => usePersonalPreferences()); const options = getMutationOptions(); diff --git a/apps/web/src/hooks/usePersonalPreferences.ts b/apps/web/src/hooks/usePersonalPreferences.ts index 37c93a6af..5cd67d677 100644 --- a/apps/web/src/hooks/usePersonalPreferences.ts +++ b/apps/web/src/hooks/usePersonalPreferences.ts @@ -39,14 +39,16 @@ function mergeResultForUpdatedFields( const mergedPreferences = currentPreferences ?? DEFAULT_PERSONAL_PREFERENCES; return { - colorTheme: - updates.colorTheme === undefined - ? mergedPreferences.colorTheme - : result.colorTheme, - narrationMode: - updates.narrationMode === undefined - ? mergedPreferences.narrationMode - : result.narrationMode, + ...mergedPreferences, + ...(updates.colorTheme !== undefined + ? { colorTheme: result.colorTheme } + : {}), + ...(updates.narrationMode !== undefined + ? { narrationMode: result.narrationMode } + : {}), + ...(updates.showCommandOutput !== undefined + ? { showCommandOutput: result.showCommandOutput } + : {}), }; } @@ -62,16 +64,20 @@ function rollbackUpdatedFields( context?.optimisticPreferences ?? mergedPreferences; return { - colorTheme: - updates.colorTheme !== undefined && - mergedPreferences.colorTheme === optimisticPreferences.colorTheme - ? previousPreferences.colorTheme - : mergedPreferences.colorTheme, - narrationMode: - updates.narrationMode !== undefined && - mergedPreferences.narrationMode === optimisticPreferences.narrationMode - ? previousPreferences.narrationMode - : mergedPreferences.narrationMode, + ...mergedPreferences, + ...(updates.colorTheme !== undefined && + mergedPreferences.colorTheme === optimisticPreferences.colorTheme + ? { colorTheme: previousPreferences.colorTheme } + : {}), + ...(updates.narrationMode !== undefined && + mergedPreferences.narrationMode === optimisticPreferences.narrationMode + ? { narrationMode: previousPreferences.narrationMode } + : {}), + ...(updates.showCommandOutput !== undefined && + mergedPreferences.showCommandOutput === + optimisticPreferences.showCommandOutput + ? { showCommandOutput: previousPreferences.showCommandOutput } + : {}), }; } diff --git a/apps/web/src/hooks/useShowCommandOutput.client.test.tsx b/apps/web/src/hooks/useShowCommandOutput.client.test.tsx new file mode 100644 index 000000000..7b9dd8d0e --- /dev/null +++ b/apps/web/src/hooks/useShowCommandOutput.client.test.tsx @@ -0,0 +1,47 @@ +import { renderHook } from '@testing-library/react'; + +const { personalPreferencesState } = vi.hoisted(() => ({ + personalPreferencesState: { + preferences: { + colorTheme: 'system' as const, + narrationMode: false, + showCommandOutput: false, + }, + isLoading: false, + isUpdating: false, + setPreferences: vi.fn(), + }, +})); + +vi.mock('./usePersonalPreferences', () => ({ + usePersonalPreferences: () => personalPreferencesState, +})); + +import { useShowCommandOutput } from './useShowCommandOutput'; + +describe('useShowCommandOutput', () => { + beforeEach(() => { + vi.clearAllMocks(); + personalPreferencesState.preferences.showCommandOutput = false; + personalPreferencesState.isLoading = false; + personalPreferencesState.isUpdating = false; + }); + + it('exposes command output visibility from personal preferences', () => { + personalPreferencesState.preferences.showCommandOutput = true; + + const { result } = renderHook(() => useShowCommandOutput()); + + expect(result.current.enabled).toBe(true); + }); + + it('updates command output visibility through personal preferences', () => { + const { result } = renderHook(() => useShowCommandOutput()); + + result.current.setEnabled(true); + + expect(personalPreferencesState.setPreferences).toHaveBeenCalledWith({ + showCommandOutput: true, + }); + }); +}); diff --git a/apps/web/src/hooks/useShowCommandOutput.ts b/apps/web/src/hooks/useShowCommandOutput.ts new file mode 100644 index 000000000..e6c0608fe --- /dev/null +++ b/apps/web/src/hooks/useShowCommandOutput.ts @@ -0,0 +1,26 @@ +'use client'; + +import { useCallback } from 'react'; + +import { usePersonalPreferences } from './usePersonalPreferences'; + +export function useShowCommandOutput() { + const { preferences, isLoading, isUpdating, setPreferences } = + usePersonalPreferences({ + errorMessage: 'Failed to update command output visibility.', + }); + + const setEnabled = useCallback( + (enabled: boolean) => { + setPreferences({ showCommandOutput: enabled }); + }, + [setPreferences], + ); + + return { + enabled: preferences.showCommandOutput, + isLoading, + isUpdating, + setEnabled, + }; +} diff --git a/apps/web/src/trpc/commands/preferences/index.ts b/apps/web/src/trpc/commands/preferences/index.ts index 225f7e5f1..effada508 100644 --- a/apps/web/src/trpc/commands/preferences/index.ts +++ b/apps/web/src/trpc/commands/preferences/index.ts @@ -1,4 +1,4 @@ -import { and, db, eq, isNull, users } from '@roomote/db/server'; +import { and, db, eq, isNull, sql, users } from '@roomote/db/server'; import { headers } from 'next/headers'; import type { UserAuthSuccess } from '@/types'; @@ -32,6 +32,10 @@ function normalizePersonalPreferences( typeof metadata.narration_mode === 'boolean' ? metadata.narration_mode : DEFAULT_PERSONAL_PREFERENCES.narrationMode, + showCommandOutput: + typeof metadata.show_command_output === 'boolean' + ? metadata.show_command_output + : DEFAULT_PERSONAL_PREFERENCES.showCommandOutput, }; } @@ -117,34 +121,27 @@ export async function updatePersonalPreferencesCommand( nextMetadataRecord.narration_mode = input.narrationMode; } + if (input.showCommandOutput !== undefined) { + nextMetadataRecord.show_command_output = input.showCommandOutput; + } + if (Object.keys(nextMetadataRecord).length === 0) { return getPersonalPreferencesCommand(auth); } - const currentUser = await db.query.users.findFirst({ - where: eq(users.id, auth.userId), - columns: { - metadata: true, - }, - }); - const normalizedMetadata = { - ...normalizeMetadata(currentUser?.metadata), - ...nextMetadataRecord, - }; - - const updatedRows = await db + const [updatedUser] = await db .update(users) .set({ - metadata: normalizedMetadata, + metadata: sql`coalesce(${users.metadata}, '{}'::jsonb) || ${JSON.stringify(nextMetadataRecord)}::jsonb`, lastSyncAt: new Date(), updatedAt: new Date(), }) .where(eq(users.id, auth.userId)) - .returning({ id: users.id }); + .returning({ metadata: users.metadata }); - if (updatedRows.length === 0) { + if (!updatedUser) { throw new Error('Unable to update preferences for the active user.'); } - return normalizePersonalPreferences(normalizedMetadata); + return normalizePersonalPreferences(normalizeMetadata(updatedUser.metadata)); } diff --git a/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts b/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts new file mode 100644 index 000000000..f907982bc --- /dev/null +++ b/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts @@ -0,0 +1,39 @@ +import { db, eq, userFactory, users } from '@roomote/db/server'; + +import type { UserAuthSuccess } from '@/types'; + +import { + getPersonalPreferencesCommand, + updatePersonalPreferencesCommand, +} from './index'; + +describe('personal preferences', () => { + it('atomically preserves independent and unrelated metadata updates', async () => { + const user = await userFactory.create({ + metadata: { unrelated_setting: 'keep-me' }, + }); + const auth = { userId: user.id } as UserAuthSuccess; + + await Promise.all([ + updatePersonalPreferencesCommand(auth, { narrationMode: true }), + updatePersonalPreferencesCommand(auth, { showCommandOutput: true }), + ]); + + await expect(getPersonalPreferencesCommand(auth)).resolves.toEqual({ + colorTheme: 'system', + narrationMode: true, + showCommandOutput: true, + }); + + const storedUser = await db.query.users.findFirst({ + where: eq(users.id, user.id), + columns: { metadata: true }, + }); + + expect(storedUser?.metadata).toMatchObject({ + unrelated_setting: 'keep-me', + narration_mode: true, + show_command_output: true, + }); + }); +}); diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index ed2d42dda..b458e5639 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -1320,11 +1320,13 @@ export const appRouter = createRouter({ .object({ colorTheme: z.enum(PERSONAL_COLOR_THEMES).optional(), narrationMode: z.boolean().optional(), + showCommandOutput: z.boolean().optional(), }) .refine( (input) => input.colorTheme !== undefined || - input.narrationMode !== undefined, + input.narrationMode !== undefined || + input.showCommandOutput !== undefined, { message: 'Expected at least one personal preference to update.', }, diff --git a/apps/web/src/types/preferences.ts b/apps/web/src/types/preferences.ts index 5c4f4856d..82cd364be 100644 --- a/apps/web/src/types/preferences.ts +++ b/apps/web/src/types/preferences.ts @@ -15,6 +15,7 @@ export function isPersonalColorTheme( export interface PersonalPreferences { colorTheme: PersonalColorTheme; narrationMode: boolean; + showCommandOutput: boolean; } export type PersonalPreferencesUpdate = Partial; @@ -22,4 +23,5 @@ export type PersonalPreferencesUpdate = Partial; export const DEFAULT_PERSONAL_PREFERENCES: PersonalPreferences = { colorTheme: 'system', narrationMode: false, + showCommandOutput: false, };