Skip to content

Commit 1c79d11

Browse files
committed
Polish editor dirty state and monitoring UI
1 parent 128f890 commit 1c79d11

21 files changed

Lines changed: 1067 additions & 198 deletions

packages/server/src/__tests__/monitoring/aggregation.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ describe("buildMonitoringSnapshot", () => {
4040
startedAt: 2,
4141
},
4242
],
43+
workspaceLabels: {
44+
"ws-1": "coder-studio",
45+
},
4346
processRows: [
4447
{
4548
pid: 1,
@@ -74,6 +77,7 @@ describe("buildMonitoringSnapshot", () => {
7477
expect(response.snapshot.workspaces[0]).toEqual(
7578
expect.objectContaining({
7679
id: "workspace:ws-1",
80+
label: "coder-studio",
7781
cpuPercent: 25,
7882
memoryBytes: 250,
7983
})
@@ -88,6 +92,43 @@ describe("buildMonitoringSnapshot", () => {
8892
expect(response.snapshot.subprocessGroups[0]?.parentId).toBe("session:sess-1");
8993
});
9094

95+
it("falls back to the workspace id when no readable label is available", () => {
96+
const response = buildMonitoringSnapshot({
97+
settings: {
98+
...createDefaultMonitoringSettings(),
99+
enabled: true,
100+
},
101+
sampledAt: 100,
102+
host: null,
103+
roots: [
104+
{
105+
ownerId: "terminal:term-1",
106+
rootPid: 100,
107+
kind: "terminal",
108+
label: "Codex",
109+
workspaceId: "ws_1779980247607_u2lfvdjf",
110+
sessionId: "sess-1",
111+
terminalId: "term-1",
112+
providerId: "codex",
113+
startedAt: 2,
114+
},
115+
],
116+
processRows: [
117+
{
118+
pid: 100,
119+
ppid: 1,
120+
cpuPercent: 20,
121+
rssBytes: 200,
122+
elapsedSec: 90,
123+
command: "codex",
124+
},
125+
],
126+
previousSnapshot: null,
127+
});
128+
129+
expect(response.snapshot.workspaces[0]?.label).toBe("ws_1779980247607_u2lfvdjf");
130+
});
131+
91132
it("keeps host data when process collection fails", () => {
92133
const response = buildMonitoringSnapshot({
93134
settings: {

packages/server/src/__tests__/monitoring/service.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,108 @@ describe("MonitoringService", () => {
334334
);
335335
});
336336

337+
it("labels workspace attribution rows with readable workspace names", async () => {
338+
const registry = new ManagedProcessRegistry({ now: () => 10 });
339+
const service = new MonitoringService({
340+
broadcaster: { broadcast: vi.fn() },
341+
settingsRepo: {
342+
get: (key: string) => {
343+
const settings = {
344+
"monitoring.enabled": true,
345+
"monitoring.hostMetricsEnabled": true,
346+
"monitoring.runtimeSummaryEnabled": true,
347+
"monitoring.workspaceAttributionEnabled": true,
348+
"monitoring.subprocessDrilldownEnabled": false,
349+
"monitoring.sampleIntervalMs": 2000,
350+
} as Record<string, unknown>;
351+
return settings[key];
352+
},
353+
},
354+
registry,
355+
sessionMgr: {
356+
getAll: () =>
357+
[
358+
{
359+
id: "sess-1",
360+
workspaceId: "ws_1779980247607_u2lfvdjf",
361+
terminalId: "term-1",
362+
providerId: "codex",
363+
state: "idle",
364+
capability: "full",
365+
startedAt: 1,
366+
lastActiveAt: 1,
367+
title: "Codex",
368+
},
369+
] satisfies Session[],
370+
findSessionIdByTerminal: () => "sess-1",
371+
},
372+
workspaceMgr: {
373+
get: (workspaceId: string) =>
374+
workspaceId === "ws_1779980247607_u2lfvdjf"
375+
? {
376+
id: workspaceId,
377+
path: "/home/spencer/workspace/coder-studio",
378+
}
379+
: undefined,
380+
},
381+
terminalMgr: {
382+
getAll: () => [
383+
{
384+
toDTO: () =>
385+
({
386+
id: "term-1",
387+
workspaceId: "ws_1779980247607_u2lfvdjf",
388+
kind: "agent",
389+
title: "Codex",
390+
cwd: "/home/spencer/workspace/coder-studio",
391+
argv: ["codex"],
392+
cols: 120,
393+
rows: 30,
394+
pid: 100,
395+
alive: true,
396+
createdAt: 1,
397+
}) satisfies Terminal,
398+
},
399+
],
400+
},
401+
hostCollector: {
402+
collect: () => ({
403+
cpuPercent: 40,
404+
memoryUsedBytes: 400,
405+
memoryTotalBytes: 1000,
406+
memoryAvailableBytes: 600,
407+
loadAverage: [0.2, 0.2, 0.1],
408+
uptimeSec: 10,
409+
pressure: "normal",
410+
}),
411+
},
412+
processCollector: {
413+
collect: async () => [
414+
{ pid: 100, ppid: 1, cpuPercent: 10, rssBytes: 100, elapsedSec: 5, command: "codex" },
415+
],
416+
},
417+
setInterval: vi.fn(() => ({ unref: vi.fn() })),
418+
clearInterval: vi.fn(),
419+
now: () => 10,
420+
});
421+
422+
service.start();
423+
const response = await service.recheck();
424+
425+
expect(response.snapshot.workspaces[0]).toEqual(
426+
expect.objectContaining({
427+
id: "workspace:ws_1779980247607_u2lfvdjf",
428+
label: "coder-studio",
429+
})
430+
);
431+
expect(response.snapshot.sessions[0]).toEqual(
432+
expect.objectContaining({
433+
parentId: "workspace:ws_1779980247607_u2lfvdjf",
434+
label: "Codex",
435+
})
436+
);
437+
});
438+
337439
it("unregisters terminal roots that are no longer active", async () => {
338440
const registry = new ManagedProcessRegistry({ now: () => 10 });
339441
let sessions: Session[] = [

packages/server/src/monitoring/aggregation.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export function buildMonitoringSnapshot(input: {
8686
sampledAt: number;
8787
host: MonitoringHostSummary | null;
8888
roots: ManagedProcessRoot[];
89+
workspaceLabels?: Record<string, string>;
8990
processRows: ProcessStatRow[] | null;
9091
previousSnapshot: MonitoringSnapshot | null;
9192
failureReason?: string;
@@ -213,7 +214,7 @@ export function buildMonitoringSnapshot(input: {
213214
id: workspaceId,
214215
kind: "workspace",
215216
workspaceId: root.workspaceId,
216-
label: root.workspaceId,
217+
label: input.workspaceLabels?.[root.workspaceId] ?? root.workspaceId,
217218
cpuPercent: 0,
218219
memoryBytes: 0,
219220
processCount: 0,

packages/server/src/monitoring/service.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { basename } from "node:path";
12
import {
23
createEmptyMonitoringResponse,
34
deriveMonitoringMode,
@@ -6,6 +7,7 @@ import {
67
type Session,
78
type Terminal,
89
Topics,
10+
type Workspace,
911
} from "@coder-studio/core";
1012
import { buildMonitoringSnapshot } from "./aggregation.js";
1113
import { MonitoringHistoryStore } from "./history-store.js";
@@ -37,6 +39,9 @@ export class MonitoringService {
3739
getAll(): Session[];
3840
findSessionIdByTerminal(terminalId: string): string | undefined;
3941
};
42+
workspaceMgr?: {
43+
get(workspaceId: string): Pick<Workspace, "id" | "name" | "path"> | undefined;
44+
};
4045
terminalMgr: {
4146
getAll(): ActiveTerminalLike[];
4247
};
@@ -171,6 +176,22 @@ export class MonitoringService {
171176
}
172177
}
173178

179+
private getWorkspaceLabels(roots: ReturnType<ManagedProcessRegistry["listRoots"]>) {
180+
const labels: Record<string, string> = {};
181+
for (const root of roots) {
182+
if (!root.workspaceId || labels[root.workspaceId]) {
183+
continue;
184+
}
185+
186+
const workspace = this.deps.workspaceMgr?.get(root.workspaceId);
187+
const label = workspace?.name?.trim() || (workspace?.path ? basename(workspace.path) : "");
188+
if (label) {
189+
labels[root.workspaceId] = label;
190+
}
191+
}
192+
return labels;
193+
}
194+
174195
private async sampleOnce(
175196
settings = resolveMonitoringSettings(this.deps.settingsRepo)
176197
): Promise<void> {
@@ -189,11 +210,13 @@ export class MonitoringService {
189210
}
190211
}
191212

213+
const roots = this.deps.registry.listRoots();
192214
const response = buildMonitoringSnapshot({
193215
settings,
194216
sampledAt: startedAt,
195217
host,
196-
roots: this.deps.registry.listRoots(),
218+
roots,
219+
workspaceLabels: this.getWorkspaceLabels(roots),
197220
processRows,
198221
previousSnapshot:
199222
this.latestSampledSnapshot.sampledAt > 0 ? this.latestSampledSnapshot : null,

packages/server/src/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ export async function createServer(
336336
settingsRepo,
337337
registry: managedProcessRegistry,
338338
sessionMgr,
339+
workspaceMgr,
339340
terminalMgr,
340341
hostCollector: new HostCollector(),
341342
processCollector: createProcessTableCollector(),

packages/web/src/features/agent-panes/views/shared/editor-pane-card.test.tsx

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,41 @@ import { fireEvent, render, screen } from "@testing-library/react";
22
import { createStore, Provider } from "jotai";
33
import { afterEach, describe, expect, it, vi } from "vitest";
44
import { localeAtom } from "../../../../atoms/app-ui";
5-
import { activeFilePathAtomFamily } from "../../../workspace/atoms";
5+
import {
6+
activeFilePathAtomFamily,
7+
type OpenFile,
8+
openFilesAtomFamily,
9+
} from "../../../workspace/atoms";
610
import { EditorPaneCard } from "./editor-pane-card";
711

812
const mocks = vi.hoisted(() => ({
9-
editorState: { marker: "editor-state" },
13+
editorState: {
14+
marker: "editor-state",
15+
currentFile: undefined as OpenFile | undefined,
16+
},
1017
mockUseCodeEditorActions: vi.fn(),
1118
mockCodeEditorHost: vi.fn(() => <div data-testid="editor-host">Editor Host</div>),
1219
mockCodeEditorDesktopHeaderActions: vi.fn(() => (
13-
<div data-testid="editor-toolbar">Editor Toolbar</div>
20+
<div data-testid="editor-toolbar" role="toolbar" aria-label="Editor actions">
21+
Editor Toolbar
22+
</div>
1423
)),
1524
}));
1625

26+
vi.mock("../../../../lib/i18n", () => ({
27+
useTranslation: () => (key: string, params?: Record<string, string>) => {
28+
const dictionary: Record<string, string> = {
29+
"action.close": "Close",
30+
"code_editor.unsaved_changes": "Unsaved changes",
31+
"code_editor.close_unsaved_title": "Discard unsaved changes?",
32+
"code_editor.close_unsaved_description": `${params?.name ?? "File"} has unsaved changes.`,
33+
"code_editor.discard_and_close": "Discard and Close",
34+
"common.cancel": "Cancel",
35+
};
36+
return dictionary[key] ?? key;
37+
},
38+
}));
39+
1740
vi.mock("../../../code-editor/actions/use-code-editor-actions", () => ({
1841
useCodeEditorActions: mocks.mockUseCodeEditorActions,
1942
}));
@@ -51,6 +74,10 @@ describe("EditorPaneCard", () => {
5174
expect(screen.getByText("app.tsx")).toBeInTheDocument();
5275
expect(screen.queryByText("src/app.tsx")).not.toBeInTheDocument();
5376
expect(screen.getByTestId("editor-toolbar")).toBeInTheDocument();
77+
expect(screen.getByTestId("editor-toolbar").closest(".panel-header")).toBeTruthy();
78+
expect(
79+
screen.queryByText("Editor Toolbar")?.closest(".editor-pane-card__toolbar-row")
80+
).toBeNull();
5481
expect(screen.getByTestId("editor-host")).toBeInTheDocument();
5582
expect(mocks.mockCodeEditorDesktopHeaderActions).toHaveBeenCalledWith(
5683
expect.objectContaining({
@@ -75,4 +102,54 @@ describe("EditorPaneCard", () => {
75102
expect(onSplitPane).toHaveBeenNthCalledWith(2, "pane-1", "vertical");
76103
expect(onClosePane).toHaveBeenCalledWith("pane-1");
77104
});
105+
106+
it("marks dirty editor pane titles and confirms before closing dirty files", () => {
107+
const store = createStore();
108+
const onClosePane = vi.fn();
109+
const onSplitPane = vi.fn();
110+
111+
mocks.mockUseCodeEditorActions.mockReturnValue(mocks.editorState);
112+
store.set(localeAtom, "en");
113+
store.set(activeFilePathAtomFamily("ws-123"), "src/app.tsx");
114+
store.set(openFilesAtomFamily("ws-123"), {
115+
"src/app.tsx": {
116+
kind: "text",
117+
path: "src/app.tsx",
118+
content: "changed",
119+
savedContent: "saved",
120+
baseHash: "hash-1",
121+
isDirty: true,
122+
},
123+
});
124+
125+
render(
126+
<Provider store={store}>
127+
<EditorPaneCard
128+
workspaceId="ws-123"
129+
paneId="pane-1"
130+
onClosePane={onClosePane}
131+
onSplitPane={onSplitPane}
132+
/>
133+
</Provider>
134+
);
135+
136+
const title = screen.getByText("app.tsx");
137+
const titleElement = title.closest(".panel-header__title");
138+
const dirtyMeta = titleElement?.nextElementSibling;
139+
140+
expect(dirtyMeta).toHaveClass("panel-header__meta");
141+
expect(dirtyMeta?.querySelector(".editor-pane-card__dirty-indicator")).toBeTruthy();
142+
143+
fireEvent.click(screen.getByRole("button", { name: "Close" }));
144+
expect(onClosePane).not.toHaveBeenCalled();
145+
expect(screen.getByRole("dialog", { name: "Discard unsaved changes?" })).toBeInTheDocument();
146+
147+
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
148+
expect(onClosePane).not.toHaveBeenCalled();
149+
150+
fireEvent.click(screen.getByRole("button", { name: "Close" }));
151+
fireEvent.click(screen.getByRole("button", { name: "Discard and Close" }));
152+
153+
expect(onClosePane).toHaveBeenCalledWith("pane-1");
154+
});
78155
});

0 commit comments

Comments
 (0)