Skip to content

Commit b333696

Browse files
committed
feat: add theme sync, scroll-to-bottom, and navigation to chat embed
Support the Coder embed postMessage protocol for theme sync, scroll-to-bottom, and navigation handling. The extension detects the VS Code color theme and forwards it to the embedded iframe via coder:set-theme, sends coder:scroll-to-bottom on chat-ready, and opens coder:navigate URLs externally. Also adds the coder.chat.refresh command, TTL expiry for pending memento values, and eager agentsEnabled context for pending chats.
1 parent f49adcf commit b333696

File tree

9 files changed

+416
-21
lines changed

9 files changed

+416
-21
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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,9 @@ 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(
@@ -333,6 +336,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
333336
// deployment is configured.
334337
const pendingChatId = await mementoManager.getAndClearPendingChatId();
335338
if (pendingChatId) {
339+
// Enable eagerly so the view is visible before focus.
340+
contextManager.set("coder.agentsEnabled", true);
336341
chatPanelProvider.openChat(pendingChatId);
337342
}
338343
}

src/webviews/chat/chatPanelProvider.ts

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { randomBytes } from "node:crypto";
1+
import * as vscode from "vscode";
22

33
import { type CoderApi } from "../../api/coderApi";
44
import { type Logger } from "../../logging/logger";
5-
6-
import type * as vscode from "vscode";
5+
import { getNonce } from "../util";
76

87
/**
98
* Provides a webview that embeds the Coder agent chat UI.
@@ -34,15 +33,38 @@ export class ChatPanelProvider
3433
private readonly logger: Logger,
3534
) {}
3635

36+
private getTheme(): "light" | "dark" {
37+
const kind = vscode.window.activeColorTheme.kind;
38+
return kind === vscode.ColorThemeKind.Light ||
39+
kind === vscode.ColorThemeKind.HighContrastLight
40+
? "light"
41+
: "dark";
42+
}
43+
44+
private sendScrollToBottom(): void {
45+
this.view?.webview.postMessage({ type: "coder:scroll-to-bottom" });
46+
}
47+
48+
private sendTheme(): void {
49+
this.view?.webview.postMessage({
50+
type: "coder:set-theme",
51+
theme: this.getTheme(),
52+
});
53+
}
54+
3755
/**
3856
* Opens the chat panel for the given chat ID.
3957
* Called after a deep link reload via the persisted
4058
* pendingChatId, or directly for testing.
4159
*/
4260
public openChat(chatId: string): void {
61+
if (this.chatId === chatId && this.view) {
62+
this.view.show(true);
63+
return;
64+
}
4365
this.chatId = chatId;
4466
this.refresh();
45-
this.view?.show(true);
67+
void vscode.commands.executeCommand(`${ChatPanelProvider.viewType}.focus`);
4668
}
4769

4870
resolveWebviewView(
@@ -56,9 +78,12 @@ export class ChatPanelProvider
5678
webviewView.webview.onDidReceiveMessage((msg: unknown) => {
5779
this.handleMessage(msg);
5880
}),
81+
vscode.window.onDidChangeActiveColorTheme(() => {
82+
this.sendTheme();
83+
}),
5984
);
6085
this.renderView();
61-
webviewView.onDidDispose(() => this.dispose());
86+
this.disposables.push(webviewView.onDidDispose(() => this.dispose()));
6287
}
6388

6489
public refresh(): void {
@@ -85,17 +110,33 @@ export class ChatPanelProvider
85110
return;
86111
}
87112

88-
const embedUrl = `${coderUrl}/agents/${this.chatId}/embed`;
113+
const embedUrl = `${coderUrl}/agents/${this.chatId}/embed?theme=${this.getTheme()}`;
89114
webview.html = this.getIframeHtml(embedUrl, coderUrl);
90115
}
91116

92117
private handleMessage(message: unknown): void {
93118
if (typeof message !== "object" || message === null) {
94119
return;
95120
}
96-
const msg = message as { type?: string };
97-
if (msg.type === "coder:vscode-ready") {
98-
this.sendAuthToken();
121+
const msg = message as { type?: string; payload?: { url?: string } };
122+
switch (msg.type) {
123+
case "coder:vscode-ready":
124+
this.sendAuthToken();
125+
break;
126+
case "coder:chat-ready":
127+
this.sendTheme();
128+
this.sendScrollToBottom();
129+
break;
130+
case "coder:navigate": {
131+
const url = msg.payload?.url;
132+
const coderUrl = this.client.getHost();
133+
if (url && coderUrl) {
134+
void vscode.env.openExternal(vscode.Uri.parse(coderUrl + url));
135+
}
136+
break;
137+
}
138+
default:
139+
break;
99140
}
100141
}
101142

@@ -142,7 +183,7 @@ export class ChatPanelProvider
142183
}
143184

144185
private getIframeHtml(embedUrl: string, allowedOrigin: string): string {
145-
const nonce = randomBytes(16).toString("base64");
186+
const nonce = getNonce();
146187

147188
return /* html */ `<!DOCTYPE html>
148189
<html lang="en">
@@ -205,6 +246,12 @@ export class ChatPanelProvider
205246
status.textContent = 'Authenticating…';
206247
vscode.postMessage({ type: 'coder:vscode-ready' });
207248
}
249+
if (data.type === 'coder:chat-ready') {
250+
vscode.postMessage({ type: 'coder:chat-ready' });
251+
}
252+
if (data.type === 'coder:navigate') {
253+
vscode.postMessage(data);
254+
}
208255
return;
209256
}
210257
@@ -216,6 +263,18 @@ export class ChatPanelProvider
216263
}, '${allowedOrigin}');
217264
}
218265
266+
if (data.type === 'coder:set-theme') {
267+
iframe.contentWindow.postMessage({
268+
type: 'coder:set-theme',
269+
payload: { theme: data.theme },
270+
}, '${allowedOrigin}');
271+
}
272+
273+
if (data.type === 'coder:scroll-to-bottom') {
274+
iframe.contentWindow.postMessage(
275+
{ type: 'coder:scroll-to-bottom' }, '${allowedOrigin}');
276+
}
277+
219278
if (data.type === 'coder:auth-error') {
220279
status.textContent = '';
221280
status.appendChild(document.createTextNode(data.error || 'Authentication failed.'));

src/webviews/util.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,6 @@ export function getWebviewHtml(
4242
</html>`;
4343
}
4444

45-
function getNonce(): string {
45+
export function getNonce(): string {
4646
return randomBytes(16).toString("base64");
4747
}

test/mocks/testHelpers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,7 @@ export class MockCoderApi implements Pick<
509509
| "setSessionToken"
510510
| "setCredentials"
511511
| "getHost"
512+
| "getSessionToken"
512513
| "getAuthenticatedUser"
513514
| "dispose"
514515
| "getExperiments"
@@ -534,6 +535,7 @@ export class MockCoderApi implements Pick<
534535
);
535536

536537
readonly getHost = vi.fn(() => this._host);
538+
readonly getSessionToken = vi.fn(() => this._token);
537539

538540
readonly getAuthenticatedUser = vi.fn((): Promise<User> => {
539541
if (this.authenticatedUser instanceof Error) {

test/mocks/vscode.runtime.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ export const TreeItemCollapsibleState = E({
2626
Expanded: 2,
2727
});
2828
export const StatusBarAlignment = E({ Left: 1, Right: 2 });
29+
export const ColorThemeKind = E({
30+
Light: 1,
31+
Dark: 2,
32+
HighContrast: 3,
33+
HighContrastLight: 4,
34+
});
2935
export const ExtensionMode = E({ Production: 1, Development: 2, Test: 3 });
3036
export const UIKind = E({ Desktop: 1, Web: 2 });
3137
export const InputBoxValidationSeverity = E({
@@ -82,8 +88,13 @@ export class EventEmitter<T> {
8288

8389
const onDidChangeConfiguration = new EventEmitter<unknown>();
8490
const onDidChangeWorkspaceFolders = new EventEmitter<unknown>();
91+
const onDidChangeActiveColorTheme = new EventEmitter<unknown>();
8592

8693
export const window = {
94+
activeColorTheme: { kind: ColorThemeKind.Dark } as { kind: number },
95+
onDidChangeActiveColorTheme: onDidChangeActiveColorTheme.event,
96+
__fireDidChangeActiveColorTheme: (e: unknown) =>
97+
onDidChangeActiveColorTheme.fire(e),
8798
showInformationMessage: vi.fn(),
8899
showWarningMessage: vi.fn(),
89100
showErrorMessage: vi.fn(),
@@ -151,6 +162,7 @@ const vscode = {
151162
ConfigurationTarget,
152163
TreeItemCollapsibleState,
153164
StatusBarAlignment,
165+
ColorThemeKind,
154166
ExtensionMode,
155167
UIKind,
156168
InputBoxValidationSeverity,

0 commit comments

Comments
 (0)