@nextclaw/companion 0.1.1-beta.0 → 0.1.1-beta.2

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 (64) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/{src/launcher/index.js → electron/launcher.js} +2 -2
  3. package/dist/{src → electron}/main.js +3 -3
  4. package/dist/{src/preload/index.js → electron/preload.js} +1 -6
  5. package/dist/{src → electron}/services/companion-application.service.js +14 -16
  6. package/dist/{src/stores/companion-runtime-state.store.js → electron/services/companion-runtime-state-persistence.service.js} +3 -3
  7. package/dist/{src/stores/companion-window-position.store.js → electron/services/companion-window-position-persistence.service.js} +4 -4
  8. package/dist/{src → electron}/services/companion-window.service.js +15 -26
  9. package/dist/electron/types/companion-shell.types.js +2 -0
  10. package/dist/src/app/app.js +17 -0
  11. package/dist/src/app/components/companion-shell.container.js +20 -0
  12. package/dist/src/app/main.js +10 -0
  13. package/dist/src/app/providers/companion-presenter.provider.js +17 -0
  14. package/dist/src/managers/companion-runtime.manager.js +152 -0
  15. package/dist/src/managers/companion-runtime.manager.test.js +45 -0
  16. package/dist/src/managers/companion-shell.manager.js +22 -0
  17. package/dist/src/presenters/companion.presenter.js +37 -0
  18. package/dist/src/services/companion-runtime-client.service.js +16 -68
  19. package/dist/src/stores/companion-runtime.store.js +27 -0
  20. package/dist/src/stores/companion-shell.store.js +18 -0
  21. package/dist/ui/assets/dist-D4AJsX_N.js +1 -0
  22. package/dist/ui/assets/index-BHBNwOpX.js +8 -0
  23. package/dist/ui/assets/index-DPctoCcE.css +1 -0
  24. package/dist/ui/index.html +13 -0
  25. package/{src/launcher/index.ts → electron/launcher.ts} +2 -2
  26. package/{src → electron}/main.ts +4 -3
  27. package/electron/preload.ts +7 -0
  28. package/{src → electron}/services/companion-application.service.ts +16 -20
  29. package/{src/stores/companion-runtime-state.store.ts → electron/services/companion-runtime-state-persistence.service.ts} +1 -1
  30. package/{src/stores/companion-window-position.store.ts → electron/services/companion-window-position-persistence.service.ts} +3 -3
  31. package/{src → electron}/services/companion-window.service.ts +23 -30
  32. package/electron/types/companion-shell.types.ts +8 -0
  33. package/index.html +12 -0
  34. package/module-structure.config.json +25 -0
  35. package/package.json +21 -9
  36. package/postcss.config.mjs +6 -0
  37. package/scripts/smoke.mjs +4 -3
  38. package/src/app/app.tsx +17 -0
  39. package/src/app/components/companion-shell.container.tsx +67 -0
  40. package/src/app/index.css +37 -0
  41. package/src/app/main.tsx +15 -0
  42. package/src/app/providers/companion-presenter.provider.tsx +27 -0
  43. package/src/managers/companion-runtime.manager.test.ts +56 -0
  44. package/src/managers/companion-runtime.manager.ts +191 -0
  45. package/src/managers/companion-shell.manager.ts +21 -0
  46. package/src/module-structure.config.json +22 -0
  47. package/src/presenters/companion.presenter.ts +36 -0
  48. package/src/services/companion-runtime-client.service.ts +21 -84
  49. package/src/stores/companion-runtime.store.ts +51 -0
  50. package/src/stores/companion-shell.store.ts +27 -0
  51. package/src/types/companion-bridge.types.d.ts +11 -0
  52. package/src/types/companion.types.ts +0 -10
  53. package/tailwind.config.mjs +32 -0
  54. package/tsconfig.json +2 -1
  55. package/vite.config.mts +16 -0
  56. package/dist/src/companion-session-view.service.test.js +0 -27
  57. package/dist/src/services/companion-session-view.service.js +0 -52
  58. package/dist/src/utils/companion-renderer-html.utils.js +0 -194
  59. package/src/companion-session-view.service.test.ts +0 -37
  60. package/src/preload/index.ts +0 -13
  61. package/src/services/companion-session-view.service.ts +0 -63
  62. package/src/utils/companion-renderer-html.utils.ts +0 -192
  63. /package/dist/{src → electron}/services/companion-tray.service.js +0 -0
  64. /package/{src → electron}/services/companion-tray.service.ts +0 -0
@@ -5,12 +5,12 @@ import { resolve } from "node:path";
5
5
 
6
6
  const loadModule = createRequire(__filename);
7
7
  const electronBinary = loadModule("electron") as string;
8
- const appRoot = resolve(__dirname, "..", "..", "..");
8
+ const mainEntryPath = resolve(__dirname, "main.js");
9
9
  const env = { ...process.env };
10
10
 
11
11
  delete env.ELECTRON_RUN_AS_NODE;
12
12
 
13
- const child = spawn(electronBinary, [appRoot, ...process.argv.slice(2)], {
13
+ const child = spawn(electronBinary, [mainEntryPath, ...process.argv.slice(2)], {
14
14
  stdio: "inherit",
15
15
  env
16
16
  });
@@ -1,3 +1,4 @@
1
+ import type { CompanionAppOptions } from "./types/companion-shell.types.js";
1
2
  import { CompanionApplicationService } from "./services/companion-application.service.js";
2
3
 
3
4
  function resolveBaseUrl(argv: string[]): string {
@@ -13,11 +14,11 @@ function resolveBaseUrl(argv: string[]): string {
13
14
  }
14
15
 
15
16
  async function main(): Promise<void> {
16
- const application = new CompanionApplicationService({
17
+ const options: CompanionAppOptions = {
17
18
  baseUrl: resolveBaseUrl(process.argv.slice(1)),
18
19
  runtimeStatePath: process.env.NEXTCLAW_COMPANION_RUNTIME_STATE_PATH?.trim() || undefined
19
- });
20
- await application.run();
20
+ };
21
+ await new CompanionApplicationService(options).run();
21
22
  }
22
23
 
23
24
  void main();
@@ -0,0 +1,7 @@
1
+ import { contextBridge, ipcRenderer } from "electron";
2
+
3
+ contextBridge.exposeInMainWorld("nextclawCompanion", {
4
+ open: () => ipcRenderer.invoke("companion:open"),
5
+ quit: () => ipcRenderer.invoke("companion:quit"),
6
+ getBootstrap: () => ipcRenderer.invoke("companion:get-bootstrap")
7
+ });
@@ -1,29 +1,29 @@
1
1
  import { app } from "electron";
2
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";
3
+
4
+ import type { CompanionAppOptions } from "../types/companion-shell.types.js";
5
+ import { CompanionRuntimeStatePersistenceService } from "./companion-runtime-state-persistence.service.js";
7
6
  import { CompanionTrayService } from "./companion-tray.service.js";
7
+ import { CompanionWindowPositionPersistenceService } from "./companion-window-position-persistence.service.js";
8
8
  import { CompanionWindowService } from "./companion-window.service.js";
9
9
 
10
10
  export class CompanionApplicationService {
11
- private readonly runtimeClient: CompanionRuntimeClientService;
12
- private readonly runtimeStateStore: CompanionRuntimeStateStore | null;
11
+ private readonly runtimeStatePersistenceService: CompanionRuntimeStatePersistenceService | null;
13
12
  private readonly windowService: CompanionWindowService;
14
13
  private readonly trayService: CompanionTrayService;
15
14
  private quitting = false;
16
15
 
17
16
  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)
17
+ this.runtimeStatePersistenceService = options.runtimeStatePath
18
+ ? new CompanionRuntimeStatePersistenceService(options.runtimeStatePath)
22
19
  : null;
23
- this.windowService = new CompanionWindowService(
24
- preloadPath,
25
- CompanionWindowPositionStore.fromUserData(app.getPath("userData"))
26
- );
20
+ this.windowService = new CompanionWindowService({
21
+ preloadPath: resolve(__dirname, "..", "preload.js"),
22
+ rendererEntryPath: resolve(__dirname, "..", "..", "ui", "index.html"),
23
+ positionPersistenceService: CompanionWindowPositionPersistenceService.fromUserData(app.getPath("userData")),
24
+ baseUrl: options.baseUrl,
25
+ onQuit: () => this.quit()
26
+ });
27
27
  this.trayService = new CompanionTrayService(
28
28
  options.baseUrl,
29
29
  () => this.windowService.toggleVisibility(),
@@ -43,17 +43,16 @@ export class CompanionApplicationService {
43
43
  app.on("window-all-closed", () => undefined);
44
44
  app.on("before-quit", () => {
45
45
  this.quitting = true;
46
- this.runtimeClient.stop();
47
46
  this.trayService.destroy();
48
47
  this.windowService.destroy();
49
- this.runtimeStateStore?.clear();
48
+ this.runtimeStatePersistenceService?.clear();
50
49
  });
51
50
  app.on("activate", () => {
52
51
  this.windowService.show();
53
52
  });
54
53
 
55
54
  await app.whenReady();
56
- this.runtimeStateStore?.write({
55
+ this.runtimeStatePersistenceService?.write({
57
56
  pid: process.pid,
58
57
  startedAt: new Date().toISOString(),
59
58
  baseUrl: this.options.baseUrl
@@ -61,9 +60,6 @@ export class CompanionApplicationService {
61
60
  await this.windowService.create();
62
61
  this.windowService.show();
63
62
  this.trayService.create();
64
- await this.runtimeClient.start((view) => {
65
- this.windowService.updateView(view);
66
- });
67
63
  };
68
64
 
69
65
  readonly quit = (): void => {
@@ -7,7 +7,7 @@ type CompanionRuntimeState = {
7
7
  baseUrl: string;
8
8
  };
9
9
 
10
- export class CompanionRuntimeStateStore {
10
+ export class CompanionRuntimeStatePersistenceService {
11
11
  constructor(private readonly filePath: string) {}
12
12
 
13
13
  readonly write = (state: CompanionRuntimeState): void => {
@@ -6,7 +6,7 @@ type WindowBounds = {
6
6
  y?: number;
7
7
  };
8
8
 
9
- export class CompanionWindowPositionStore {
9
+ export class CompanionWindowPositionPersistenceService {
10
10
  constructor(private readonly filePath: string) {}
11
11
 
12
12
  readonly read = (): WindowBounds | null => {
@@ -25,7 +25,7 @@ export class CompanionWindowPositionStore {
25
25
  writeFileSync(this.filePath, JSON.stringify(bounds, null, 2));
26
26
  };
27
27
 
28
- static readonly fromUserData = (userDataPath: string): CompanionWindowPositionStore => {
29
- return new CompanionWindowPositionStore(resolve(userDataPath, "companion-window.json"));
28
+ static readonly fromUserData = (userDataPath: string): CompanionWindowPositionPersistenceService => {
29
+ return new CompanionWindowPositionPersistenceService(resolve(userDataPath, "companion-window.json"));
30
30
  };
31
31
  }
@@ -1,23 +1,25 @@
1
- import { app, BrowserWindow, ipcMain, shell } from "electron";
2
- import type { CompanionAvatarView } from "../types/companion.types.js";
3
- import type { CompanionWindowPositionStore } from "../stores/companion-window-position.store.js";
4
- import { renderCompanionHtml } from "../utils/companion-renderer-html.utils.js";
1
+ import { BrowserWindow, ipcMain, shell } from "electron";
2
+
3
+ import type { CompanionShellBootstrap } from "../types/companion-shell.types.js";
4
+ import type { CompanionWindowPositionPersistenceService } from "./companion-window-position-persistence.service.js";
5
5
 
6
6
  export class CompanionWindowService {
7
7
  private window: BrowserWindow | null = null;
8
- private currentView: CompanionAvatarView | null = null;
9
8
 
10
- constructor(
11
- private readonly preloadPath: string,
12
- private readonly positionStore: CompanionWindowPositionStore
13
- ) {}
9
+ constructor(private readonly options: {
10
+ preloadPath: string;
11
+ rendererEntryPath: string;
12
+ positionPersistenceService: CompanionWindowPositionPersistenceService;
13
+ baseUrl: string;
14
+ onQuit: () => void;
15
+ }) {}
14
16
 
15
17
  readonly create = async (): Promise<void> => {
16
18
  if (this.window) {
17
19
  return;
18
20
  }
19
21
 
20
- const bounds = this.positionStore.read();
22
+ const bounds = this.options.positionPersistenceService.read();
21
23
  this.window = new BrowserWindow({
22
24
  width: 112,
23
25
  height: 132,
@@ -31,7 +33,7 @@ export class CompanionWindowService {
31
33
  skipTaskbar: true,
32
34
  hasShadow: false,
33
35
  webPreferences: {
34
- preload: this.preloadPath,
36
+ preload: this.options.preloadPath,
35
37
  contextIsolation: true,
36
38
  nodeIntegration: false
37
39
  }
@@ -52,26 +54,22 @@ export class CompanionWindowService {
52
54
 
53
55
  ipcMain.removeHandler("companion:open");
54
56
  ipcMain.handle("companion:open", async () => {
55
- const openUrl = this.currentView?.openUrl;
56
- if (openUrl) {
57
- await shell.openExternal(openUrl);
58
- }
57
+ await shell.openExternal(this.options.baseUrl);
59
58
  return null;
60
59
  });
61
60
  ipcMain.removeHandler("companion:quit");
62
61
  ipcMain.handle("companion:quit", async () => {
63
- app.quit();
62
+ this.options.onQuit();
64
63
  return null;
65
64
  });
66
-
67
- ipcMain.removeAllListeners("companion:ready");
68
- ipcMain.on("companion:ready", () => {
69
- if (this.currentView) {
70
- this.window?.webContents.send("companion:view", this.currentView);
71
- }
65
+ ipcMain.removeHandler("companion:get-bootstrap");
66
+ ipcMain.handle("companion:get-bootstrap", async (): Promise<CompanionShellBootstrap> => {
67
+ return {
68
+ baseUrl: this.options.baseUrl
69
+ };
72
70
  });
73
71
 
74
- await this.window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(renderCompanionHtml())}`);
72
+ await this.window.loadFile(this.options.rendererEntryPath);
75
73
  };
76
74
 
77
75
  readonly show = (): void => {
@@ -89,15 +87,10 @@ export class CompanionWindowService {
89
87
  this.window.showInactive();
90
88
  };
91
89
 
92
- readonly updateView = (view: CompanionAvatarView): void => {
93
- this.currentView = view;
94
- this.window?.webContents.send("companion:view", view);
95
- };
96
-
97
90
  readonly destroy = (): void => {
98
91
  ipcMain.removeHandler("companion:open");
99
92
  ipcMain.removeHandler("companion:quit");
100
- ipcMain.removeAllListeners("companion:ready");
93
+ ipcMain.removeHandler("companion:get-bootstrap");
101
94
  this.window?.destroy();
102
95
  this.window = null;
103
96
  };
@@ -107,7 +100,7 @@ export class CompanionWindowService {
107
100
  if (!bounds) {
108
101
  return;
109
102
  }
110
- this.positionStore.write({
103
+ this.options.positionPersistenceService.write({
111
104
  x: bounds.x,
112
105
  y: bounds.y
113
106
  });
@@ -0,0 +1,8 @@
1
+ export type CompanionAppOptions = {
2
+ baseUrl: string;
3
+ runtimeStatePath?: string;
4
+ };
5
+
6
+ export type CompanionShellBootstrap = {
7
+ baseUrl: string;
8
+ };
package/index.html ADDED
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>NextClaw Companion</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/app/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,25 @@
1
+ {
2
+ "contractKind": "legacy",
3
+ "organizationModel": "legacy-apps-companion-workspace-root",
4
+ "rootPolicy": "contract-only",
5
+ "enforcement": "error",
6
+ "allowedRootDirectories": [
7
+ "electron",
8
+ "scripts",
9
+ "src"
10
+ ],
11
+ "requiredRootDirectories": [
12
+ "electron",
13
+ "src"
14
+ ],
15
+ "allowedRootFiles": [
16
+ "CHANGELOG.md",
17
+ "index.html",
18
+ "module-structure.config.json",
19
+ "package.json",
20
+ "postcss.config.mjs",
21
+ "tailwind.config.mjs",
22
+ "tsconfig.json",
23
+ "vite.config.mts"
24
+ ]
25
+ }
package/package.json CHANGED
@@ -1,31 +1,43 @@
1
1
  {
2
2
  "name": "@nextclaw/companion",
3
- "version": "0.1.1-beta.0",
3
+ "version": "0.1.1-beta.2",
4
4
  "private": false,
5
5
  "description": "Standalone Electron companion shell for active NextClaw agents.",
6
6
  "author": "NextClaw Team",
7
7
  "homepage": "https://github.com/Peiiii/nextclaw",
8
- "main": "dist/src/main.js",
8
+ "main": "dist/electron/main.js",
9
9
  "bin": {
10
- "nextclaw-companion": "dist/src/launcher/index.js"
10
+ "nextclaw-companion": "dist/electron/launcher.js"
11
11
  },
12
12
  "license": "MIT",
13
13
  "dependencies": {
14
14
  "electron": "^32.2.1",
15
- "@nextclaw/client-sdk": "0.1.1-beta.0"
15
+ "lucide-react": "^0.462.0",
16
+ "react": "^18.3.1",
17
+ "react-dom": "^18.3.1",
18
+ "zustand": "^5.0.2",
19
+ "@nextclaw/client-sdk": "0.1.1-beta.2"
16
20
  },
17
21
  "devDependencies": {
22
+ "@types/react": "^18.3.12",
23
+ "@types/react-dom": "^18.3.1",
24
+ "@vitejs/plugin-react": "^6.0.1",
25
+ "autoprefixer": "^10.4.20",
18
26
  "@types/node": "^20.17.6",
27
+ "postcss": "^8.4.49",
28
+ "tailwindcss": "^3.4.15",
19
29
  "typescript": "^5.6.3",
30
+ "vite": "^8.0.3",
20
31
  "vitest": "^4.1.2"
21
32
  },
22
33
  "scripts": {
23
- "dev": "pnpm build:main && node dist/src/launcher/index.js",
24
- "build": "pnpm build:main",
34
+ "dev": "pnpm build && node dist/electron/launcher.js",
35
+ "build": "pnpm build:main && pnpm build:renderer",
25
36
  "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\"",
37
+ "build:renderer": "vite build --config vite.config.mts",
38
+ "lint": "eslint \"electron/**/*.ts\" \"src/**/*.{ts,tsx}\" vite.config.mts",
27
39
  "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"
40
+ "test": "vitest run src/managers/companion-runtime.manager.test.ts",
41
+ "smoke": "pnpm build && node scripts/smoke.mjs"
30
42
  }
31
43
  }
@@ -0,0 +1,6 @@
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {}
5
+ }
6
+ };
package/scripts/smoke.mjs CHANGED
@@ -2,9 +2,10 @@ import { existsSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
 
4
4
  const requiredFiles = [
5
- resolve("dist/src/main.js"),
6
- resolve("dist/src/launcher/index.js"),
7
- resolve("dist/src/preload/index.js")
5
+ resolve("dist/electron/main.js"),
6
+ resolve("dist/electron/launcher.js"),
7
+ resolve("dist/electron/preload.js"),
8
+ resolve("dist/ui/index.html")
8
9
  ];
9
10
 
10
11
  const missingFiles = requiredFiles.filter((filePath) => !existsSync(filePath));
@@ -0,0 +1,17 @@
1
+ import { useEffect } from "react";
2
+
3
+ import { CompanionShellContainer } from "./components/companion-shell.container.js";
4
+ import { usePresenter } from "./providers/companion-presenter.provider.js";
5
+
6
+ export function App() {
7
+ const presenter = usePresenter();
8
+
9
+ useEffect(() => {
10
+ void presenter.bootstrap();
11
+ return () => {
12
+ presenter.shutdown();
13
+ };
14
+ }, [presenter]);
15
+
16
+ return <CompanionShellContainer />;
17
+ }
@@ -0,0 +1,67 @@
1
+ import { ExternalLink, X } from "lucide-react";
2
+
3
+ import { useCompanionRuntimeStore } from "../../stores/companion-runtime.store.js";
4
+ import { useCompanionShellStore } from "../../stores/companion-shell.store.js";
5
+ import { usePresenter } from "../providers/companion-presenter.provider.js";
6
+
7
+ export function CompanionShellContainer() {
8
+ const presenter = usePresenter();
9
+ const view = useCompanionRuntimeStore((state) => state.snapshot.currentView);
10
+ const connectionState = useCompanionRuntimeStore((state) => state.snapshot.connectionState);
11
+ const bootstrapped = useCompanionShellStore((state) => state.snapshot.bootstrapped);
12
+
13
+ const statusColorClass = connectionState === "running"
14
+ ? "bg-success"
15
+ : connectionState === "offline"
16
+ ? "bg-danger"
17
+ : "bg-slate-300";
18
+
19
+ return (
20
+ <div className="h-screen w-screen overflow-hidden bg-transparent">
21
+ <div className="grid h-full place-items-center">
22
+ <div className="grid w-[112px] justify-items-center gap-2">
23
+ <div className="drag-region relative grid h-24 w-24 place-items-center overflow-hidden rounded-[28px] border border-white/70 bg-white/90 shadow-companion backdrop-blur-xl">
24
+ <div className="no-drag absolute right-1.5 top-1.5 z-20 flex gap-1">
25
+ <button
26
+ type="button"
27
+ aria-label="Open NextClaw"
28
+ onClick={() => void presenter.companionShellManager.open()}
29
+ className="grid h-5 w-5 place-items-center rounded-full bg-slate-900/10 text-slate-700 transition hover:bg-slate-900/15"
30
+ >
31
+ <ExternalLink className="h-3 w-3" />
32
+ </button>
33
+ <button
34
+ type="button"
35
+ aria-label="Quit Companion"
36
+ onClick={() => void presenter.companionShellManager.quit()}
37
+ className="grid h-5 w-5 place-items-center rounded-full bg-slate-900/10 text-slate-700 transition hover:bg-slate-900/15"
38
+ >
39
+ <X className="h-3 w-3" />
40
+ </button>
41
+ </div>
42
+ {view.avatarUrl ? (
43
+ <img src={view.avatarUrl} alt="" className="h-full w-full object-cover" />
44
+ ) : (
45
+ <span className="grid h-full w-full place-items-center bg-gradient-to-b from-white to-slate-200 text-[28px] font-bold text-slate-800">
46
+ {(view.title || "NC").slice(0, 2).toUpperCase()}
47
+ </span>
48
+ )}
49
+ <span className={`absolute bottom-2 right-2 h-3.5 w-3.5 rounded-full border-2 border-white ${statusColorClass}`} />
50
+ </div>
51
+ <button
52
+ type="button"
53
+ onClick={() => void presenter.companionShellManager.open()}
54
+ className="no-drag w-full rounded-xl px-1 text-center"
55
+ >
56
+ <div className="truncate text-[12px] font-semibold leading-4 text-slate-900">
57
+ {bootstrapped ? view.title : "NextClaw"}
58
+ </div>
59
+ <div className="truncate pt-0.5 text-[11px] leading-4 text-slate-500">
60
+ {bootstrapped ? view.subtitle : "Connecting"}
61
+ </div>
62
+ </button>
63
+ </div>
64
+ </div>
65
+ </div>
66
+ );
67
+ }
@@ -0,0 +1,37 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ * {
7
+ box-sizing: border-box;
8
+ }
9
+
10
+ html,
11
+ body,
12
+ #root {
13
+ margin: 0;
14
+ width: 100%;
15
+ height: 100%;
16
+ overflow: hidden;
17
+ background: transparent;
18
+ color: rgb(15 23 42);
19
+ font-family: "SF Pro Display", "Inter", "Helvetica Neue", sans-serif;
20
+ -webkit-font-smoothing: antialiased;
21
+ -moz-osx-font-smoothing: grayscale;
22
+ }
23
+
24
+ button {
25
+ font: inherit;
26
+ }
27
+ }
28
+
29
+ @layer utilities {
30
+ .drag-region {
31
+ -webkit-app-region: drag;
32
+ }
33
+
34
+ .no-drag {
35
+ -webkit-app-region: no-drag;
36
+ }
37
+ }
@@ -0,0 +1,15 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+
4
+ import { App } from "./app.js";
5
+ import { CompanionPresenterProvider } from "./providers/companion-presenter.provider.js";
6
+ import { companionPresenter } from "../presenters/companion.presenter.js";
7
+ import "./index.css";
8
+
9
+ createRoot(document.getElementById("root") as HTMLElement).render(
10
+ <StrictMode>
11
+ <CompanionPresenterProvider presenter={companionPresenter}>
12
+ <App />
13
+ </CompanionPresenterProvider>
14
+ </StrictMode>
15
+ );
@@ -0,0 +1,27 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+
3
+ import type { CompanionPresenter } from "../../presenters/companion.presenter.js";
4
+
5
+ const CompanionPresenterContext = createContext<CompanionPresenter | null>(null);
6
+
7
+ export function CompanionPresenterProvider({
8
+ presenter,
9
+ children
10
+ }: {
11
+ presenter: CompanionPresenter;
12
+ children: ReactNode;
13
+ }) {
14
+ return (
15
+ <CompanionPresenterContext.Provider value={presenter}>
16
+ {children}
17
+ </CompanionPresenterContext.Provider>
18
+ );
19
+ }
20
+
21
+ export function usePresenter(): CompanionPresenter {
22
+ const presenter = useContext(CompanionPresenterContext);
23
+ if (!presenter) {
24
+ throw new Error("usePresenter must be used inside CompanionPresenterProvider");
25
+ }
26
+ return presenter;
27
+ }
@@ -0,0 +1,56 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+
3
+ import { useCompanionRuntimeStore } from "../stores/companion-runtime.store.js";
4
+ import { CompanionRuntimeManager } from "./companion-runtime.manager.js";
5
+
6
+ describe("CompanionRuntimeManager", () => {
7
+ beforeEach(() => {
8
+ useCompanionRuntimeStore.getState().reset();
9
+ });
10
+
11
+ it("prefers the most recently updated running session", () => {
12
+ const manager = new CompanionRuntimeManager();
13
+ const baseUrl = "http://127.0.0.1:55667";
14
+
15
+ (manager as unknown as { baseUrl: string | null }).baseUrl = baseUrl;
16
+
17
+ const snapshot = manager.syncRuntimeSnapshot({
18
+ agents: [{ id: "writer", displayName: "Writer" }],
19
+ sessions: [
20
+ { sessionId: "older", agentId: "writer", messageCount: 1, updatedAt: "2026-05-05T00:00:00.000Z", status: "running" },
21
+ { sessionId: "newer", agentId: "writer", messageCount: 2, updatedAt: "2026-05-06T00:00:00.000Z", status: "running" }
22
+ ]
23
+ });
24
+
25
+ expect(snapshot.currentView.sessionId).toBe("newer");
26
+ expect(snapshot.currentView.title).toBe("Writer");
27
+ expect(snapshot.connectionState).toBe("running");
28
+ });
29
+
30
+ it("falls back to an idle shell view when no running session exists", () => {
31
+ const manager = new CompanionRuntimeManager();
32
+ const baseUrl = "http://127.0.0.1:55667";
33
+
34
+ (manager as unknown as { baseUrl: string | null }).baseUrl = baseUrl;
35
+
36
+ const snapshot = manager.syncRuntimeSnapshot({
37
+ agents: [],
38
+ sessions: []
39
+ });
40
+
41
+ expect(snapshot.currentView.state).toBe("idle");
42
+ expect(snapshot.currentView.subtitle).toBe("No active agent");
43
+ expect(snapshot.connectionState).toBe("idle");
44
+ });
45
+
46
+ it("produces an offline snapshot with a readable reason", () => {
47
+ const manager = new CompanionRuntimeManager();
48
+ (manager as unknown as { baseUrl: string | null }).baseUrl = "http://127.0.0.1:55667";
49
+
50
+ const snapshot = manager.applyOfflineState({ summary: "Cannot reach runtime" });
51
+
52
+ expect(snapshot.connectionState).toBe("offline");
53
+ expect(snapshot.currentView.state).toBe("offline");
54
+ expect(snapshot.currentView.subtitle).toBe("Cannot reach runtime");
55
+ });
56
+ });