Skip to content

Commit d2d0a42

Browse files
committed
fix(api): give startNewTask(newTab) a fresh tab panel instead of reusing the tracked one
The F0 single-tab reuse in openClineInNewTab made the 2nd/3rd startNewTask({newTab:true}) return the first tab's provider, so its evictCurrentTask() aborted the live task that tab was serving (TaskUnfocused -> removeRegisteredTask, breaking follow-up suggestion delivery) and re-registering listeners on the reused provider stacked duplicate task handlers (Nth task events re-emitted N times). Extract the creation body as createClineTabPanel (always creates a fresh panel + provider), point API.startNewTask at it, keep the UI button path on openClineInNewTab's reuse behavior, guard registerListeners with a per-provider set, and only clear the tracked tab ref on dispose when it still points at the disposed panel. Restores the 3-panel follow-up coordination contract; the e2e 3-panel coordinated-rounds test that failed 6/6 now passes locally.
1 parent 6e0b03a commit d2d0a42

4 files changed

Lines changed: 107 additions & 23 deletions

File tree

‎src/activate/__tests__/registerCommands.spec.ts‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import { TelemetryService } from "@roo-code/telemetry"
44

55
import { ClineProvider } from "../../core/webview/ClineProvider"
66

7-
import { getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands"
7+
import {
8+
createClineTabPanel,
9+
getVisibleProviderOrLog,
10+
openClineInNewTab,
11+
registerCommands,
12+
setPanel,
13+
} from "../registerCommands"
814

915
vi.mock("execa", () => ({
1016
execa: vi.fn(),
@@ -640,3 +646,55 @@ describe("openClineInNewTab", () => {
640646
expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1)
641647
})
642648
})
649+
650+
describe("createClineTabPanel", () => {
651+
let mockOutputChannel: vscode.OutputChannel
652+
let mockContext: vscode.ExtensionContext
653+
654+
beforeEach(() => {
655+
vi.clearAllMocks()
656+
657+
mockOutputChannel = {
658+
appendLine: vi.fn(),
659+
append: vi.fn(),
660+
clear: vi.fn(),
661+
hide: vi.fn(),
662+
name: "mock",
663+
replace: vi.fn(),
664+
show: vi.fn(),
665+
dispose: vi.fn(),
666+
}
667+
668+
mockContext = {
669+
subscriptions: [],
670+
extensionUri: { path: "/mock/ext" },
671+
} as unknown as vscode.ExtensionContext
672+
673+
const mockPanel = {
674+
webview: { postMessage: vi.fn() },
675+
onDidChangeViewState: vi.fn(),
676+
onDidDispose: vi.fn(),
677+
}
678+
;(vscode.window.createWebviewPanel as Mock).mockReturnValue(mockPanel)
679+
680+
// Reset module-level panel state.
681+
setPanel(undefined, "sidebar")
682+
setPanel(undefined, "tab")
683+
})
684+
685+
it("always creates a fresh panel even when a tab is already tracked", async () => {
686+
const mockTrackedPanel = {
687+
webview: { postMessage: vi.fn() },
688+
onDidChangeViewState: vi.fn(),
689+
onDidDispose: vi.fn(),
690+
reveal: vi.fn().mockResolvedValue(undefined),
691+
} as unknown as vscode.WebviewPanel
692+
setPanel(mockTrackedPanel, "tab")
693+
;(ClineProvider.getInstanceForView as Mock).mockReturnValue({ postMessageToWebview: vi.fn() })
694+
695+
await createClineTabPanel({ context: mockContext, outputChannel: mockOutputChannel })
696+
697+
expect(mockTrackedPanel.reveal).not.toHaveBeenCalled()
698+
expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1)
699+
})
700+
})

‎src/activate/registerCommands.ts‎

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -282,18 +282,16 @@ const getCommandsMap = ({
282282
},
283283
})
284284

285-
export const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {
286-
// Reuse the tracked tab instead of opening a second one: a repeated
287-
// "Open in editor" click reveals the existing tab's panel.
288-
if (tabPanel) {
289-
const existingProvider = ClineProvider.getInstanceForView(tabPanel)
290-
if (existingProvider) {
291-
await tabPanel.reveal()
292-
await existingProvider.postMessageToWebview({ type: "action", action: "didBecomeVisible" })
293-
return existingProvider
294-
}
295-
}
296-
285+
/**
286+
* Create a fresh "Zoo Code" tab panel and provider.
287+
*
288+
* Unlike `openClineInNewTab`, this never reuses the tracked tab: callers that
289+
* must not evict the task an existing tab is already serving (such as
290+
* `API.startNewTask({ newTab: true })`) get a new surface here so concurrent
291+
* tab tasks stay independent. The UI "Open in editor" button keeps the
292+
* single-tab reuse behavior.
293+
*/
294+
export const createClineTabPanel = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {
297295
// (This example uses webviewProvider activation event which is necessary to
298296
// deserialize cached webview, but since we use retainContextWhenHidden, we
299297
// don't need to use that event).
@@ -356,7 +354,11 @@ export const openClineInNewTab = async ({ context, outputChannel }: Omit<Registe
356354
// Handle panel closing events.
357355
newPanel.onDidDispose(
358356
() => {
359-
setPanel(undefined, "tab")
357+
// Only clear the tracked ref when it still points to this panel, so
358+
// closing an older concurrent tab never wipes the newer tab's ref.
359+
if (tabPanel === newPanel) {
360+
setPanel(undefined, "tab")
361+
}
360362
},
361363
null,
362364
context.subscriptions, // Also register dispose listener
@@ -368,3 +370,18 @@ export const openClineInNewTab = async ({ context, outputChannel }: Omit<Registe
368370

369371
return tabProvider
370372
}
373+
374+
export const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {
375+
// Reuse the tracked tab instead of opening a second one: a repeated
376+
// "Open in editor" click reveals the existing tab's panel.
377+
if (tabPanel) {
378+
const existingProvider = ClineProvider.getInstanceForView(tabPanel)
379+
if (existingProvider) {
380+
await tabPanel.reveal()
381+
await existingProvider.postMessageToWebview({ type: "action", action: "didBecomeVisible" })
382+
return existingProvider
383+
}
384+
}
385+
386+
return createClineTabPanel({ context, outputChannel })
387+
}

‎src/extension/__tests__/api-task-control.spec.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import { RooCodeEventName, type ModeConfig, type RooCodeSettings } from "@roo-co
88
import { API } from "../api"
99
import { ClineProvider } from "../../core/webview/ClineProvider"
1010

11-
const { openClineInNewTabMock } = vi.hoisted(() => ({
12-
openClineInNewTabMock: vi.fn(),
11+
const { createClineTabPanelMock } = vi.hoisted(() => ({
12+
createClineTabPanelMock: vi.fn(),
1313
}))
1414

1515
vi.mock("vscode", () => ({
@@ -23,7 +23,7 @@ vi.mock("@roo-code/ipc", () => ({
2323
}))
2424

2525
vi.mock("../../activate/registerCommands", () => ({
26-
openClineInNewTab: openClineInNewTabMock,
26+
createClineTabPanel: createClineTabPanelMock,
2727
}))
2828

2929
vi.mock("../../integrations/terminal/Terminal", () => ({
@@ -108,14 +108,14 @@ describe("API task controls", () => {
108108
describe("startNewTask", () => {
109109
it("reverts and closes existing editors before opening a new tab unless preserveOpenTabs is true", async () => {
110110
const newTabProvider = createProvider("new-tab-task")
111-
openClineInNewTabMock.mockResolvedValue(newTabProvider)
111+
createClineTabPanelMock.mockResolvedValue(newTabProvider)
112112

113113
const taskId = await api.startNewTask({ configuration, text: "new task", newTab: true })
114114

115115
expect(taskId).toBe("new-tab-task")
116116
expect(vscode.commands.executeCommand).toHaveBeenNthCalledWith(1, "workbench.action.files.revert")
117117
expect(vscode.commands.executeCommand).toHaveBeenNthCalledWith(2, "workbench.action.closeAllEditors")
118-
expect(openClineInNewTabMock).toHaveBeenCalledWith({
118+
expect(createClineTabPanelMock).toHaveBeenCalledWith({
119119
context: sidebarProvider.context,
120120
outputChannel,
121121
})
@@ -131,7 +131,7 @@ describe("API task controls", () => {
131131

132132
it("opens a new tab without revert or close commands when preserveOpenTabs is true", async () => {
133133
const newTabProvider = createProvider("preserved-tab-task")
134-
openClineInNewTabMock.mockResolvedValue(newTabProvider)
134+
createClineTabPanelMock.mockResolvedValue(newTabProvider)
135135

136136
const taskId = await api.startNewTask({
137137
configuration,
@@ -142,7 +142,7 @@ describe("API task controls", () => {
142142

143143
expect(taskId).toBe("preserved-tab-task")
144144
expect(vscode.commands.executeCommand).not.toHaveBeenCalled()
145-
expect(openClineInNewTabMock).toHaveBeenCalledWith({
145+
expect(createClineTabPanelMock).toHaveBeenCalledWith({
146146
context: sidebarProvider.context,
147147
outputChannel,
148148
})

‎src/extension/api.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { ClineProvider } from "../core/webview/ClineProvider"
3131
import type { Task } from "../core/task/Task"
3232
import { Terminal } from "../integrations/terminal/Terminal"
3333
import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry"
34-
import { openClineInNewTab } from "../activate/registerCommands"
34+
import { createClineTabPanel } from "../activate/registerCommands"
3535
import { getCommands } from "../services/command/commands"
3636
import { getModels } from "../api/providers/fetchers/modelCache"
3737

@@ -51,6 +51,7 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
5151
private readonly context: vscode.ExtensionContext
5252
private readonly ipc?: IpcServer
5353
private readonly tasksById = new Map<string, RegisteredTask>()
54+
private readonly listenersRegisteredFor = new Set<ClineProvider>()
5455
private readonly log: (...args: unknown[]) => void
5556
private logfile?: string
5657

@@ -204,7 +205,8 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
204205
await vscode.commands.executeCommand("workbench.action.closeAllEditors")
205206
}
206207

207-
provider = await openClineInNewTab({ context: this.context, outputChannel: this.outputChannel })
208+
// A fresh tab: reusing the tracked tab would evict the task it is already serving.
209+
provider = await createClineTabPanel({ context: this.context, outputChannel: this.outputChannel })
208210
this.registerListeners(provider)
209211
} else {
210212
await vscode.commands.executeCommand(`${Package.name}.SidebarProvider.focus`)
@@ -434,6 +436,13 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
434436
}
435437

436438
private registerListeners(provider: ClineProvider) {
439+
// A duplicated registration would re-emit every task event once per copy of the
440+
// handler, so each provider is wired exactly once.
441+
if (this.listenersRegisteredFor.has(provider)) {
442+
return
443+
}
444+
this.listenersRegisteredFor.add(provider)
445+
437446
provider.on(RooCodeEventName.TaskCreated, (task) => {
438447
this.tasksById.set(task.taskId, { task: task as unknown as TaskAskController, provider })
439448

0 commit comments

Comments
 (0)