Date: Fri, 28 Aug 2026 17:41:22 +0000
Subject: [PATCH 2/4] feat: run task tools while waking tasks
---
.../[taskId]/WakeTaskInput.client.test.tsx | 64 +++++++++++
.../(sandbox)/task/[taskId]/WakeTaskInput.tsx | 39 +++++--
.../sidebar-actions/TaskToolsButton.tsx | 108 ++++++++++--------
3 files changed, 155 insertions(+), 56 deletions(-)
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.client.test.tsx
index 9d7342d00..25d1f2c07 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.client.test.tsx
@@ -172,6 +172,27 @@ vi.mock('./prompt-input/TaskModelSwitcher', () => ({
},
}));
+vi.mock('./sidebar-actions/TaskToolsButton', () => ({
+ TaskToolsMenu: ({
+ disabled,
+ onSelect,
+ }: {
+ disabled?: boolean;
+ onSelect: (actionId: 'simplify') => void;
+ }) => (
+
+
+
+
+ ),
+}));
+
import { WakeTaskInput } from './WakeTaskInput';
function renderWithQueryClient(ui: React.ReactNode, queryClient: QueryClient) {
@@ -277,6 +298,49 @@ describe('WakeTaskInput', () => {
expect(restoreMutateAsyncMock).not.toHaveBeenCalled();
});
+ it('runs a task tool as the wake-up prompt', async () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+ });
+ preparePromptAttachmentsMock.mockImplementation(
+ ({ text }: { text: string }) => Promise.resolve({ text }),
+ );
+
+ renderWithQueryClient(
+ ,
+ queryClient,
+ );
+
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Simplify changed code' }),
+ );
+
+ await waitFor(() => {
+ expect(restoreMutateAsyncMock).toHaveBeenCalledWith({
+ sourceSnapshotId: 'snap-42',
+ sourceRunId: 42,
+ clientMessageId: expect.any(String),
+ resumePrompt: '$simplify',
+ });
+ });
+
+ expect(appendOptimisticAcpEventMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ text: '$simplify',
+ }),
+ );
+ });
+
it('prefills the sleeping draft, appends an optimistic transcript row, and resumes the task with a deferred prompt', async () => {
const queryClient = new QueryClient({
defaultOptions: {
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.tsx
index 08df1129f..8df87865a 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.tsx
@@ -3,7 +3,11 @@
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
-import { DEFAULT_MANAGED_DEPLOYMENT_ACCESS } from '@roomote/types';
+import {
+ DEFAULT_MANAGED_DEPLOYMENT_ACCESS,
+ getTaskToolInvocation,
+ type TaskToolActionId,
+} from '@roomote/types';
import type { PromptInputMessage } from '@/components/ai-elements';
import { TaskPromptInput } from '@/components/tasks';
@@ -17,10 +21,12 @@ import { cn } from '@/lib/utils';
import { useOptimisticPromptSubmission } from './prompt-input/useOptimisticPromptSubmission';
import { TaskModelSwitcher } from './prompt-input/TaskModelSwitcher';
+import { TaskToolsMenu } from './sidebar-actions/TaskToolsButton';
+import { shouldShowTaskToolsActions } from './sidebar-actions/utils';
interface WakeTaskInputProps {
taskRun: Pick &
- Partial>;
+ Partial>;
initialPrompt?: string;
embedded?: boolean;
}
@@ -133,6 +139,13 @@ export function WakeTaskInput({
}
};
+ const handleTaskToolSelect = (actionId: TaskToolActionId) => {
+ void handleSubmit({
+ text: getTaskToolInvocation(actionId, taskRun.harness),
+ files: [],
+ });
+ };
+
const input = (
: undefined}
tools={
- taskRun.harness === 'opencode-server' ? (
-
- ) : undefined
+ <>
+ {shouldShowTaskToolsActions(taskRun.payloadKind) && (
+
+ )}
+ {taskRun.harness === 'opencode-server' && (
+
+ )}
+ >
}
surface={embedded ? 'embedded' : 'default'}
submitDisabledReason={taskLaunchDisabledReason}
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/TaskToolsButton.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/TaskToolsButton.tsx
index ca0df70be..debd60615 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/TaskToolsButton.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/TaskToolsButton.tsx
@@ -3,6 +3,8 @@
import { Fragment, memo } from 'react';
import { toast } from 'sonner';
+import type { TaskToolActionId } from '@roomote/types';
+
import { useUser } from '@/hooks/useUser';
import { generateClientUuid } from '@/lib/client-uuid';
import { useTRPCClient } from '@/trpc/client';
@@ -31,6 +33,44 @@ import { TASK_TOOL_CATALOG } from '../task-tools';
import { type SidebarActionBaseProps } from './types';
import { isTaskRunAsleep } from './utils';
+export function TaskToolsMenu({
+ onSelect,
+ disabled = false,
+}: {
+ onSelect: (actionId: TaskToolActionId) => void | Promise;
+ disabled?: boolean;
+}) {
+ return (
+
+
+
+
+
+
+
+ Task Tools
+ {TASK_TOOL_CATALOG.map(({ actionId, label, separator, icon: Icon }) => (
+
+ {separator && }
+ void onSelect(actionId)}
+ className="flex items-center gap-2 cursor-pointer min-w-64"
+ >
+
+ {label}
+
+
+ ))}
+
+
+ );
+}
+
function TaskToolsButtonBase({
taskRun,
}: Pick) {
@@ -52,54 +92,28 @@ function TaskToolsButtonBase({
return null;
}
- const content = (
- <>
- Task Tools
- {TASK_TOOL_CATALOG.map(({ actionId, label, separator, icon: Icon }) => (
-
- {separator && }
- {
- const clientMessageId = generateClientUuid();
-
- try {
- await trpcClient.sandboxSession.sendPrompt.mutate({
- taskId: taskRun.taskId,
- taskTool: { actionId },
- source: 'web',
- clientMessageId,
- userImageUrl,
- });
- } catch (error) {
- toast.error(
- error instanceof Error
- ? error.message
- : 'Failed to send task tool.',
- );
- }
- }}
- className="flex items-center gap-2 cursor-pointer min-w-64"
- >
-
- {label}
-
-
- ))}
- >
- );
-
return (
-
-
-
-
-
-
- {content}
-
+ {
+ const clientMessageId = generateClientUuid();
+
+ try {
+ await trpcClient.sandboxSession.sendPrompt.mutate({
+ taskId: taskRun.taskId,
+ taskTool: { actionId },
+ source: 'web',
+ clientMessageId,
+ userImageUrl,
+ });
+ } catch (error) {
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : 'Failed to send task tool.',
+ );
+ }
+ }}
+ />
);
}
From c9dd7a8ef4ba24a25e1cd07b64282203901e3b2c Mon Sep 17 00:00:00 2001
From: "@tomny-dev" <20028678+tomny-dev@users.noreply.github.com>
Date: Fri, 28 Aug 2026 18:20:49 +0000
Subject: [PATCH 3/4] fix: disable wake tools in read-only mode
---
.../[taskId]/WakeTaskInput.client.test.tsx | 60 +++++++++++++++----
.../(sandbox)/task/[taskId]/WakeTaskInput.tsx | 2 +-
2 files changed, 51 insertions(+), 11 deletions(-)
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.client.test.tsx
index 25d1f2c07..7dd35aa38 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.client.test.tsx
@@ -9,6 +9,7 @@ const {
removeOptimisticQueuedMessageMock,
restoreMutateAsyncMock,
toastErrorMock,
+ useAuthorizedUserMock,
useSandboxCurrentUserInfoMock,
} = vi.hoisted(() => ({
appendOptimisticAcpEventMock: vi.fn(),
@@ -18,6 +19,7 @@ const {
removeOptimisticQueuedMessageMock: vi.fn(),
restoreMutateAsyncMock: vi.fn(),
toastErrorMock: vi.fn(),
+ useAuthorizedUserMock: vi.fn(),
useSandboxCurrentUserInfoMock: vi.fn(),
}));
@@ -55,16 +57,7 @@ vi.mock('@/hooks/snapshots', () => ({
}));
vi.mock('@/hooks/useUser', () => ({
- useAuthorizedUser: () => ({
- managedAccess: {
- state: 'active',
- reason: null,
- revision: 1,
- effectiveAt: '2026-01-01T00:00:00.000Z',
- restrictionStartsAt: null,
- remediationUrl: null,
- },
- }),
+ useAuthorizedUser: useAuthorizedUserMock,
}));
vi.mock('@/trpc/client', () => ({
@@ -219,6 +212,16 @@ describe('WakeTaskInput', () => {
runId: 84,
taskId: 'task-42',
});
+ useAuthorizedUserMock.mockReturnValue({
+ managedAccess: {
+ state: 'active',
+ reason: null,
+ revision: 1,
+ effectiveAt: '2026-01-01T00:00:00.000Z',
+ restrictionStartsAt: null,
+ remediationUrl: null,
+ },
+ });
useSandboxCurrentUserInfoMock.mockReturnValue(null);
});
@@ -341,6 +344,43 @@ describe('WakeTaskInput', () => {
);
});
+ it('disables task tools when managed access blocks waking the task', () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+ });
+ useAuthorizedUserMock.mockReturnValue({
+ managedAccess: {
+ state: 'read_only',
+ reason: 'billing_required',
+ revision: 2,
+ effectiveAt: '2026-01-02T00:00:00.000Z',
+ restrictionStartsAt: null,
+ remediationUrl: null,
+ },
+ });
+
+ renderWithQueryClient(
+ ,
+ queryClient,
+ );
+
+ expect(screen.getByRole('button', { name: 'Task Tools' })).toBeDisabled();
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Simplify changed code' }),
+ );
+ expect(restoreMutateAsyncMock).not.toHaveBeenCalled();
+ });
+
it('prefills the sleeping draft, appends an optimistic transcript row, and resumes the task with a deferred prompt', async () => {
const queryClient = new QueryClient({
defaultOptions: {
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.tsx
index 8df87865a..9ad58ab52 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.tsx
@@ -167,7 +167,7 @@ export function WakeTaskInput({
{shouldShowTaskToolsActions(taskRun.payloadKind) && (
)}
{taskRun.harness === 'opencode-server' && (
From d12bc99db6ce4c8890529a4df76d4339a0bc0c88 Mon Sep 17 00:00:00 2001
From: "@tomny-dev" <20028678+tomny-dev@users.noreply.github.com>
Date: Fri, 28 Aug 2026 19:51:28 +0000
Subject: [PATCH 4/4] refactor: simplify wake model state
---
.../web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.tsx | 8 ++------
.../task/[taskId]/prompt-input/TaskModelSwitcher.tsx | 3 +--
2 files changed, 3 insertions(+), 8 deletions(-)
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.tsx
index 9ad58ab52..b160792a3 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/WakeTaskInput.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useCallback, useEffect, useState } from 'react';
+import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import {
@@ -51,10 +51,6 @@ export function WakeTaskInput({
onSuccess: () => setPromptText(''),
});
const isBusy = sending || restore.isPending || modelSettingsPending;
- const handleModelSettingsPendingChange = useCallback(
- (pending: boolean) => setModelSettingsPending(pending),
- [],
- );
useEffect(() => {
setPromptText(initialPrompt);
@@ -174,7 +170,7 @@ export function WakeTaskInput({
)}
>
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/TaskModelSwitcher.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/TaskModelSwitcher.tsx
index b40d14dc5..26a615475 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/TaskModelSwitcher.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/TaskModelSwitcher.tsx
@@ -218,7 +218,6 @@ export function TaskModelSwitcher({
: 'Model';
const [resetting, setResetting] = useState(false);
- const isPending = updateModelSelection.isPending || resetting;
// Reset clears roles sequentially: firing the per-role mutations
// concurrently would race their payload read-modify-writes (the server
@@ -423,7 +422,7 @@ export function TaskModelSwitcher({
size="sm"
className="text-xs font-medium"
onClick={() => void handleReset()}
- disabled={disabled || isPending}
+ disabled={disabled || updateModelSelection.isPending || resetting}
>
Defaults