@nextclaw/companion 0.1.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/dist/src/companion-session-view.service.test.js +27 -0
  4. package/dist/src/launcher/index.js +22 -0
  5. package/dist/src/main.js +22 -0
  6. package/dist/src/preload/index.js +13 -0
  7. package/dist/src/services/companion-application.service.js +68 -0
  8. package/dist/src/services/companion-runtime-client.service.js +88 -0
  9. package/dist/src/services/companion-session-view.service.js +52 -0
  10. package/dist/src/services/companion-tray.service.js +41 -0
  11. package/dist/src/services/companion-window.service.js +106 -0
  12. package/dist/src/stores/companion-runtime-state.store.js +21 -0
  13. package/dist/src/stores/companion-window-position.store.js +30 -0
  14. package/dist/src/types/companion.types.js +2 -0
  15. package/dist/src/utils/companion-renderer-html.utils.js +194 -0
  16. package/package.json +31 -0
  17. package/scripts/smoke.mjs +20 -0
  18. package/src/companion-session-view.service.test.ts +37 -0
  19. package/src/launcher/index.ts +24 -0
  20. package/src/main.ts +23 -0
  21. package/src/preload/index.ts +13 -0
  22. package/src/services/companion-application.service.ts +76 -0
  23. package/src/services/companion-runtime-client.service.ts +118 -0
  24. package/src/services/companion-session-view.service.ts +63 -0
  25. package/src/services/companion-tray.service.ts +44 -0
  26. package/src/services/companion-window.service.ts +115 -0
  27. package/src/stores/companion-runtime-state.store.ts +23 -0
  28. package/src/stores/companion-window-position.store.ts +31 -0
  29. package/src/types/companion.types.ts +37 -0
  30. package/src/utils/companion-renderer-html.utils.ts +192 -0
  31. package/tsconfig.json +13 -0
@@ -0,0 +1,194 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderCompanionHtml = renderCompanionHtml;
4
+ const COMPANION_HTML = `<!doctype html>
5
+ <html lang="en">
6
+ <head>
7
+ <meta charset="utf-8" />
8
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
9
+ <title>NextClaw Companion</title>
10
+ <style>
11
+ :root {
12
+ color-scheme: light;
13
+ --ring: rgba(12, 84, 190, 0.2);
14
+ --panel: rgba(255, 255, 255, 0.88);
15
+ --ink: #16324f;
16
+ --muted: #5f6c7c;
17
+ --idle: #d7dce2;
18
+ --running: #26a269;
19
+ --offline: #d64545;
20
+ }
21
+ * { box-sizing: border-box; }
22
+ html, body {
23
+ margin: 0;
24
+ width: 100%;
25
+ height: 100%;
26
+ overflow: hidden;
27
+ background: transparent;
28
+ font-family: "SF Pro Display", "Helvetica Neue", sans-serif;
29
+ }
30
+ body {
31
+ display: grid;
32
+ place-items: center;
33
+ }
34
+ button {
35
+ border: 0;
36
+ background: none;
37
+ padding: 0;
38
+ cursor: pointer;
39
+ }
40
+ .shell {
41
+ width: 112px;
42
+ height: 132px;
43
+ display: grid;
44
+ justify-items: center;
45
+ gap: 8px;
46
+ }
47
+ .avatar {
48
+ width: 96px;
49
+ height: 96px;
50
+ border-radius: 28px;
51
+ background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(235,241,247,0.94));
52
+ box-shadow: 0 14px 36px rgba(13, 30, 51, 0.16), inset 0 0 0 1px rgba(255,255,255,0.82);
53
+ position: relative;
54
+ display: grid;
55
+ place-items: center;
56
+ overflow: hidden;
57
+ -webkit-app-region: drag;
58
+ }
59
+ .avatar::after {
60
+ content: "";
61
+ position: absolute;
62
+ inset: 4px;
63
+ border-radius: 24px;
64
+ box-shadow: inset 0 0 0 1px var(--ring);
65
+ pointer-events: none;
66
+ }
67
+ .avatar img {
68
+ width: 100%;
69
+ height: 100%;
70
+ object-fit: cover;
71
+ }
72
+ .initials {
73
+ width: 100%;
74
+ height: 100%;
75
+ display: grid;
76
+ place-items: center;
77
+ color: var(--ink);
78
+ font-size: 28px;
79
+ font-weight: 700;
80
+ letter-spacing: 0;
81
+ background: radial-gradient(circle at top, rgba(255,255,255,0.98), rgba(223,232,242,0.96));
82
+ }
83
+ .status {
84
+ position: absolute;
85
+ right: 8px;
86
+ bottom: 8px;
87
+ width: 14px;
88
+ height: 14px;
89
+ border-radius: 999px;
90
+ border: 2px solid var(--panel);
91
+ background: var(--idle);
92
+ }
93
+ .status[data-state="running"] { background: var(--running); }
94
+ .status[data-state="offline"] { background: var(--offline); }
95
+ .meta {
96
+ width: 100%;
97
+ padding: 0 4px;
98
+ text-align: center;
99
+ -webkit-app-region: no-drag;
100
+ cursor: pointer;
101
+ }
102
+ .title {
103
+ color: var(--ink);
104
+ font-size: 12px;
105
+ font-weight: 700;
106
+ line-height: 1.25;
107
+ white-space: nowrap;
108
+ overflow: hidden;
109
+ text-overflow: ellipsis;
110
+ }
111
+ .subtitle {
112
+ color: var(--muted);
113
+ font-size: 11px;
114
+ line-height: 1.2;
115
+ margin-top: 2px;
116
+ white-space: nowrap;
117
+ overflow: hidden;
118
+ text-overflow: ellipsis;
119
+ }
120
+ .actions {
121
+ position: absolute;
122
+ top: 6px;
123
+ right: 6px;
124
+ display: flex;
125
+ gap: 4px;
126
+ -webkit-app-region: no-drag;
127
+ }
128
+ .icon-button {
129
+ width: 18px;
130
+ height: 18px;
131
+ border-radius: 999px;
132
+ background: rgba(22, 50, 79, 0.12);
133
+ color: var(--ink);
134
+ font-size: 11px;
135
+ font-weight: 700;
136
+ display: grid;
137
+ place-items: center;
138
+ }
139
+ </style>
140
+ </head>
141
+ <body>
142
+ <div class="shell">
143
+ <div class="avatar">
144
+ <div class="actions">
145
+ <button class="icon-button" id="close-button" type="button" aria-label="Quit Companion">x</button>
146
+ </div>
147
+ <div class="initials" id="initials">NC</div>
148
+ <img id="avatar-image" alt="" hidden />
149
+ <span class="status" id="status-dot" data-state="idle"></span>
150
+ </div>
151
+ <div class="meta" id="open-button" role="button" tabindex="0" aria-label="Open NextClaw">
152
+ <div class="title" id="title">NextClaw</div>
153
+ <div class="subtitle" id="subtitle">Waiting</div>
154
+ </div>
155
+ </div>
156
+ <script>
157
+ const titleNode = document.getElementById("title");
158
+ const subtitleNode = document.getElementById("subtitle");
159
+ const initialsNode = document.getElementById("initials");
160
+ const avatarImageNode = document.getElementById("avatar-image");
161
+ const statusNode = document.getElementById("status-dot");
162
+ const openButtonNode = document.getElementById("open-button");
163
+ const closeButtonNode = document.getElementById("close-button");
164
+ const applyView = (view) => {
165
+ titleNode.textContent = view.title;
166
+ subtitleNode.textContent = view.subtitle;
167
+ initialsNode.textContent = (view.title || "NC").slice(0, 2).toUpperCase();
168
+ statusNode.dataset.state = view.state;
169
+ if (view.avatarUrl) {
170
+ avatarImageNode.src = view.avatarUrl;
171
+ avatarImageNode.hidden = false;
172
+ initialsNode.hidden = true;
173
+ } else {
174
+ avatarImageNode.hidden = true;
175
+ avatarImageNode.removeAttribute("src");
176
+ initialsNode.hidden = false;
177
+ }
178
+ };
179
+ window.nextclawCompanion.onView(applyView);
180
+ openButtonNode.addEventListener("click", () => window.nextclawCompanion.open());
181
+ openButtonNode.addEventListener("keydown", (event) => {
182
+ if (event.key === "Enter" || event.key === " ") {
183
+ event.preventDefault();
184
+ window.nextclawCompanion.open();
185
+ }
186
+ });
187
+ closeButtonNode.addEventListener("click", () => window.nextclawCompanion.quit());
188
+ window.nextclawCompanion.ready();
189
+ </script>
190
+ </body>
191
+ </html>`;
192
+ function renderCompanionHtml() {
193
+ return COMPANION_HTML;
194
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@nextclaw/companion",
3
+ "version": "0.1.1-beta.0",
4
+ "private": false,
5
+ "description": "Standalone Electron companion shell for active NextClaw agents.",
6
+ "author": "NextClaw Team",
7
+ "homepage": "https://github.com/Peiiii/nextclaw",
8
+ "main": "dist/src/main.js",
9
+ "bin": {
10
+ "nextclaw-companion": "dist/src/launcher/index.js"
11
+ },
12
+ "license": "MIT",
13
+ "dependencies": {
14
+ "electron": "^32.2.1",
15
+ "@nextclaw/client-sdk": "0.1.1-beta.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/node": "^20.17.6",
19
+ "typescript": "^5.6.3",
20
+ "vitest": "^4.1.2"
21
+ },
22
+ "scripts": {
23
+ "dev": "pnpm build:main && node dist/src/launcher/index.js",
24
+ "build": "pnpm build:main",
25
+ "build:main": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json --outDir dist --noEmit false",
26
+ "lint": "eslint \"src/**/*.ts\"",
27
+ "tsc": "tsc -p tsconfig.json --noEmit",
28
+ "test": "vitest run src/companion-session-view.service.test.ts",
29
+ "smoke": "pnpm build:main && node scripts/smoke.mjs"
30
+ }
31
+ }
@@ -0,0 +1,20 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ const requiredFiles = [
5
+ resolve("dist/src/main.js"),
6
+ resolve("dist/src/launcher/index.js"),
7
+ resolve("dist/src/preload/index.js")
8
+ ];
9
+
10
+ const missingFiles = requiredFiles.filter((filePath) => !existsSync(filePath));
11
+
12
+ if (missingFiles.length > 0) {
13
+ console.error("Missing companion build artifacts:");
14
+ for (const filePath of missingFiles) {
15
+ console.error(`- ${filePath}`);
16
+ }
17
+ process.exit(1);
18
+ }
19
+
20
+ console.log("Companion smoke passed.");
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { CompanionSessionViewService } from "./services/companion-session-view.service.js";
3
+
4
+ describe("CompanionSessionViewService", () => {
5
+ it("prefers the most recently updated running session", () => {
6
+ const service = new CompanionSessionViewService(
7
+ "http://127.0.0.1:55667",
8
+ (agentId) => `http://127.0.0.1:55667/api/agents/${agentId}/avatar`
9
+ );
10
+
11
+ const view = service.selectView({
12
+ agents: [{ id: "writer", displayName: "Writer" }],
13
+ sessions: [
14
+ { sessionId: "older", agentId: "writer", messageCount: 1, updatedAt: "2026-05-05T00:00:00.000Z", status: "running" },
15
+ { sessionId: "newer", agentId: "writer", messageCount: 2, updatedAt: "2026-05-06T00:00:00.000Z", status: "running" }
16
+ ]
17
+ });
18
+
19
+ expect(view.sessionId).toBe("newer");
20
+ expect(view.title).toBe("Writer");
21
+ });
22
+
23
+ it("falls back to an idle shell view when no running session exists", () => {
24
+ const service = new CompanionSessionViewService(
25
+ "http://127.0.0.1:55667",
26
+ () => "http://127.0.0.1:55667/api/agents/default/avatar"
27
+ );
28
+
29
+ const view = service.selectView({
30
+ agents: [],
31
+ sessions: []
32
+ });
33
+
34
+ expect(view.state).toBe("idle");
35
+ expect(view.subtitle).toBe("No active agent");
36
+ });
37
+ });
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { createRequire } from "node:module";
4
+ import { resolve } from "node:path";
5
+
6
+ const loadModule = createRequire(__filename);
7
+ const electronBinary = loadModule("electron") as string;
8
+ const appRoot = resolve(__dirname, "..", "..", "..");
9
+ const env = { ...process.env };
10
+
11
+ delete env.ELECTRON_RUN_AS_NODE;
12
+
13
+ const child = spawn(electronBinary, [appRoot, ...process.argv.slice(2)], {
14
+ stdio: "inherit",
15
+ env
16
+ });
17
+
18
+ child.on("exit", (code, signal) => {
19
+ if (signal) {
20
+ process.kill(process.pid, signal);
21
+ return;
22
+ }
23
+ process.exit(code ?? 0);
24
+ });
package/src/main.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { CompanionApplicationService } from "./services/companion-application.service.js";
2
+
3
+ function resolveBaseUrl(argv: string[]): string {
4
+ const baseUrlFromCli = argv.find((value) => value.startsWith("--base-url="));
5
+ if (baseUrlFromCli) {
6
+ return baseUrlFromCli.slice("--base-url=".length);
7
+ }
8
+ const baseUrlFlagIndex = argv.findIndex((value) => value === "--base-url");
9
+ if (baseUrlFlagIndex >= 0 && argv[baseUrlFlagIndex + 1]) {
10
+ return argv[baseUrlFlagIndex + 1];
11
+ }
12
+ return process.env.NEXTCLAW_COMPANION_BASE_URL?.trim() || "http://127.0.0.1:55667";
13
+ }
14
+
15
+ async function main(): Promise<void> {
16
+ const application = new CompanionApplicationService({
17
+ baseUrl: resolveBaseUrl(process.argv.slice(1)),
18
+ runtimeStatePath: process.env.NEXTCLAW_COMPANION_RUNTIME_STATE_PATH?.trim() || undefined
19
+ });
20
+ await application.run();
21
+ }
22
+
23
+ void main();
@@ -0,0 +1,13 @@
1
+ import { contextBridge, ipcRenderer } from "electron";
2
+ import type { CompanionAvatarView } from "../types/companion.types.js";
3
+
4
+ contextBridge.exposeInMainWorld("nextclawCompanion", {
5
+ onView: (listener: (view: CompanionAvatarView) => void) => {
6
+ ipcRenderer.on("companion:view", (_event, view: CompanionAvatarView) => {
7
+ listener(view);
8
+ });
9
+ },
10
+ open: () => ipcRenderer.invoke("companion:open"),
11
+ quit: () => ipcRenderer.invoke("companion:quit"),
12
+ ready: () => ipcRenderer.send("companion:ready")
13
+ });
@@ -0,0 +1,76 @@
1
+ import { app } from "electron";
2
+ import { resolve } from "node:path";
3
+ import type { CompanionAppOptions } from "../types/companion.types.js";
4
+ import { CompanionRuntimeStateStore } from "../stores/companion-runtime-state.store.js";
5
+ import { CompanionWindowPositionStore } from "../stores/companion-window-position.store.js";
6
+ import { CompanionRuntimeClientService } from "./companion-runtime-client.service.js";
7
+ import { CompanionTrayService } from "./companion-tray.service.js";
8
+ import { CompanionWindowService } from "./companion-window.service.js";
9
+
10
+ export class CompanionApplicationService {
11
+ private readonly runtimeClient: CompanionRuntimeClientService;
12
+ private readonly runtimeStateStore: CompanionRuntimeStateStore | null;
13
+ private readonly windowService: CompanionWindowService;
14
+ private readonly trayService: CompanionTrayService;
15
+ private quitting = false;
16
+
17
+ constructor(private readonly options: CompanionAppOptions) {
18
+ const preloadPath = resolve(__dirname, "..", "preload", "index.js");
19
+ this.runtimeClient = new CompanionRuntimeClientService(options.baseUrl);
20
+ this.runtimeStateStore = options.runtimeStatePath
21
+ ? new CompanionRuntimeStateStore(options.runtimeStatePath)
22
+ : null;
23
+ this.windowService = new CompanionWindowService(
24
+ preloadPath,
25
+ CompanionWindowPositionStore.fromUserData(app.getPath("userData"))
26
+ );
27
+ this.trayService = new CompanionTrayService(
28
+ options.baseUrl,
29
+ () => this.windowService.toggleVisibility(),
30
+ () => this.quit()
31
+ );
32
+ }
33
+
34
+ readonly run = async (): Promise<void> => {
35
+ if (!app.requestSingleInstanceLock()) {
36
+ app.quit();
37
+ return;
38
+ }
39
+
40
+ app.on("second-instance", () => {
41
+ this.windowService.show();
42
+ });
43
+ app.on("window-all-closed", () => undefined);
44
+ app.on("before-quit", () => {
45
+ this.quitting = true;
46
+ this.runtimeClient.stop();
47
+ this.trayService.destroy();
48
+ this.windowService.destroy();
49
+ this.runtimeStateStore?.clear();
50
+ });
51
+ app.on("activate", () => {
52
+ this.windowService.show();
53
+ });
54
+
55
+ await app.whenReady();
56
+ this.runtimeStateStore?.write({
57
+ pid: process.pid,
58
+ startedAt: new Date().toISOString(),
59
+ baseUrl: this.options.baseUrl
60
+ });
61
+ await this.windowService.create();
62
+ this.windowService.show();
63
+ this.trayService.create();
64
+ await this.runtimeClient.start((view) => {
65
+ this.windowService.updateView(view);
66
+ });
67
+ };
68
+
69
+ readonly quit = (): void => {
70
+ if (this.quitting) {
71
+ return;
72
+ }
73
+ this.quitting = true;
74
+ app.quit();
75
+ };
76
+ }
@@ -0,0 +1,118 @@
1
+ import type {
2
+ CompanionAgentProfile,
3
+ CompanionAvatarView,
4
+ CompanionSessionSummary
5
+ } from "../types/companion.types.js";
6
+ import { CompanionSessionViewService } from "./companion-session-view.service.js";
7
+
8
+ type CompanionSdkClient = {
9
+ agents: {
10
+ list: () => Promise<CompanionAgentProfile[]>;
11
+ resolveAvatarUrl: (agentId: string) => string;
12
+ };
13
+ sessions: {
14
+ list: () => Promise<{ sessions: CompanionSessionSummary[] }>;
15
+ subscribe: (
16
+ handler: (event: unknown) => void,
17
+ options: { reconnectDelayMs?: number; onError?: (error: unknown) => void }
18
+ ) => { close: () => void };
19
+ };
20
+ };
21
+
22
+ export class CompanionRuntimeClientService {
23
+ private client: CompanionSdkClient | null = null;
24
+ private viewService: CompanionSessionViewService | null = null;
25
+ private refreshTimer: ReturnType<typeof setInterval> | null = null;
26
+ private subscription: { close: () => void } | null = null;
27
+
28
+ constructor(private readonly baseUrl: string) {}
29
+
30
+ readonly start = async (onView: (view: CompanionAvatarView) => void): Promise<void> => {
31
+ await this.ensureClient();
32
+ await this.refresh(onView);
33
+ const client = await this.ensureClient();
34
+ this.subscription = client.sessions.subscribe(
35
+ async () => {
36
+ await this.refresh(onView);
37
+ },
38
+ {
39
+ reconnectDelayMs: 1000,
40
+ onError: async () => {
41
+ await this.refresh(onView);
42
+ }
43
+ }
44
+ );
45
+ this.refreshTimer = setInterval(() => {
46
+ void this.refresh(onView);
47
+ }, 10000);
48
+ };
49
+
50
+ readonly stop = (): void => {
51
+ this.subscription?.close();
52
+ this.subscription = null;
53
+ if (this.refreshTimer !== null) {
54
+ clearInterval(this.refreshTimer);
55
+ this.refreshTimer = null;
56
+ }
57
+ };
58
+
59
+ private readonly refresh = async (onView: (view: CompanionAvatarView) => void): Promise<void> => {
60
+ try {
61
+ const client = await this.ensureClient();
62
+ const [agents, sessions] = await Promise.all([
63
+ client.agents.list(),
64
+ client.sessions.list()
65
+ ]);
66
+ onView(
67
+ this.ensureViewService(client).selectView({
68
+ agents,
69
+ sessions: sessions.sessions
70
+ })
71
+ );
72
+ } catch (error) {
73
+ console.error("[companion] refresh failed", error);
74
+ onView(
75
+ this.createOfflineView(
76
+ error instanceof Error
77
+ ? { summary: this.summarizeOfflineError(error.message) }
78
+ : undefined
79
+ )
80
+ );
81
+ }
82
+ };
83
+
84
+ private readonly ensureClient = async (): Promise<CompanionSdkClient> => {
85
+ if (this.client) {
86
+ return this.client;
87
+ }
88
+ const sdkModule = await import("@nextclaw/client-sdk");
89
+ this.client = sdkModule.createNextClawClient({ baseUrl: this.baseUrl }) as CompanionSdkClient;
90
+ return this.client;
91
+ };
92
+
93
+ private readonly ensureViewService = (client: CompanionSdkClient): CompanionSessionViewService => {
94
+ if (this.viewService) {
95
+ return this.viewService;
96
+ }
97
+ this.viewService = new CompanionSessionViewService(this.baseUrl, client.agents.resolveAvatarUrl);
98
+ return this.viewService;
99
+ };
100
+
101
+ private readonly createOfflineView = (reason?: { summary: string }): CompanionAvatarView => {
102
+ const viewService =
103
+ this.viewService ??
104
+ new CompanionSessionViewService(this.baseUrl, (agentId) => `${this.baseUrl}/api/agents/${encodeURIComponent(agentId)}/avatar`);
105
+ return viewService.createOfflineView(reason);
106
+ };
107
+
108
+ private readonly summarizeOfflineError = (message: string): string => {
109
+ if (/fetch failed/i.test(message)) {
110
+ return "Cannot reach runtime";
111
+ }
112
+ if (/timed out/i.test(message)) {
113
+ return "Runtime timeout";
114
+ }
115
+ const trimmed = message.trim();
116
+ return trimmed.length > 28 ? `${trimmed.slice(0, 25)}...` : trimmed || "Runtime unavailable";
117
+ };
118
+ }
@@ -0,0 +1,63 @@
1
+ import type {
2
+ CompanionAgentProfile,
3
+ CompanionAvatarView,
4
+ CompanionOfflineReason,
5
+ CompanionSessionViewInput
6
+ } from "../types/companion.types.js";
7
+
8
+ export class CompanionSessionViewService {
9
+ constructor(
10
+ private readonly baseUrl: string,
11
+ private readonly resolveAvatarUrl: (agentId: string) => string
12
+ ) {}
13
+
14
+ readonly selectView = (input: CompanionSessionViewInput): CompanionAvatarView => {
15
+ const runningSession = [...input.sessions]
16
+ .filter((session) => session.status === "running")
17
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
18
+
19
+ if (!runningSession) {
20
+ return {
21
+ state: "idle",
22
+ title: "NextClaw",
23
+ subtitle: "No active agent",
24
+ openUrl: this.baseUrl
25
+ };
26
+ }
27
+
28
+ const agent = this.findAgent(input.agents, runningSession.agentId);
29
+
30
+ return {
31
+ state: "running",
32
+ title: agent?.displayName?.trim() || runningSession.agentId || "Active Agent",
33
+ subtitle: runningSession.sessionId,
34
+ avatarUrl: runningSession.agentId ? this.resolveAvatar(agent, runningSession.agentId) : undefined,
35
+ sessionId: runningSession.sessionId,
36
+ agentId: runningSession.agentId,
37
+ openUrl: this.baseUrl
38
+ };
39
+ };
40
+
41
+ readonly createOfflineView = (reason?: CompanionOfflineReason): CompanionAvatarView => {
42
+ return {
43
+ state: "offline",
44
+ title: "NextClaw",
45
+ subtitle: reason?.summary?.trim() || "Runtime unavailable",
46
+ openUrl: this.baseUrl
47
+ };
48
+ };
49
+
50
+ private readonly findAgent = (
51
+ agents: CompanionAgentProfile[],
52
+ agentId: string | undefined
53
+ ): CompanionAgentProfile | null => {
54
+ if (!agentId) {
55
+ return null;
56
+ }
57
+ return agents.find((agent) => agent.id === agentId) ?? null;
58
+ };
59
+
60
+ private readonly resolveAvatar = (agent: CompanionAgentProfile | null, agentId: string): string => {
61
+ return agent?.avatarUrl?.trim() || this.resolveAvatarUrl(agentId);
62
+ };
63
+ }
@@ -0,0 +1,44 @@
1
+ import { Menu, Tray, nativeImage, shell } from "electron";
2
+
3
+ export class CompanionTrayService {
4
+ private tray: Tray | null = null;
5
+
6
+ constructor(
7
+ private readonly baseUrl: string,
8
+ private readonly onToggleWindow: () => void,
9
+ private readonly onQuit: () => void
10
+ ) {}
11
+
12
+ readonly create = (): void => {
13
+ if (this.tray) {
14
+ return;
15
+ }
16
+
17
+ this.tray = new Tray(this.createTrayIcon());
18
+ this.tray.setToolTip("NextClaw Companion");
19
+ this.tray.on("click", this.onToggleWindow);
20
+ this.tray.setContextMenu(
21
+ Menu.buildFromTemplate([
22
+ { label: "Show Companion", click: this.onToggleWindow },
23
+ { label: "Open NextClaw", click: () => void shell.openExternal(this.baseUrl) },
24
+ { type: "separator" },
25
+ { label: "Quit", click: this.onQuit }
26
+ ])
27
+ );
28
+ };
29
+
30
+ readonly destroy = (): void => {
31
+ this.tray?.destroy();
32
+ this.tray = null;
33
+ };
34
+
35
+ private readonly createTrayIcon = () => {
36
+ const svg = encodeURIComponent(
37
+ `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
38
+ <rect x="1" y="1" width="18" height="18" rx="6" fill="#16324f"/>
39
+ <circle cx="10" cy="10" r="4" fill="#f5f8fb"/>
40
+ </svg>`
41
+ );
42
+ return nativeImage.createFromDataURL(`data:image/svg+xml;charset=utf-8,${svg}`);
43
+ };
44
+ }