Skip to content

Commit 0c07017

Browse files
authored
feat: add theme sync, scroll-to-bottom, and navigation to chat embed (#860)
Sync VS Code color theme to the embedded chat iframe via postMessage, with the initial theme passed as a URL query parameter to prevent flicker. Send scroll-to-bottom when the iframe signals chat-ready. Handle navigate messages from the iframe by opening URLs externally with origin validation. Also adds a chat refresh toolbar command, TTL expiry for pending memento values, and direct chat opening when the workspace is already active.
1 parent e595b54 commit 0c07017

File tree

11 files changed

+483
-65
lines changed

11 files changed

+483
-65
lines changed

package.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,12 @@
353353
"category": "Coder",
354354
"icon": "$(refresh)"
355355
},
356+
{
357+
"command": "coder.chat.refresh",
358+
"title": "Refresh Chat",
359+
"category": "Coder",
360+
"icon": "$(refresh)"
361+
},
356362
{
357363
"command": "coder.applyRecommendedSettings",
358364
"title": "Apply Recommended SSH Settings",
@@ -424,6 +430,10 @@
424430
"command": "coder.tasks.refresh",
425431
"when": "false"
426432
},
433+
{
434+
"command": "coder.chat.refresh",
435+
"when": "false"
436+
},
427437
{
428438
"command": "coder.applyRecommendedSettings"
429439
}
@@ -465,6 +475,11 @@
465475
"command": "coder.tasks.refresh",
466476
"when": "coder.authenticated && view == coder.tasksPanel",
467477
"group": "navigation@1"
478+
},
479+
{
480+
"command": "coder.chat.refresh",
481+
"when": "view == coder.chatPanel",
482+
"group": "navigation@1"
468483
}
469484
],
470485
"view/item/context": [
@@ -579,7 +594,7 @@
579594
"extensionPack": [
580595
"ms-vscode-remote.remote-ssh"
581596
],
582-
"packageManager": "pnpm@10.32.1",
597+
"packageManager": "pnpm@10.33.0",
583598
"engines": {
584599
"vscode": "^1.106.0",
585600
"node": ">= 22"

src/core/mementoManager.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ import type { Memento } from "vscode";
33
// Maximum number of recent URLs to store.
44
const MAX_URLS = 10;
55

6+
// Pending values expire after this duration to guard against stale
7+
// state from crashes or interrupted reloads.
8+
const PENDING_TTL_MS = 5 * 60 * 1000;
9+
10+
interface Stamped<T> {
11+
value: T;
12+
setAt: number;
13+
}
14+
615
export class MementoManager {
716
constructor(private readonly memento: Memento) {}
817

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

4857
/**
@@ -51,21 +60,21 @@ export class MementoManager {
5160
* prompting the user for confirmation.
5261
*/
5362
public async getAndClearFirstConnect(): Promise<boolean> {
54-
const isFirst = this.memento.get<boolean>("firstConnect");
55-
if (isFirst !== undefined) {
63+
const value = this.getStamped<boolean>("firstConnect");
64+
if (value !== undefined) {
5665
await this.memento.update("firstConnect", undefined);
5766
}
58-
return isFirst === true;
67+
return value === true;
5968
}
6069

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

6675
/** Read and clear the pending chat ID (undefined if none). */
6776
public async getAndClearPendingChatId(): Promise<string | undefined> {
68-
const chatId = this.memento.get<string>("pendingChatId");
77+
const chatId = this.getStamped<string>("pendingChatId");
6978
if (chatId !== undefined) {
7079
await this.memento.update("pendingChatId", undefined);
7180
}
@@ -76,4 +85,20 @@ export class MementoManager {
7685
public async clearPendingChatId(): Promise<void> {
7786
await this.memento.update("pendingChatId", undefined);
7887
}
88+
89+
private async setStamped<T>(key: string, value: T): Promise<void> {
90+
await this.memento.update(key, { value, setAt: Date.now() });
91+
}
92+
93+
private getStamped<T>(key: string): T | undefined {
94+
const raw = this.memento.get<Stamped<T>>(key);
95+
if (raw?.setAt !== undefined && Date.now() - raw.setAt <= PENDING_TTL_MS) {
96+
return raw.value;
97+
}
98+
// Expired or legacy, clean up.
99+
if (raw !== undefined) {
100+
void this.memento.update(key, undefined);
101+
}
102+
return undefined;
103+
}
79104
}

src/extension.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,10 +232,18 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
232232
chatPanelProvider,
233233
{ webviewOptions: { retainContextWhenHidden: true } },
234234
),
235+
vscode.commands.registerCommand("coder.chat.refresh", () =>
236+
chatPanelProvider.refresh(),
237+
),
235238
);
236239

237240
ctx.subscriptions.push(
238-
registerUriHandler(serviceContainer, deploymentManager, commands),
241+
registerUriHandler({
242+
serviceContainer,
243+
deploymentManager,
244+
commands,
245+
chatPanelProvider,
246+
}),
239247
vscode.commands.registerCommand(
240248
"coder.login",
241249
commands.login.bind(commands),
@@ -333,6 +341,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
333341
// deployment is configured.
334342
const pendingChatId = await mementoManager.getAndClearPendingChatId();
335343
if (pendingChatId) {
344+
// Enable eagerly so the view is visible before focus.
345+
contextManager.set("coder.agentsEnabled", true);
336346
chatPanelProvider.openChat(pendingChatId);
337347
}
338348
}

src/uri/uriHandler.ts

Lines changed: 31 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
import * as vscode from "vscode";
22

33
import { errToStr } from "../api/api-helper";
4-
import { type Commands } from "../commands";
5-
import { type ServiceContainer } from "../core/container";
6-
import { type DeploymentManager } from "../deployment/deploymentManager";
74
import { CALLBACK_PATH } from "../oauth/utils";
85
import { maybeAskUrl } from "../promptUtils";
96
import { toSafeHost } from "../util";
107
import { vscodeProposed } from "../vscodeProposed";
118

12-
interface UriRouteContext {
13-
params: URLSearchParams;
9+
import type { Commands } from "../commands";
10+
import type { ServiceContainer } from "../core/container";
11+
import type { DeploymentManager } from "../deployment/deploymentManager";
12+
import type { ChatPanelProvider } from "../webviews/chat/chatPanelProvider";
13+
14+
interface UriHandlerDeps {
1415
serviceContainer: ServiceContainer;
15-
deploymentManager: DeploymentManager;
16-
commands: Commands;
16+
deploymentManager: Pick<DeploymentManager, "setDeployment">;
17+
commands: Pick<Commands, "open" | "openDevContainer">;
18+
chatPanelProvider: Pick<ChatPanelProvider, "openChat">;
19+
}
20+
21+
interface UriRouteContext extends UriHandlerDeps {
22+
params: URLSearchParams;
1723
}
1824

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

3739
return vscode.window.registerUriHandler({
3840
handleUri: async (uri) => {
3941
try {
40-
await routeUri(uri, serviceContainer, deploymentManager, commands);
42+
const handler = routes[uri.path];
43+
if (!handler) {
44+
throw new Error(`Unknown path ${uri.path}`);
45+
}
46+
await handler({
47+
...deps,
48+
params: new URLSearchParams(uri.query),
49+
});
4150
} catch (error) {
4251
const message = errToStr(error, "No error message was provided");
4352
output.warn(`Failed to handle URI ${uri.toString()}: ${message}`);
@@ -51,25 +60,6 @@ export function registerUriHandler(
5160
});
5261
}
5362

54-
async function routeUri(
55-
uri: vscode.Uri,
56-
serviceContainer: ServiceContainer,
57-
deploymentManager: DeploymentManager,
58-
commands: Commands,
59-
): Promise<void> {
60-
const handler = routes[uri.path];
61-
if (!handler) {
62-
throw new Error(`Unknown path ${uri.path}`);
63-
}
64-
65-
await handler({
66-
params: new URLSearchParams(uri.query),
67-
serviceContainer,
68-
deploymentManager,
69-
commands,
70-
});
71-
}
72-
7363
function getRequiredParam(params: URLSearchParams, name: string): string {
7464
const value = params.get(name);
7565
if (!value) {
@@ -116,6 +106,13 @@ async function handleOpen(ctx: UriRouteContext): Promise<void> {
116106
await mementoManager.clearPendingChatId();
117107
}
118108
}
109+
110+
// Already-open workspace: VS Code refocuses without reloading,
111+
// so activate() won't run. openChat is idempotent if both fire.
112+
if (opened && chatId) {
113+
serviceContainer.getContextManager().set("coder.agentsEnabled", true);
114+
ctx.chatPanelProvider.openChat(chatId);
115+
}
119116
}
120117

121118
async function handleOpenDevContainer(ctx: UriRouteContext): Promise<void> {
@@ -155,7 +152,7 @@ async function handleOpenDevContainer(ctx: UriRouteContext): Promise<void> {
155152
async function setupDeployment(
156153
params: URLSearchParams,
157154
serviceContainer: ServiceContainer,
158-
deploymentManager: DeploymentManager,
155+
deploymentManager: Pick<DeploymentManager, "setDeployment">,
159156
): Promise<void> {
160157
const secretsManager = serviceContainer.getSecretsManager();
161158
const mementoManager = serviceContainer.getMementoManager();

0 commit comments

Comments
 (0)