Skip to content
Merged
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
17 changes: 16 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,12 @@
"category": "Coder",
"icon": "$(refresh)"
},
{
"command": "coder.chat.refresh",
"title": "Refresh Chat",
"category": "Coder",
"icon": "$(refresh)"
},
{
"command": "coder.applyRecommendedSettings",
"title": "Apply Recommended SSH Settings",
Expand Down Expand Up @@ -424,6 +430,10 @@
"command": "coder.tasks.refresh",
"when": "false"
},
{
"command": "coder.chat.refresh",
"when": "false"
},
{
"command": "coder.applyRecommendedSettings"
}
Expand Down Expand Up @@ -465,6 +475,11 @@
"command": "coder.tasks.refresh",
"when": "coder.authenticated && view == coder.tasksPanel",
"group": "navigation@1"
},
{
"command": "coder.chat.refresh",
"when": "view == coder.chatPanel",
"group": "navigation@1"
}
],
"view/item/context": [
Expand Down Expand Up @@ -579,7 +594,7 @@
"extensionPack": [
"ms-vscode-remote.remote-ssh"
],
"packageManager": "pnpm@10.32.1",
"packageManager": "pnpm@10.33.0",
"engines": {
"vscode": "^1.106.0",
"node": ">= 22"
Expand Down
37 changes: 31 additions & 6 deletions src/core/mementoManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import type { Memento } from "vscode";
// Maximum number of recent URLs to store.
const MAX_URLS = 10;

// Pending values expire after this duration to guard against stale
// state from crashes or interrupted reloads.
const PENDING_TTL_MS = 5 * 60 * 1000;

interface Stamped<T> {
value: T;
setAt: number;
}

export class MementoManager {
constructor(private readonly memento: Memento) {}

Expand Down Expand Up @@ -42,7 +51,7 @@ export class MementoManager {
* the workspace startup confirmation is shown to the user.
*/
public async setFirstConnect(): Promise<void> {
return this.memento.update("firstConnect", true);
return this.setStamped("firstConnect", true);
}

/**
Expand All @@ -51,21 +60,21 @@ export class MementoManager {
* prompting the user for confirmation.
*/
public async getAndClearFirstConnect(): Promise<boolean> {
const isFirst = this.memento.get<boolean>("firstConnect");
if (isFirst !== undefined) {
const value = this.getStamped<boolean>("firstConnect");
if (value !== undefined) {
await this.memento.update("firstConnect", undefined);
}
return isFirst === true;
return value === true;
}

/** Store a chat ID to open after a remote-authority reload. */
public async setPendingChatId(chatId: string): Promise<void> {
await this.memento.update("pendingChatId", chatId);
await this.setStamped("pendingChatId", chatId);
}

/** Read and clear the pending chat ID (undefined if none). */
public async getAndClearPendingChatId(): Promise<string | undefined> {
const chatId = this.memento.get<string>("pendingChatId");
const chatId = this.getStamped<string>("pendingChatId");
if (chatId !== undefined) {
await this.memento.update("pendingChatId", undefined);
}
Expand All @@ -76,4 +85,20 @@ export class MementoManager {
public async clearPendingChatId(): Promise<void> {
await this.memento.update("pendingChatId", undefined);
}

private async setStamped<T>(key: string, value: T): Promise<void> {
await this.memento.update(key, { value, setAt: Date.now() });
}

private getStamped<T>(key: string): T | undefined {
const raw = this.memento.get<Stamped<T>>(key);
if (raw?.setAt !== undefined && Date.now() - raw.setAt <= PENDING_TTL_MS) {
return raw.value;
}
// Expired or legacy, clean up.
if (raw !== undefined) {
void this.memento.update(key, undefined);
}
return undefined;
}
}
12 changes: 11 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,18 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
chatPanelProvider,
{ webviewOptions: { retainContextWhenHidden: true } },
),
vscode.commands.registerCommand("coder.chat.refresh", () =>
chatPanelProvider.refresh(),
),
);

ctx.subscriptions.push(
registerUriHandler(serviceContainer, deploymentManager, commands),
registerUriHandler({
serviceContainer,
deploymentManager,
commands,
chatPanelProvider,
}),
vscode.commands.registerCommand(
"coder.login",
commands.login.bind(commands),
Expand Down Expand Up @@ -333,6 +341,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
// deployment is configured.
const pendingChatId = await mementoManager.getAndClearPendingChatId();
if (pendingChatId) {
// Enable eagerly so the view is visible before focus.
contextManager.set("coder.agentsEnabled", true);
chatPanelProvider.openChat(pendingChatId);
}
}
Expand Down
65 changes: 31 additions & 34 deletions src/uri/uriHandler.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import * as vscode from "vscode";

import { errToStr } from "../api/api-helper";
import { type Commands } from "../commands";
import { type ServiceContainer } from "../core/container";
import { type DeploymentManager } from "../deployment/deploymentManager";
import { CALLBACK_PATH } from "../oauth/utils";
import { maybeAskUrl } from "../promptUtils";
import { toSafeHost } from "../util";
import { vscodeProposed } from "../vscodeProposed";

interface UriRouteContext {
params: URLSearchParams;
import type { Commands } from "../commands";
import type { ServiceContainer } from "../core/container";
import type { DeploymentManager } from "../deployment/deploymentManager";
import type { ChatPanelProvider } from "../webviews/chat/chatPanelProvider";

interface UriHandlerDeps {
serviceContainer: ServiceContainer;
deploymentManager: DeploymentManager;
commands: Commands;
deploymentManager: Pick<DeploymentManager, "setDeployment">;
commands: Pick<Commands, "open" | "openDevContainer">;
chatPanelProvider: Pick<ChatPanelProvider, "openChat">;
}

interface UriRouteContext extends UriHandlerDeps {
params: URLSearchParams;
}

type UriRouteHandler = (ctx: UriRouteContext) => Promise<void>;
Expand All @@ -27,17 +33,20 @@ const routes: Readonly<Record<string, UriRouteHandler>> = {
/**
* Registers the URI handler for `{vscode.env.uriScheme}://coder.coder-remote`... URIs.
*/
export function registerUriHandler(
serviceContainer: ServiceContainer,
deploymentManager: DeploymentManager,
commands: Commands,
): vscode.Disposable {
const output = serviceContainer.getLogger();
export function registerUriHandler(deps: UriHandlerDeps): vscode.Disposable {
const output = deps.serviceContainer.getLogger();

return vscode.window.registerUriHandler({
handleUri: async (uri) => {
try {
await routeUri(uri, serviceContainer, deploymentManager, commands);
const handler = routes[uri.path];
if (!handler) {
throw new Error(`Unknown path ${uri.path}`);
}
await handler({
...deps,
params: new URLSearchParams(uri.query),
});
} catch (error) {
const message = errToStr(error, "No error message was provided");
output.warn(`Failed to handle URI ${uri.toString()}: ${message}`);
Expand All @@ -51,25 +60,6 @@ export function registerUriHandler(
});
}

async function routeUri(
uri: vscode.Uri,
serviceContainer: ServiceContainer,
deploymentManager: DeploymentManager,
commands: Commands,
): Promise<void> {
const handler = routes[uri.path];
if (!handler) {
throw new Error(`Unknown path ${uri.path}`);
}

await handler({
params: new URLSearchParams(uri.query),
serviceContainer,
deploymentManager,
commands,
});
}

function getRequiredParam(params: URLSearchParams, name: string): string {
const value = params.get(name);
if (!value) {
Expand Down Expand Up @@ -116,6 +106,13 @@ async function handleOpen(ctx: UriRouteContext): Promise<void> {
await mementoManager.clearPendingChatId();
}
}

// Already-open workspace: VS Code refocuses without reloading,
// so activate() won't run. openChat is idempotent if both fire.
if (opened && chatId) {
serviceContainer.getContextManager().set("coder.agentsEnabled", true);
ctx.chatPanelProvider.openChat(chatId);
}
}

async function handleOpenDevContainer(ctx: UriRouteContext): Promise<void> {
Expand Down Expand Up @@ -155,7 +152,7 @@ async function handleOpenDevContainer(ctx: UriRouteContext): Promise<void> {
async function setupDeployment(
params: URLSearchParams,
serviceContainer: ServiceContainer,
deploymentManager: DeploymentManager,
deploymentManager: Pick<DeploymentManager, "setDeployment">,
): Promise<void> {
const secretsManager = serviceContainer.getSecretsManager();
const mementoManager = serviceContainer.getMementoManager();
Expand Down
Loading