Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ import { CompactionWarning } from "../CompactionWarning/CompactionWarning";
import { ContextSwitchWarning as ContextSwitchWarningBanner } from "../ContextSwitchWarning/ContextSwitchWarning";
import {
ConcurrentLocalWarningDecoration,
useConcurrentLocalStreamingWorkspaceName,
useConcurrentLocalActiveWorkspaceName,
} from "../ConcurrentLocalWarning/ConcurrentLocalWarning";
import { BackgroundProcessesBanner } from "../BackgroundProcessesBanner/BackgroundProcessesBanner";
import { checkAutoCompaction } from "@/common/utils/compaction/autoCompactionCheck";
Expand Down Expand Up @@ -327,7 +327,7 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
: null;
const shouldShowQueuedAgentTaskPrompt =
Boolean(queuedAgentTaskPrompt) && (workspaceState?.messages.length ?? 0) === 0;
const concurrentLocalStreamingWorkspaceName = useConcurrentLocalStreamingWorkspaceName({
const concurrentLocalActiveWorkspaceName = useConcurrentLocalActiveWorkspaceName({
workspaceId,
projectPath,
runtimeConfig,
Expand Down Expand Up @@ -1629,7 +1629,7 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
isCompacting={isCompacting}
shouldShowPinnedTodoList={shouldShowPinnedTodoList}
shouldShowReviewsBanner={shouldShowReviewsBanner}
concurrentLocalStreamingWorkspaceName={concurrentLocalStreamingWorkspaceName}
concurrentLocalActiveWorkspaceName={concurrentLocalActiveWorkspaceName}
canInterrupt={canInterrupt}
autoCompactionResult={autoCompactionResult}
shouldShowCompactionWarning={shouldShowCompactionWarning}
Expand Down Expand Up @@ -1694,7 +1694,7 @@ interface ChatInputPaneProps {
isTranscriptCaughtUp: boolean;
shouldShowPinnedTodoList: boolean;
shouldShowReviewsBanner: boolean;
concurrentLocalStreamingWorkspaceName: string | null;
concurrentLocalActiveWorkspaceName: string | null;
canInterrupt: boolean;
autoCompactionResult: ReturnType<typeof checkAutoCompaction>;
shouldShowCompactionWarning: boolean;
Expand Down Expand Up @@ -1756,12 +1756,12 @@ const ChatInputPane: React.FC<ChatInputPaneProps> = (props) => {
// message insert above a live tail row, so bottom-lock had to correct after layout and
// visibly flashed while another local agent was active. Pin it with composer decorations
// instead; new transcript rows no longer move the warning.
if (props.concurrentLocalStreamingWorkspaceName) {
if (props.concurrentLocalActiveWorkspaceName) {
addDecorationEntry({
key: "concurrent-local-warning",
node: (
<ConcurrentLocalWarningDecoration
streamingWorkspaceName={props.concurrentLocalStreamingWorkspaceName}
streamingWorkspaceName={props.concurrentLocalActiveWorkspaceName}
/>
),
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test";
import { isConcurrentLocalWorkspaceActive } from "./ConcurrentLocalWarning";

describe("isConcurrentLocalWorkspaceActive", () => {
test("stays active across a background monitor wake cycle", () => {
const wakeCycle = [
{
canInterrupt: true,
isStarting: false,
pendingBackgroundWake: false,
activeWorkflowRunCount: 0,
activeBashMonitorCount: 1,
},
{
canInterrupt: false,
isStarting: false,
pendingBackgroundWake: true,
activeWorkflowRunCount: 0,
activeBashMonitorCount: 0,
},
{
canInterrupt: false,
isStarting: true,
pendingBackgroundWake: true,
activeWorkflowRunCount: 0,
activeBashMonitorCount: 0,
},
{
canInterrupt: true,
isStarting: false,
pendingBackgroundWake: false,
activeWorkflowRunCount: 0,
activeBashMonitorCount: 0,
},
];

expect(wakeCycle.map(isConcurrentLocalWorkspaceActive)).toEqual([true, true, true, true]);
});

test("stays active while a background workflow can wake the agent", () => {
expect(
isConcurrentLocalWorkspaceActive({
canInterrupt: false,
isStarting: false,
pendingBackgroundWake: false,
activeWorkflowRunCount: 1,
activeBashMonitorCount: 0,
})
).toBe(true);
});

test("is inactive once no stream, startup, or wake monitor remains", () => {
expect(
isConcurrentLocalWorkspaceActive({
canInterrupt: false,
isStarting: false,
pendingBackgroundWake: false,
activeWorkflowRunCount: 0,
activeBashMonitorCount: 0,
})
).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useMemo, useSyncExternalStore } from "react";
import { AlertTriangle } from "lucide-react";
import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore";
import { useWorkspaceStoreRaw, type WorkspaceSidebarState } from "@/browser/stores/WorkspaceStore";
import { cn } from "@/common/lib/utils";
import { isLocalProjectRuntime } from "@/common/types/runtime";
import type { RuntimeConfig } from "@/common/types/runtime";
Expand All @@ -12,11 +12,32 @@ interface ConcurrentLocalWarningProps {
runtimeConfig?: RuntimeConfig;
}

type ConcurrentLocalWorkspaceActivity = Pick<
WorkspaceSidebarState,
| "canInterrupt"
| "isStarting"
| "pendingBackgroundWake"
| "activeWorkflowRunCount"
| "activeBashMonitorCount"
>;

export function isConcurrentLocalWorkspaceActive(state: ConcurrentLocalWorkspaceActivity): boolean {
// User rationale: background work briefly transitions through idle and startup states as it wakes
// the owning agent. Treat the whole wake cycle as active so the warning does not flash between turns.
return (
state.canInterrupt ||
state.isStarting ||
Comment thread
ammar-agent marked this conversation as resolved.
state.pendingBackgroundWake === true ||
state.activeWorkflowRunCount > 0 ||
state.activeBashMonitorCount > 0
);
}

/**
* Returns the name of another local-project workspace that is actively streaming in the same
* project directory, or null when there is no conflicting local stream to warn about.
* Returns the name of another active local-project workspace in the same project directory, or null
* when there is no conflicting local agent to warn about.
*/
export function useConcurrentLocalStreamingWorkspaceName(
export function useConcurrentLocalActiveWorkspaceName(
props: ConcurrentLocalWarningProps
): string | null {
const isLocalProject = isLocalProjectRuntime(props.runtimeConfig);
Expand Down Expand Up @@ -53,7 +74,7 @@ export function useConcurrentLocalStreamingWorkspaceName(
for (const id of otherLocalWorkspaceIds) {
try {
const state = store.getWorkspaceSidebarState(id);
if (state.canInterrupt) {
if (isConcurrentLocalWorkspaceActive(state)) {
const meta = workspaceMetadata.get(id);
return meta?.name ?? id;
}
Expand Down
2 changes: 2 additions & 0 deletions src/browser/stores/WorkspaceStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2960,6 +2960,7 @@ describe("WorkspaceStore", () => {
streaming: true,
lastModel: "claude-sonnet-4",
lastThinkingLevel: "high",
pendingBackgroundWake: true,
activeWorkflowRunCount: 1,
todoStatus: { emoji: "🔄", message: "Run checks" },
hasTodos: true,
Expand All @@ -2985,6 +2986,7 @@ describe("WorkspaceStore", () => {
expect(state.canInterrupt).toBe(true);
expect(state.currentModel).toBe(activitySnapshot.lastModel);
expect(state.currentThinkingLevel).toBe(activitySnapshot.lastThinkingLevel);
expect(state.pendingBackgroundWake).toBe(true);
expect(state.activeWorkflowRunCount).toBe(1);
expect(store.getWorkspaceSidebarState(workspaceId).activeWorkflowRunCount).toBe(1);
expect(state.agentStatus).toEqual(activitySnapshot.todoStatus ?? undefined);
Expand Down
7 changes: 7 additions & 0 deletions src/browser/stores/WorkspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export interface WorkspaceState {
loadedSkills: LoadedSkill[];
skillLoadErrors: SkillLoadError[];
agentStatus: { emoji: string; message: string; url?: string } | undefined;
pendingBackgroundWake?: boolean;
activeWorkflowRunCount: number;
activeBashMonitorCount: number;
lastAbortReason: StreamAbortReasonSnapshot | null;
Expand Down Expand Up @@ -183,6 +184,7 @@ export interface WorkspaceSidebarState {
loadedSkills: LoadedSkill[];
skillLoadErrors: SkillLoadError[];
agentStatus: { emoji: string; message: string; url?: string } | undefined;
pendingBackgroundWake?: boolean;
activeWorkflowRunCount: number;
activeBashMonitorCount: number;
terminalActiveCount: number;
Expand Down Expand Up @@ -1989,6 +1991,7 @@ export class WorkspaceStore {
(activity?.hasTodos === false ? undefined : deriveTodoStatus(aggregatorTodos)));
const agentStatus =
displayStatus ?? liveTodoStatus ?? fallbackAgentStatus ?? persistedTodoStatus;
const pendingBackgroundWake = activity?.pendingBackgroundWake ?? false;
const activeWorkflowRunCount = activity?.activeWorkflowRunCount ?? 0;
const activeBashMonitorCount = activity?.activeBashMonitorCount ?? 0;
const goal = activity?.goal ?? null;
Expand All @@ -2015,6 +2018,7 @@ export class WorkspaceStore {
skillLoadErrors: aggregator.getSkillLoadErrors(),
lastAbortReason: aggregator.getLastAbortReason(),
agentStatus,
pendingBackgroundWake,
activeWorkflowRunCount,
activeBashMonitorCount,
pendingStreamStartTime,
Expand Down Expand Up @@ -2127,6 +2131,7 @@ export class WorkspaceStore {
cached.loadedSkills === fullState.loadedSkills &&
cached.skillLoadErrors === fullState.skillLoadErrors &&
cached.agentStatus === fullState.agentStatus &&
cached.pendingBackgroundWake === fullState.pendingBackgroundWake &&
cached.activeWorkflowRunCount === fullState.activeWorkflowRunCount &&
cached.activeBashMonitorCount === fullState.activeBashMonitorCount &&
cached.terminalActiveCount === terminalActiveCount &&
Expand All @@ -2151,6 +2156,7 @@ export class WorkspaceStore {
loadedSkills: fullState.loadedSkills,
skillLoadErrors: fullState.skillLoadErrors,
agentStatus: fullState.agentStatus,
pendingBackgroundWake: fullState.pendingBackgroundWake,
activeWorkflowRunCount: fullState.activeWorkflowRunCount,
activeBashMonitorCount: fullState.activeBashMonitorCount,
terminalActiveCount,
Expand Down Expand Up @@ -2814,6 +2820,7 @@ export class WorkspaceStore {
previous?.lastThinkingLevel !== snapshot?.lastThinkingLevel ||
previous?.recency !== snapshot?.recency ||
previous?.hasTodos !== snapshot?.hasTodos ||
(previous?.pendingBackgroundWake ?? false) !== (snapshot?.pendingBackgroundWake ?? false) ||
(previous?.activeWorkflowRunCount ?? 0) !== (snapshot?.activeWorkflowRunCount ?? 0) ||
(previous?.activeBashMonitorCount ?? 0) !== (snapshot?.activeBashMonitorCount ?? 0) ||
!areAgentStatusesEqual(previous?.displayStatus, snapshot?.displayStatus) ||
Expand Down
4 changes: 4 additions & 0 deletions src/common/orpc/schemas/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,10 @@ export const WorkspaceActivitySnapshotSchema = z.object({
isIdleCompaction: z.boolean().optional().meta({
description: "Whether the current streaming activity is an idle (background) compaction",
}),
pendingBackgroundWake: z.boolean().optional().meta({
description:
"Whether a durable background notification is waiting to wake the workspace or is starting its synthetic turn.",
}),
activeWorkflowRunCount: z.number().int().nonnegative().optional().meta({
description:
"Number of top-level workflow runs in this workspace that are pending, running, or backgrounded.",
Expand Down
22 changes: 20 additions & 2 deletions src/node/services/taskService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,8 @@ function createWorkspaceServiceMocks(
updateAgentStatus: ReturnType<typeof mock>;
isExperimentEnabled: ReturnType<typeof mock>;
emitChatEvent: ReturnType<typeof mock>;
setPendingBackgroundWake: ReturnType<typeof mock>;
refreshPendingBackgroundWake: ReturnType<typeof mock>;
isWorkflowInvocationCurrent: ReturnType<typeof mock>;
create: ReturnType<typeof mock>;
}>
Expand All @@ -414,6 +416,8 @@ function createWorkspaceServiceMocks(
updateAgentStatus: ReturnType<typeof mock>;
isExperimentEnabled: ReturnType<typeof mock>;
emitChatEvent: ReturnType<typeof mock>;
setPendingBackgroundWake: ReturnType<typeof mock>;
refreshPendingBackgroundWake: ReturnType<typeof mock>;
isWorkflowInvocationCurrent: ReturnType<typeof mock>;
create: ReturnType<typeof mock>;
} {
Expand Down Expand Up @@ -454,6 +458,10 @@ function createWorkspaceServiceMocks(
const isWorkflowInvocationCurrent =
overrides?.isWorkflowInvocationCurrent ?? mock(() => Promise.resolve(true));

const setPendingBackgroundWake = overrides?.setPendingBackgroundWake ?? mock(() => undefined);
const refreshPendingBackgroundWake =
overrides?.refreshPendingBackgroundWake ?? mock((): Promise<void> => Promise.resolve());

const create =
overrides?.create ??
mock(
Expand Down Expand Up @@ -484,6 +492,8 @@ function createWorkspaceServiceMocks(
updateAgentStatus,
isExperimentEnabled,
emitChatEvent,
setPendingBackgroundWake,
refreshPendingBackgroundWake,
isWorkflowInvocationCurrent,
} as unknown as WorkspaceService,
create,
Expand All @@ -507,6 +517,8 @@ function createWorkspaceServiceMocks(
updateAgentStatus,
isExperimentEnabled,
emitChatEvent,
setPendingBackgroundWake,
refreshPendingBackgroundWake,
isWorkflowInvocationCurrent,
};
}
Expand Down Expand Up @@ -2233,14 +2245,17 @@ describe("TaskService", () => {
const sendMessage = mock(
(..._args: unknown[]): Promise<Result<void>> => Promise.resolve(Ok(undefined))
);
const { workspaceService } = createWorkspaceServiceMocks({ sendMessage });
const { workspaceService, refreshPendingBackgroundWake } = createWorkspaceServiceMocks({
sendMessage,
});
const { taskService } = createTaskServiceHarness(config, { workspaceService });
const internal = taskService as unknown as {
drainTerminalAttention: (ownerWorkspaceId: string) => Promise<void>;
};

await internal.drainTerminalAttention(parentId);

expect(refreshPendingBackgroundWake).toHaveBeenCalledWith(parentId);
expect(sendMessage).toHaveBeenCalledTimes(1);
const prompt = String(sendMessage.mock.calls[0]?.[1]);
expect(prompt).toContain("Background sub-agent task(s) have completed");
Expand Down Expand Up @@ -2878,10 +2893,13 @@ describe("TaskService", () => {
const sendMessage = mock(
(..._args: unknown[]): Promise<Result<void>> => Promise.resolve(Ok(undefined))
);
const { workspaceService } = createWorkspaceServiceMocks({ sendMessage });
const { workspaceService, setPendingBackgroundWake } = createWorkspaceServiceMocks({
sendMessage,
});
const { taskService } = createTaskServiceHarness(config, { workspaceService });

await taskService.initialize();
expect(setPendingBackgroundWake).toHaveBeenCalledWith(parentId);
await flushTerminalAttentionDrains(taskService);

expect(sendMessage).toHaveBeenCalledTimes(1);
Expand Down
Loading
Loading