Skip to content
8 changes: 8 additions & 0 deletions packages/types/src/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ export const commandIds = [
"popoutButtonClicked",
"settingsButtonClicked",

// Editor-tab (popped-out) surface variants of the title-bar buttons. The
// shared ids above target the sidebar click origin, so the tab surface
// needs its own ids (see registerCommands.ts getTabProvider).
"plusButtonClickedInTab",
"settingsButtonClickedInTab",
"marketplaceButtonClickedInTab",
"historyButtonClickedInTab",

"openInNewTab",

"newTask",
Expand Down
600 changes: 551 additions & 49 deletions src/activate/__tests__/registerCommands.spec.ts

Large diffs are not rendered by default.

191 changes: 153 additions & 38 deletions src/activate/registerCommands.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as vscode from "vscode"
import delay from "delay"

import type { CommandId } from "@roo-code/types"
import type { CommandId, ExtensionMessage } from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"

import { Package } from "../shared/package"
Expand Down Expand Up @@ -32,6 +32,12 @@ export function getVisibleProviderOrLog(outputChannel: vscode.OutputChannel): Cl
let sidebarPanel: vscode.WebviewView | undefined = undefined
let tabPanel: vscode.WebviewPanel | undefined = undefined

// In-flight "open in editor" creation shared by overlapping calls: a
// double-click starts before the first call tracks its new panel, so
// concurrent callers must share one creation instead of racing to create
// two tab panels.
let pendingTabPanelCreation: Promise<ClineProvider> | undefined

/**
* Get the currently active panel
* @returns WebviewPanel或WebviewView
Expand All @@ -41,21 +47,35 @@ export function getPanel(): vscode.WebviewPanel | vscode.WebviewView | undefined
}

/**
* Set panel references
* Set panel references.
*
* The two refs are independent: each surface keeps its own ref for its whole
* lifetime, so resolving the sidebar view never wipes a live tab panel (and
* vice versa). Callers pass `undefined` only when the surface itself is
* disposed (see the `onDidDispose` wiring in `openClineInNewTab`).
*/
export function setPanel(
newPanel: vscode.WebviewPanel | vscode.WebviewView | undefined,
type: "sidebar" | "tab",
): void {
if (type === "sidebar") {
sidebarPanel = newPanel as vscode.WebviewView
tabPanel = undefined
} else {
tabPanel = newPanel as vscode.WebviewPanel
sidebarPanel = undefined
}
}

/**
* The instance that owns the tracked tab panel, if it is still alive.
*
* Title-bar commands on the editor-tab surface use this instead of the
* visible-instance heuristic, so a click on the tab's title bar always
* targets that tab even when the sidebar is visible side-by-side.
*/
function getTabProvider(): ClineProvider | undefined {
return tabPanel ? ClineProvider.getInstanceForView(tabPanel) : undefined
}

export type RegisterCommandOptions = {
context: vscode.ExtensionContext
outputChannel: vscode.OutputChannel
Expand Down Expand Up @@ -85,27 +105,58 @@ export const registerCommands = (options: RegisterCommandOptions) => {
// `filePath?: string`, others take none) and VS Code dispatches positional
// args dynamically.
type CommandCallback = (...args: any[]) => unknown

// Posts each action in order to the target instance. Failures are logged
// (not thrown) with the handler-specific prefix so a failed post stays
// attributable in the output channel.
const postActions = (
outputChannel: vscode.OutputChannel,
target: ClineProvider,
actions: readonly NonNullable<ExtensionMessage["action"]>[],
logPrefix: string,
) => {
for (const action of actions) {
void target
.postMessageToWebview({ type: "action", action })
.catch((error) => outputChannel.appendLine(`[${logPrefix}] postMessageToWebview failed: ${error}`))
}
}

const getCommandsMap = ({
context,
outputChannel,
provider,
}: RegisterCommandOptions): Record<Exclude<CommandId, "showRipgrepDiagnostic">, CommandCallback> => ({
activationCompleted: () => {},
// The `view/title` menu is scoped to the sidebar view, so the click
// origin of these handlers is the sidebar provider wired in at
// activation (`provider`). Target it directly instead of the
// visible-instance heuristic, which would follow the user's focus to a
// tab instance when both surfaces are open side-by-side. The `*InTab`
// variants serve the `editor/title` menu and target the tab instance
// through `getTabProvider()` instead.
plusButtonClicked: async () => {
const visibleProvider = getVisibleProviderOrLog(outputChannel)
TelemetryService.instance.captureTitleButtonClicked("plus")

if (!visibleProvider) {
await provider.evictCurrentTask()
await provider.refreshWorkspace()
await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
// Send focusInput action immediately after chatButtonClicked
// This ensures the focus happens after the view has switched
await provider.postMessageToWebview({ type: "action", action: "focusInput" })
},
plusButtonClickedInTab: async () => {
const tabProvider = getTabProvider()
if (!tabProvider) {
return
}

TelemetryService.instance.captureTitleButtonClicked("plus")

await visibleProvider.evictCurrentTask()
await visibleProvider.refreshWorkspace()
await visibleProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
// Send focusInput action immediately after chatButtonClicked
// This ensures the focus happens after the view has switched
await visibleProvider.postMessageToWebview({ type: "action", action: "focusInput" })
await tabProvider.evictCurrentTask()
await tabProvider.refreshWorkspace()
await tabProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
await tabProvider.postMessageToWebview({ type: "action", action: "focusInput" })
},
popoutButtonClicked: () => {
TelemetryService.instance.captureTitleButtonClicked("popout")
Expand All @@ -114,43 +165,50 @@ const getCommandsMap = ({
},
openInNewTab: () => openClineInNewTab({ context, outputChannel }),
settingsButtonClicked: () => {
const visibleProvider = getVisibleProviderOrLog(outputChannel)
TelemetryService.instance.captureTitleButtonClicked("settings")

if (!visibleProvider) {
// Also explicitly post the visibility message to trigger scroll reliably.
postActions(outputChannel, provider, ["settingsButtonClicked", "didBecomeVisible"], "settingsButtonClicked")
},
settingsButtonClickedInTab: () => {
const tabProvider = getTabProvider()
if (!tabProvider) {
return
}

TelemetryService.instance.captureTitleButtonClicked("settings")

void visibleProvider
.postMessageToWebview({ type: "action", action: "settingsButtonClicked" })
.catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`))
// Also explicitly post the visibility message to trigger scroll reliably
void visibleProvider
.postMessageToWebview({ type: "action", action: "didBecomeVisible" })
.catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`))
postActions(
outputChannel,
tabProvider,
["settingsButtonClicked", "didBecomeVisible"],
"settingsButtonClickedInTab",
)
},
historyButtonClicked: () => {
const visibleProvider = getVisibleProviderOrLog(outputChannel)
TelemetryService.instance.captureTitleButtonClicked("history")

if (!visibleProvider) {
postActions(outputChannel, provider, ["historyButtonClicked"], "historyButtonClicked")
},
historyButtonClickedInTab: () => {
const tabProvider = getTabProvider()
if (!tabProvider) {
return
}

TelemetryService.instance.captureTitleButtonClicked("history")

void visibleProvider
.postMessageToWebview({ type: "action", action: "historyButtonClicked" })
.catch((error) => outputChannel.appendLine(`[historyButtonClicked] postMessageToWebview failed: ${error}`))
postActions(outputChannel, tabProvider, ["historyButtonClicked"], "historyButtonClickedInTab")
},
marketplaceButtonClicked: () => {
const visibleProvider = getVisibleProviderOrLog(outputChannel)
if (!visibleProvider) return
void visibleProvider
.postMessageToWebview({ type: "action", action: "marketplaceButtonClicked" })
.catch((error) =>
outputChannel.appendLine(`[marketplaceButtonClicked] postMessageToWebview failed: ${error}`),
)
postActions(outputChannel, provider, ["marketplaceButtonClicked"], "marketplaceButtonClicked")
},
marketplaceButtonClickedInTab: () => {
const tabProvider = getTabProvider()
if (!tabProvider) {
return
}
postActions(outputChannel, tabProvider, ["marketplaceButtonClicked"], "marketplaceButtonClickedInTab")
},
newTask: handleNewTask,
setCustomStoragePath: async () => {
Expand All @@ -177,8 +235,11 @@ const getCommandsMap = ({
try {
await focusPanel(tabPanel, sidebarPanel)

// Send focus input message only for sidebar panels
if (sidebarPanel && getPanel() === sidebarPanel) {
// Send focus input message only when the sidebar panel was
// focused: the tab takes selection priority in focusPanel, so
// the sidebar receives the message only when no tab panel is
// tracked.
if (sidebarPanel && !tabPanel) {
await provider.postMessageToWebview({ type: "action", action: "focusInput" })
}
} catch (error) {
Expand Down Expand Up @@ -222,6 +283,53 @@ const getCommandsMap = ({
})

export const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {
// Serialize overlapping "Open in editor" calls: a double-click starts
// before the first call tracks its new panel, so without a shared
// in-flight creation both calls would race to create two tab panels.
// The shared promise is stored before the creation body awaits
// ContextProxy.getInstance, so every caller started while the creation
// is in flight — openInNewTab and popoutButtonClicked both dispatch
// through here — awaits it instead of creating a second panel: exactly
// one panel is created and every caller receives the same provider.
if (pendingTabPanelCreation) {
return pendingTabPanelCreation
}

const creation = createTabPanelUnlocked({ context, outputChannel })
pendingTabPanelCreation = creation

try {
return await creation
} finally {
// Clear once settled (success or failure) so the next call starts
// fresh: the reuse path in createTabPanelUnlocked then takes over
// for the tracked panel. Guard the clear so this settlement cannot
// clobber a replacement already stored in the slot. That clobber is
// unreachable in single-threaded settlement order: while the slot
// holds this in-flight creation, every other caller receives that
// same promise (guard above), so no replacement can be stored before
// this finally block runs — the equality check pins the invariant.
// Stryker disable next-line ConditionalExpression: defensive clobber guard, unreachable per the ordering argument above.
if (pendingTabPanelCreation === creation) {
pendingTabPanelCreation = undefined
}
}
}

// The unserialized tab-creation body. Only openClineInNewTab may call it,
// after it has stored the shared in-flight promise.
const createTabPanelUnlocked = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {
// Reuse the tracked tab instead of opening a second one: a repeated
// "Open in editor" click reveals the existing tab's panel.
if (tabPanel) {
const existingProvider = ClineProvider.getInstanceForView(tabPanel)
if (existingProvider) {
await tabPanel.reveal()
await existingProvider.postMessageToWebview({ type: "action", action: "didBecomeVisible" })
return existingProvider
}
}

// (This example uses webviewProvider activation event which is necessary to
// deserialize cached webview, but since we use retainContextWhenHidden, we
// don't need to use that event).
Expand All @@ -234,7 +342,8 @@ export const openClineInNewTab = async ({ context, outputChannel }: Omit<Registe
try {
mdmService = MdmService.getInstance()
} catch (error) {
// MDM service not initialized, which is fine - extension can work without it
// MDM service unavailable: log the fallback and continue without it.
outputChannel.appendLine(`[openClineInNewTab] MDM service unavailable, continuing without it: ${error}`)
mdmService = undefined
}

Expand All @@ -258,6 +367,7 @@ export const openClineInNewTab = async ({ context, outputChannel }: Omit<Registe
})

// Save as tab type panel.
// Stryker disable next-line StringLiteral: setPanel branches only on type === "sidebar", so any other literal routes to the identical tab-ref assignment
setPanel(newPanel, "tab")

// TODO: Use better svg icon with light and dark variants (see
Expand All @@ -281,10 +391,15 @@ export const openClineInNewTab = async ({ context, outputChannel }: Omit<Registe
context.subscriptions, // Register listener for disposal
)

// Handle panel closing events.
// Handle panel closing events: clear the tracked ref only if this panel
// is still the tracked one, so a late disposal of an already-replaced
// panel cannot clobber the replacement's ref.
newPanel.onDidDispose(
() => {
setPanel(undefined, "tab")
if (tabPanel === newPanel) {
// Stryker disable next-line StringLiteral: setPanel branches only on type === "sidebar", so any other literal routes to the identical tab-ref assignment
setPanel(undefined, "tab")
}
},
null,
context.subscriptions, // Also register dispose listener
Expand Down
10 changes: 10 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,16 @@ export class ClineProvider
return Array.from(this.activeInstances)
}

/**
* Returns the live instance whose current view is the given view or panel,
* if any. Title-bar commands on a specific surface use this to target the
* instance that owns that surface rather than the visible-instance
* heuristic (which picks whichever surface the user last focused).
*/
public static getInstanceForView(view: vscode.WebviewView | vscode.WebviewPanel): ClineProvider | undefined {
return Array.from(this.activeInstances).find((instance) => instance.view === view)
}

public static async getInstance(): Promise<ClineProvider | undefined> {
let visibleProvider = ClineProvider.getVisibleInstance()

Expand Down
12 changes: 12 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,18 @@ describe("ClineProvider", () => {
expect(ClineProvider.getVisibleInstance()).toBe(provider)
})

describe("getInstanceForView", () => {
it("returns the instance that owns the given view", async () => {
await provider.resolveWebviewView(mockWebviewView)

expect(ClineProvider.getInstanceForView(mockWebviewView)).toBe(provider)
})

it("returns undefined when no live instance owns the view", () => {
expect(ClineProvider.getInstanceForView({} as vscode.WebviewView)).toBeUndefined()
})
})

test("loads full model details when preparing an LM Studio task", async () => {
await provider.performPreparationTasks({
apiConfiguration: {
Expand Down
5 changes: 0 additions & 5 deletions src/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,6 @@
"count": 14
}
},
"activate/__tests__/registerCommands.spec.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"activate/registerCodeActions.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
Expand Down
Loading
Loading