ompchamber 0.5.0 → 0.7.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 (89) hide show
  1. package/README.md +67 -6
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/static/css/{index.5291f58223.css → index.51f68210a5.css} +1 -1
  4. package/dist/client/static/js/async/{3y.ededeebde8.js → 3y.4c2596f64c.js} +3 -3
  5. package/dist/client/static/js/async/73.308dff4da8.js +1 -0
  6. package/dist/client/static/js/async/{ty.3d93cf5f5f.js → 7g.a7814350c3.js} +1 -1
  7. package/dist/client/static/js/async/a.bce4542392.js +1 -0
  8. package/dist/client/static/js/async/g.86e2256a70.js +2 -0
  9. package/dist/client/static/js/async/hn.b645041e00.js +1 -0
  10. package/dist/client/static/js/async/{jg.69a4beade8.js → jg.d86ebd4013.js} +1 -1
  11. package/dist/client/static/js/async/l.12dd34e1be.js +1 -0
  12. package/dist/client/static/js/async/ls.9d9a01026c.js +104 -0
  13. package/dist/client/static/js/async/mw.ca1512563c.js +10 -0
  14. package/dist/client/static/js/async/nk.fc7e58325e.js +1 -0
  15. package/dist/client/static/js/async/{q.7fb4d2f374.js → q.542c184fec.js} +3 -3
  16. package/dist/client/static/js/async/t.cda4891526.js +1 -0
  17. package/dist/client/static/js/async/tr.e878658140.js +309 -0
  18. package/dist/client/static/js/async/uf.24ca3d49c3.js +2 -0
  19. package/dist/client/static/js/index.eccad0b70a.js +33 -0
  20. package/dist/client/static/js/{uy.5f791144ce.js → uy.2e8929ed0c.js} +2 -2
  21. package/package.json +15 -5
  22. package/src/cli/lib/args.js +6 -6
  23. package/src/cli/lib/commands/logs.js +76 -34
  24. package/src/cli/lib/commands/restart.js +40 -8
  25. package/src/cli/lib/commands/serve.js +12 -4
  26. package/src/cli/lib/commands/status.js +40 -18
  27. package/src/cli/lib/commands/stop.js +12 -7
  28. package/src/cli/lib/commands/update.js +23 -12
  29. package/src/cli/lib/paths.js +8 -28
  30. package/src/cli/lib/runtime.js +121 -108
  31. package/src/client/components/common/code-editor/index.tsx +5 -1
  32. package/src/client/components/common/code-surface/index.tsx +49 -1
  33. package/src/client/components/common/code-surface/measure.ts +99 -0
  34. package/src/client/components/layout/RightActivityBar.tsx +42 -69
  35. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +5 -6
  36. package/src/client/components/layout/desktop-layout/index.tsx +11 -9
  37. package/src/client/components/layout/desktop-layout/resizer/index.tsx +22 -29
  38. package/src/client/components/layout/desktop-layout/resizer/utils.test.ts +33 -0
  39. package/src/client/components/layout/desktop-layout/resizer/utils.ts +34 -0
  40. package/src/client/components/mobile/RightSidebar.tsx +42 -119
  41. package/src/client/components/mobile/mobile-main-view/Header.tsx +17 -1
  42. package/src/client/components/workspace/editor/index.tsx +4 -1
  43. package/src/client/hooks/chat/omp/index.ts +4 -109
  44. package/src/client/hooks/chat/omp/prompt-send.ts +161 -0
  45. package/src/client/hooks/chat/timeline/send.ts +3 -2
  46. package/src/client/hooks/editor/use-file-editor.test.ts +158 -0
  47. package/src/client/hooks/editor/use-file-editor.ts +35 -19
  48. package/src/server/index.ts +52 -1
  49. package/src/server/lib/lifecycle/bun.ts +18 -0
  50. package/src/server/lib/lifecycle/identity.ts +108 -0
  51. package/src/server/lib/lifecycle/instance.ts +102 -0
  52. package/src/server/lib/lifecycle/launch-mode.test.ts +32 -0
  53. package/src/server/lib/lifecycle/launch-mode.ts +40 -0
  54. package/src/server/lib/lifecycle/occupant.ts +101 -0
  55. package/src/server/lib/lifecycle/paths.ts +50 -0
  56. package/src/server/lib/lifecycle/port-guard.ts +170 -0
  57. package/src/server/lib/lifecycle/probe.ts +121 -0
  58. package/src/server/lib/lifecycle/restart.test.ts +21 -0
  59. package/src/server/lib/lifecycle/restart.ts +111 -0
  60. package/src/server/lib/omp/rpc/session-commands.ts +15 -1
  61. package/src/server/lib/updates/apply.ts +8 -5
  62. package/src/server/lib/updates/install.ts +7 -4
  63. package/src/server/plugins/static.test.ts +63 -0
  64. package/src/server/plugins/static.ts +28 -4
  65. package/src/server/routes/health/probe.ts +9 -0
  66. package/src/shared/lib/markdown/mermaid.ts +8 -0
  67. package/src/shared/lib/omp/session/stream-state.server.ts +16 -1
  68. package/src/shared/lib/workspace/panel-widths.test.ts +5 -5
  69. package/src/shared/lib/workspace/panel-widths.ts +23 -10
  70. package/src/shared/lib/workspace/right-panels.ts +10 -4
  71. package/dist/client/static/js/async/73.50280ce58d.js +0 -1
  72. package/dist/client/static/js/async/a.e05da304c8.js +0 -10
  73. package/dist/client/static/js/async/ar.1e14da5588.js +0 -1
  74. package/dist/client/static/js/async/g.7a4bbd4798.js +0 -2
  75. package/dist/client/static/js/async/hn.7568bdb58b.js +0 -1
  76. package/dist/client/static/js/async/l.102a97fce6.js +0 -104
  77. package/dist/client/static/js/async/l6.c56640c205.js +0 -1
  78. package/dist/client/static/js/async/nk.6de011832e.js +0 -1
  79. package/dist/client/static/js/async/t.cb3fcf8615.js +0 -1
  80. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  81. package/dist/client/static/js/async/vn.e98e2f3b61.js +0 -309
  82. package/dist/client/static/js/index.0ac93ee878.js +0 -33
  83. /package/dist/client/static/js/async/{3y.ededeebde8.js.LICENSE.txt → 3y.4c2596f64c.js.LICENSE.txt} +0 -0
  84. /package/dist/client/static/js/async/{g.7a4bbd4798.js.LICENSE.txt → g.86e2256a70.js.LICENSE.txt} +0 -0
  85. /package/dist/client/static/js/async/{a.e05da304c8.js.LICENSE.txt → mw.ca1512563c.js.LICENSE.txt} +0 -0
  86. /package/dist/client/static/js/async/{q.7fb4d2f374.js.LICENSE.txt → q.542c184fec.js.LICENSE.txt} +0 -0
  87. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → uf.24ca3d49c3.js.LICENSE.txt} +0 -0
  88. /package/dist/client/static/js/{index.0ac93ee878.js.LICENSE.txt → index.eccad0b70a.js.LICENSE.txt} +0 -0
  89. /package/dist/client/static/js/{uy.5f791144ce.js.LICENSE.txt → uy.2e8929ed0c.js.LICENSE.txt} +0 -0
@@ -1,111 +1,96 @@
1
- // Runtime helpers for the OMPChamber CLI: registry persistence, process
2
- // liveness, server entry resolution, health probing and detached spawning.
3
- // Bun-native file APIs are used wherever Bun ships them; node:fs remains for
4
- // sync directory/metadata ops and 0o600 file creation (Bun.write ignores mode).
1
+ // Runtime helpers for the OMPChamber CLI: instance discovery, process identity,
2
+ // server entry resolution, health probing and detached spawning.
3
+ //
4
+ // The instance record is written by the server that owns the port (see
5
+ // src/server/lib/lifecycle/instance.ts), not by the CLI that spawned it, so a
6
+ // server started by `bun run dev` or by `--foreground` is as discoverable as a
7
+ // detached one. Discovery combines that record with a live `/api/health` probe:
8
+ // the probe covers servers started outside the CLI, the record covers servers
9
+ // that are alive but deaf (starting up, hung).
5
10
 
6
11
  import fs from 'node:fs';
7
12
 
8
- import { getLogFilePath, getRegistryPath, ensureDataDirs, getRunDir } from '@/cli/lib/paths.js';
13
+ import { ensureDataDirs, getLogFilePath } from '@/cli/lib/paths.js';
9
14
  import { killChildTree, STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
15
+ import { getProcessState, isProcessAlive } from '@/server/lib/lifecycle/identity';
16
+ import { launchModeArg } from '@/server/lib/lifecycle/launch-mode';
17
+ import { listInstanceRecords, readInstanceRecord, removeInstanceRecord } from '@/server/lib/lifecycle/instance';
18
+ import { fetchHealth, probeHost } from '@/server/lib/lifecycle/probe';
10
19
  import { joinPath, homeDir } from '@/cli/lib/path-utils.js';
11
20
 
12
21
  const HEALTH_INTERVAL_MS = 500;
13
22
  const HEALTH_TIMEOUT_MS = 30_000;
14
23
 
15
24
  /**
16
- * Read and parse the registry entry for `port`. Returns null on any error.
25
+ * CLI shape of a live instance, so records and probed servers print the same.
17
26
  */
18
- export async function readRegistry(port) {
19
- try {
20
- const parsed = await Bun.file(getRegistryPath(port)).json();
21
- return parsed && typeof parsed === 'object' ? parsed : null;
22
- } catch {
23
- return null;
24
- }
25
- }
26
-
27
- /**
28
- * Read every `<port>.json` registry entry in the run directory.
29
- */
30
- export async function listRegistries() {
31
- try {
32
- const dir = getRunDir();
33
- const entries = [];
34
- for (const file of await fs.promises.readdir(dir)) {
35
- if (!/^\d+\.json$/.test(file)) continue;
36
- try {
37
- const parsed = await Bun.file(joinPath(dir, file)).json();
38
- if (parsed && typeof parsed === 'object') entries.push(parsed);
39
- } catch {
40
- // Skip unreadable or corrupt registry files.
41
- }
42
- }
43
- return entries;
44
- } catch {
45
- return [];
46
- }
47
- }
48
-
49
- /**
50
- * Atomically write a registry entry (tmp file + rename) with owner-only perms.
51
- */
52
- export function writeRegistry(entry) {
53
- if (!entry || entry.port === undefined || entry.port === null) {
54
- throw new Error('writeRegistry requires an entry with a port');
55
- }
56
- ensureDataDirs();
57
- const target = getRegistryPath(entry.port);
58
- const tmp = `${target}.${process.pid}.tmp`;
59
- fs.writeFileSync(tmp, JSON.stringify(entry, null, 2), { mode: 0o600 });
60
- fs.renameSync(tmp, target);
61
- return target;
27
+ function toLiveInstance({ pid, port, host, mode, launchMode, startedAt, source }) {
28
+ const resolvedHost = host || 'localhost';
29
+ return {
30
+ pid,
31
+ port,
32
+ host: resolvedHost,
33
+ mode: mode ?? 'unknown',
34
+ launchMode: launchMode ?? 'unknown',
35
+ startedAt,
36
+ source: source ?? 'registry',
37
+ logFile: getLogFilePath(port),
38
+ url: `http://${probeHost(resolvedHost)}:${port}`,
39
+ };
62
40
  }
63
41
 
64
42
  /**
65
- * Remove a registry entry. Never throws.
43
+ * A recorded PID counts as the instance only while it is alive *and* still
44
+ * identifiable: a recycled PID must not resurrect a dead instance. `unknown`
45
+ * (identity unreadable on this platform) is accepted so Windows keeps working.
66
46
  */
67
- export async function removeRegistry(port) {
68
- try {
69
- await fs.promises.rm(getRegistryPath(port), { force: true });
70
- } catch {
71
- // Nothing to do: the registry is best-effort state.
72
- }
47
+ function isRecordLive(record) {
48
+ if (!record) return false;
49
+ return isProcessAlive(record.pid) && getProcessState(record.pid) !== 'mismatched';
73
50
  }
74
51
 
75
52
  /**
76
- * Liveness-only check for a PID. Never throws.
53
+ * Every recorded instance whose process is still alive. Records left behind by
54
+ * a killed server (SIGKILL skips its cleanup handler) are pruned here, so a
55
+ * recycled PID can never be reported as a running instance.
77
56
  */
78
- export function isProcessAlive(pid) {
79
- try {
80
- process.kill(pid, 0);
81
- return true;
82
- } catch {
83
- return false;
57
+ export async function listLiveInstances() {
58
+ const live = [];
59
+ for (const record of listInstanceRecords()) {
60
+ if (isRecordLive(record)) live.push(toLiveInstance(record));
61
+ else removeInstanceRecord(record.port);
84
62
  }
63
+ // Directory order is filesystem-dependent; a port-ordered report is stable
64
+ // across runs and keeps multi-instance output readable.
65
+ live.sort((a, b) => a.port - b.port);
66
+ return live;
85
67
  }
86
68
 
87
69
  /**
88
- * Registry entry whose recorded PID is alive. With no port, returns the first
89
- * live instance; otherwise null.
70
+ * The live instance on `port`, or the first live instance when no port is
71
+ * given. Falls back to a health probe for servers that never wrote a record.
90
72
  */
91
73
  export async function findLiveInstance(port) {
92
74
  if (port === null || port === undefined) {
93
- const all = await listRegistries();
94
- return all.find((entry) => entry && isProcessAlive(Number(entry.pid))) ?? null;
75
+ const [live] = await listLiveInstances();
76
+ return live ?? null;
95
77
  }
96
- const entry = await readRegistry(port);
97
- if (entry && isProcessAlive(Number(entry.pid))) return entry;
98
- return null;
99
- }
100
78
 
101
- /**
102
- * Map a bind host to an address that is actually reachable for probing.
103
- */
104
- export function probeHost(host) {
105
- if (host === '0.0.0.0' || host === '::' || host === '' || host === null || host === undefined) {
106
- return '127.0.0.1';
107
- }
108
- return host;
79
+ const record = readInstanceRecord(port);
80
+ if (isRecordLive(record)) return toLiveInstance(record);
81
+ if (record) removeInstanceRecord(port);
82
+
83
+ const health = await fetchHealth(port, record?.host);
84
+ if (!health || typeof health.pid !== 'number' || !isProcessAlive(health.pid)) return null;
85
+ return toLiveInstance({
86
+ pid: health.pid,
87
+ port,
88
+ host: health.host ?? record?.host,
89
+ mode: health.mode,
90
+ launchMode: health.launchMode ?? record?.launchMode,
91
+ startedAt: health.startedAt,
92
+ source: 'probe',
93
+ });
109
94
  }
110
95
 
111
96
  function isBun(candidate) {
@@ -147,9 +132,15 @@ export function resolveBunBin() {
147
132
  *
148
133
  * Bun executes the TypeScript entry directly in both modes, so dev and prod
149
134
  * differ only by NODE_ENV — the client bundle in `dist/client` is required by
150
- * the SSR shell either way.
135
+ * the SSR shell either way. `--ompchamber-server` is an identity marker: it
136
+ * makes this process recognizable in `ps` output, which is how a starting
137
+ * server decides whether a busy port belongs to OMPChamber before signalling
138
+ * anything. The launch mode rides along as argv (`--launch-mode=`), never as an
139
+ * environment variable: env is inherited by descendants, so a `bun run dev`
140
+ * started from an OMPChamber shell would otherwise be labelled `daemon` and
141
+ * replaced by the next update.
151
142
  */
152
- export function buildServeInvocation({ pkgRoot, mode, port, host }) {
143
+ export function buildServeInvocation({ pkgRoot, mode, port, host, launchMode = 'daemon' }) {
153
144
  const entry = joinPath(pkgRoot, 'src', 'server', 'index.ts');
154
145
  if (!fs.existsSync(entry)) {
155
146
  throw new Error(`Could not locate the server entry at ${entry}.`);
@@ -159,7 +150,7 @@ export function buildServeInvocation({ pkgRoot, mode, port, host }) {
159
150
  }
160
151
  return {
161
152
  file: resolveBunBin(),
162
- args: [entry],
153
+ args: [entry, '--ompchamber-server', launchModeArg(launchMode)],
163
154
  env: {
164
155
  ...Bun.env,
165
156
  NODE_ENV: mode === 'prod' ? 'production' : 'development',
@@ -171,13 +162,17 @@ export function buildServeInvocation({ pkgRoot, mode, port, host }) {
171
162
 
172
163
  /**
173
164
  * Spawn a detached server, stream its output to the per-port log file and
174
- * persist the registry entry.
165
+ * return the CLI-facing entry description.
166
+ *
167
+ * The instance record is *not* written here: the server writes it once it
168
+ * actually owns the port, so a spawn that fails (port lost to something else)
169
+ * cannot leave a record pointing at a process that never served.
175
170
  */
176
- export function spawnDetachedServer({ pkgRoot, mode, port, host }) {
171
+ export function spawnDetachedServer({ pkgRoot, mode, port, host, launchMode = 'daemon' }) {
177
172
  ensureDataDirs();
178
173
  const logFile = getLogFilePath(port);
179
174
  const fd = fs.openSync(logFile, 'a');
180
- const { file, args, env } = buildServeInvocation({ pkgRoot, mode, port, host });
175
+ const { file, args, env } = buildServeInvocation({ pkgRoot, mode, port, host, launchMode });
181
176
 
182
177
  let child;
183
178
  try {
@@ -194,17 +189,19 @@ export function spawnDetachedServer({ pkgRoot, mode, port, host }) {
194
189
  fs.closeSync(fd);
195
190
  }
196
191
 
197
- const entry = {
198
- pid: child.pid,
199
- port,
200
- host,
201
- mode,
202
- startedAt: new Date().toISOString(),
203
- logFile,
204
- url: `http://${probeHost(host)}:${port}`,
192
+ return {
193
+ child,
194
+ entry: {
195
+ pid: child.pid,
196
+ port,
197
+ host,
198
+ mode,
199
+ launchMode,
200
+ startedAt: new Date().toISOString(),
201
+ logFile,
202
+ url: `http://${probeHost(host)}:${port}`,
203
+ },
205
204
  };
206
- writeRegistry(entry);
207
- return { child, entry };
208
205
  }
209
206
 
210
207
  function sleep(ms) {
@@ -239,7 +236,7 @@ export async function waitForHealth(port, host, timeoutMs = HEALTH_TIMEOUT_MS) {
239
236
 
240
237
  function waitForProcessExit(pid, timeoutMs) {
241
238
  if (!isProcessAlive(pid)) return Promise.resolve(true);
242
- const deadline = Date.now() + Math.max(0, timeoutMs);
239
+ const deadline = Date.now() + timeoutMs;
243
240
  return new Promise((resolve) => {
244
241
  const tick = () => {
245
242
  if (!isProcessAlive(pid)) {
@@ -257,24 +254,40 @@ function waitForProcessExit(pid, timeoutMs) {
257
254
  }
258
255
 
259
256
  /**
260
- * SIGTERM the detached process group, escalate to SIGKILL after the timeout,
261
- * then remove the registry entry. Resolves true once the PID is gone.
257
+ * SIGTERM the process, escalate to SIGKILL after the timeout, then drop the
258
+ * instance record. Resolves true once the PID is gone.
259
+ *
260
+ * Identity is re-checked before signalling: an entry discovered by probe was
261
+ * confirmed by the server itself, but a record-only entry whose PID has since
262
+ * been recycled belongs to a stranger and is never signalled — the stale record
263
+ * is simply removed.
262
264
  */
263
265
  export async function stopInstance(entry, { timeoutMs = STOP_TIMEOUT_MS } = {}) {
264
266
  const port = entry?.port;
265
267
  const pid = Number(entry?.pid);
266
- let gone = true;
268
+ const dropRecord = async () => {
269
+ if (port !== undefined && port !== null) removeInstanceRecord(port);
270
+ };
267
271
 
268
- if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) {
269
- const target = { pid, kill: (signal) => process.kill(pid, signal) };
270
- killChildTree(target, false);
272
+ if (!Number.isInteger(pid) || pid <= 0 || !isProcessAlive(pid)) {
273
+ await dropRecord();
274
+ return true;
275
+ }
276
+
277
+ const confirmedByProbe = entry?.source === 'probe' || entry?.source === 'registry+probe';
278
+ if (!confirmedByProbe && getProcessState(pid) !== 'matched') {
279
+ await dropRecord();
280
+ return true;
281
+ }
282
+
283
+ const target = { pid, kill: (signal) => process.kill(pid, signal) };
284
+ killChildTree(target, false);
285
+ let gone = await waitForProcessExit(pid, timeoutMs);
286
+ if (!gone) {
287
+ killChildTree(target, true);
271
288
  gone = await waitForProcessExit(pid, timeoutMs);
272
- if (!gone) {
273
- killChildTree(target, true);
274
- gone = await waitForProcessExit(pid, timeoutMs);
275
- }
276
289
  }
277
290
 
278
- if (port !== undefined && port !== null) await removeRegistry(port);
291
+ await dropRecord();
279
292
  return gone;
280
293
  }
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useState } from 'preact/hooks';
2
- import type { CSSProperties, TargetedKeyboardEvent } from 'preact';
2
+ import type { CSSProperties, Ref, TargetedKeyboardEvent } from 'preact';
3
3
 
4
4
  /**
5
5
  * Preact hand-roll of the textarea-over-<pre> highlighting editor
@@ -38,6 +38,8 @@ export interface CodeEditorProps {
38
38
  textareaClassName?: string;
39
39
  /** Layout styles for the whole editor; typography must match both layers. */
40
40
  style?: CSSProperties;
41
+ /** The `<pre>` layer that actually wraps the code — gutter alignment measures it. */
42
+ preRef?: Ref<HTMLPreElement>;
41
43
  className?: string;
42
44
  placeholder?: string;
43
45
  readOnly?: boolean;
@@ -73,6 +75,7 @@ export function CodeEditor({
73
75
  preClassName,
74
76
  textareaClassName,
75
77
  style,
78
+ preRef,
76
79
  className,
77
80
  placeholder,
78
81
  readOnly,
@@ -314,6 +317,7 @@ export function CodeEditor({
314
317
  return (
315
318
  <div className={className} style={{ position: 'relative', textAlign: 'left', boxSizing: 'border-box', padding: 0, overflow: 'hidden', ...style }}>
316
319
  <pre
320
+ ref={preRef}
317
321
  className={preClassName}
318
322
  aria-hidden="true"
319
323
  style={{ ...layerStyle, ...contentStyle, position: 'relative', pointerEvents: 'none' }}
@@ -1,5 +1,7 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
1
2
  import type { CSSProperties } from 'preact';
2
3
  import { CodeEditor } from '@/client/components/common/code-editor';
4
+ import { measureWrappedLines, type WrappedLines } from '@/client/components/common/code-surface/measure';
3
5
  import { highlightCode } from '@/shared/lib/code/syntax-highlight';
4
6
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
5
7
 
@@ -25,12 +27,26 @@ interface CodeSurfaceProps {
25
27
  editorStyle?: CSSProperties;
26
28
  }
27
29
 
30
+ /** Same measurement, so a re-render does not resize every gutter row. */
31
+ function sameWrappedLines(previous: WrappedLines | null, next: WrappedLines | null): boolean {
32
+ if (previous === next) return true;
33
+ if (!previous || !next || previous.rowHeight !== next.rowHeight) return false;
34
+ return (
35
+ previous.heights.length === next.heights.length &&
36
+ previous.heights.every((height, index) => height === next.heights[index])
37
+ );
38
+ }
39
+
28
40
  /**
29
41
  * The line-number gutter + syntax-highlighted editing surface shared by the
30
42
  * desktop editor and the phone's full-screen editor. Callers own only the
31
43
  * layout classes (padding, font, zoom) that genuinely differ per layout; the
32
44
  * gutter, word-wrap behaviour and Shiki highlighting live here so the two
33
45
  * surfaces cannot drift.
46
+ *
47
+ * The gutter spaces its numbers from the editor's measured wrap layout: with
48
+ * word wrap on, a long line covers several rows and every number below it has
49
+ * to move down by the same amount.
34
50
  */
35
51
  export function CodeSurface({
36
52
  value,
@@ -47,13 +63,44 @@ export function CodeSurface({
47
63
  editorStyle,
48
64
  }: CodeSurfaceProps) {
49
65
  const wrapClass = wordWrap ? WRAP_ON : WRAP_OFF;
66
+ const preRef = useRef<HTMLPreElement | null>(null);
67
+ const [wrapped, setWrapped] = useState<WrappedLines | null>(null);
50
68
  useSyntaxReady();
51
69
 
70
+ // Re-measured on the inputs that move a wrap point: the text, the wrap
71
+ // preference, and the editor's font size / padding.
72
+ const measure = useCallback(() => {
73
+ const pre = preRef.current;
74
+ const next = wordWrap && pre ? measureWrappedLines(pre, value) : null;
75
+ setWrapped((previous) => (sameWrappedLines(previous, next) ? previous : next));
76
+ }, [value, wordWrap, editorPadding, editorStyle?.fontSize]);
77
+
78
+ useLayoutEffect(() => {
79
+ measure();
80
+ }, [measure]);
81
+
82
+ // The wrap width also changes without a re-render — dragging the panel
83
+ // splitter, resizing the window, a zoom that reflows — so the editor's own
84
+ // box drives a re-measure too.
85
+ const measureRef = useRef(measure);
86
+ measureRef.current = measure;
87
+ useEffect(() => {
88
+ const pre = preRef.current;
89
+ if (!pre || typeof ResizeObserver === 'undefined') return;
90
+ const observer = new ResizeObserver(() => measureRef.current());
91
+ observer.observe(pre);
92
+ return () => observer.disconnect();
93
+ }, []);
94
+
52
95
  return (
53
96
  <div className={rootClassName}>
54
97
  <div className={gutterClassName} style={gutterStyle}>
55
98
  {value.split('\n').map((_, i) => (
56
- <div key={i + 1} className={gutterLineClassName}>
99
+ <div
100
+ key={i + 1}
101
+ className={gutterLineClassName}
102
+ style={wrapped ? { height: wrapped.heights[i], flexShrink: 0 } : undefined}
103
+ >
57
104
  {i + 1}
58
105
  </div>
59
106
  ))}
@@ -64,6 +111,7 @@ export function CodeSurface({
64
111
  onValueChange={onValueChange}
65
112
  highlight={(code) => highlightCode(code, language)}
66
113
  padding={editorPadding}
114
+ preRef={preRef}
67
115
  textareaClassName={`focus:outline-none ${wrapClass}`}
68
116
  preClassName={wrapClass}
69
117
  style={editorStyle}
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Wrapped-row measurement for the line-number gutter.
3
+ *
4
+ * The gutter is a column of one-row line numbers, which is only right while a
5
+ * line fits on one row. With word wrap on, the editor lays a long line out over
6
+ * several rows and every number after it would sit above its own line. How many
7
+ * rows a line takes is a layout fact — font metrics, break opportunities, the
8
+ * width the editor actually got — so it is measured rather than estimated: the
9
+ * same lines are laid out once in a hidden mirror that copies the editor's
10
+ * typography, and each line's height is read back.
11
+ */
12
+
13
+ /**
14
+ * The mirror has to reproduce the editor's line breaking exactly, so every
15
+ * property that can move a break is copied from the live editor's computed
16
+ * style instead of being restated (a second copy of the font stack would drift).
17
+ */
18
+ const MIRRORED_PROPERTIES = [
19
+ 'direction',
20
+ 'fontFamily',
21
+ 'fontFeatureSettings',
22
+ 'fontKerning',
23
+ 'fontSize',
24
+ 'fontStyle',
25
+ 'fontVariantLigatures',
26
+ 'fontWeight',
27
+ 'letterSpacing',
28
+ 'overflowWrap',
29
+ 'tabSize',
30
+ 'textRendering',
31
+ 'textTransform',
32
+ 'whiteSpace',
33
+ 'wordBreak',
34
+ 'wordSpacing',
35
+ ] as const;
36
+
37
+ export interface WrappedLines {
38
+ /** Height in px of a single visual row. */
39
+ rowHeight: number;
40
+ /** Height in px each line of the source occupies once wrapped. */
41
+ heights: number[];
42
+ }
43
+
44
+ let mirrorHost: HTMLDivElement | null = null;
45
+ let mirrorLines: HTMLDivElement[] = [];
46
+
47
+ /** Off-screen mirror, reused across measurements (one per page). */
48
+ function getMirrorHost(): HTMLDivElement {
49
+ if (!mirrorHost || !mirrorHost.isConnected) {
50
+ mirrorHost = document.createElement('div');
51
+ mirrorHost.setAttribute('aria-hidden', 'true');
52
+ mirrorHost.style.cssText =
53
+ 'position:fixed;top:0;left:-100000px;visibility:hidden;pointer-events:none;margin:0;border:0;padding:0;box-sizing:border-box;';
54
+ document.body.appendChild(mirrorHost);
55
+ mirrorLines = [];
56
+ }
57
+ return mirrorHost;
58
+ }
59
+
60
+ /**
61
+ * Height each line of `value` occupies in `source`'s wrap layout, or null when
62
+ * the layout is not measurable yet (SSR, zero width, unresolved line-height) —
63
+ * callers then fall back to one row per line.
64
+ */
65
+ export function measureWrappedLines(source: HTMLPreElement, value: string): WrappedLines | null {
66
+ if (typeof document === 'undefined' || !source.isConnected) return null;
67
+ const computed = getComputedStyle(source);
68
+ const rowHeight = Number.parseFloat(computed.lineHeight);
69
+ if (!Number.isFinite(rowHeight) || rowHeight <= 0) return null;
70
+ // The editor's text column: its padding is the gutter's offset, not text area.
71
+ const width =
72
+ source.clientWidth -
73
+ Number.parseFloat(computed.paddingLeft || '0') -
74
+ Number.parseFloat(computed.paddingRight || '0');
75
+ if (width <= 0) return null;
76
+
77
+ const host = getMirrorHost();
78
+ for (const property of MIRRORED_PROPERTIES) host.style[property] = computed[property];
79
+ host.style.lineHeight = `${rowHeight}px`;
80
+ host.style.width = `${width}px`;
81
+
82
+ const lines = value.split('\n');
83
+ while (mirrorLines.length > lines.length) mirrorLines.pop()?.remove();
84
+ while (mirrorLines.length < lines.length) {
85
+ const line = document.createElement('div');
86
+ host.appendChild(line);
87
+ mirrorLines.push(line);
88
+ }
89
+ // Write the whole text first, then read the whole layout: interleaving the
90
+ // two would force a re-layout per line.
91
+ for (let i = 0; i < lines.length; i++) mirrorLines[i].textContent = lines[i];
92
+ const heights = mirrorLines.map((line) => {
93
+ const height = line.getBoundingClientRect().height;
94
+ // An empty line has no line box at all, and sub-pixel wrapping would
95
+ // otherwise leave the gutter fractions of a row out of step.
96
+ return height > rowHeight ? Math.round(height / rowHeight) * rowHeight : rowHeight;
97
+ });
98
+ return { rowHeight, heights };
99
+ }
@@ -1,7 +1,8 @@
1
1
  import { BarChart3, Bot, Files, GitBranch, Globe, Layers, Search, Terminal } from 'lucide-preact';
2
+ import type { ReactNode } from 'preact/compat';
2
3
  import { useGitStatus } from '@/client/hooks/workspace/git-status';
3
4
  import { GIT_STATUS_POLL_MS } from '@/shared/lib/workspace/refresh-cadence';
4
- import type { RightPanelType } from '@/shared/lib/workspace/right-panels';
5
+ import { RIGHT_PANEL_TYPES, type RightPanelType } from '@/shared/lib/workspace/right-panels';
5
6
 
6
7
  interface RightActivityBarProps {
7
8
  activePanel: RightPanelType;
@@ -12,83 +13,55 @@ interface RightActivityBarProps {
12
13
  refreshKey: number;
13
14
  }
14
15
 
16
+ /**
17
+ * Icon and tooltip per view. The *order* is not restated here: it comes from
18
+ * RIGHT_PANEL_TYPES, the same list the phone's right-sidebar tab bar renders,
19
+ * so the two layouts cannot list the views differently.
20
+ */
21
+ const PANEL_META: Record<RightPanelType, { title: string; icon: ReactNode }> = {
22
+ context: { title: 'Context & Telemetry', icon: <Layers size={16} /> },
23
+ files: { title: 'Files', icon: <Files size={16} /> },
24
+ search: { title: 'Search', icon: <Search size={16} /> },
25
+ git: { title: 'Source Control', icon: <GitBranch size={16} /> },
26
+ terminal: { title: 'Terminal (Bun)', icon: <Terminal size={16} /> },
27
+ 'user-browser': { title: 'Browser (Anda)', icon: <Globe size={16} /> },
28
+ browser: { title: 'Browser Agent', icon: <Bot size={16} /> },
29
+ usage: { title: 'Usage', icon: <BarChart3 size={16} /> },
30
+ };
31
+
15
32
  export function RightActivityBar({ activePanel, onChangePanel, isPanelOpen, hasActiveContext, activeProjectPath, refreshKey }: RightActivityBarProps) {
16
33
  const { changes } = useGitStatus(activeProjectPath ?? undefined, '.', refreshKey, hasActiveContext, GIT_STATUS_POLL_MS);
17
34
  const hasGitChanges = changes.length > 0;
18
- const getBtnClass = (panel: RightPanelType) => {
19
- const base = "relative w-full h-10 flex items-center justify-center transition-colors border-l-2";
20
- const isActive = isPanelOpen && activePanel === panel;
21
- return `${base} ${isActive ? 'text-ink border-ink bg-ink/5' : 'text-ink/40 border-transparent hover:text-ink hover:bg-ink/5'}`;
22
- };
23
35
 
24
36
  return (
25
37
  <nav className="w-12 flex-shrink-0 border-l border-ink/10 bg-paper flex flex-col items-center py-3 space-y-2 z-10">
26
38
  {/* Top Icons */}
27
39
  <div className="flex flex-col items-center space-y-1 w-full">
28
- <button
29
- className={getBtnClass('context')}
30
- onClick={() => onChangePanel('context')}
31
- title="Context & Telemetry"
32
- >
33
- <Layers size={16} />
34
- </button>
35
- <button
36
- className={getBtnClass('files')}
37
- onClick={() => onChangePanel('files')}
38
- title="Files"
39
- >
40
- <Files size={16} />
41
- </button>
42
- <button
43
- className={getBtnClass('search')}
44
- onClick={() => onChangePanel('search')}
45
- title="Search"
46
- >
47
- <Search size={16} />
48
- </button>
49
- <button
50
- className={getBtnClass('git')}
51
- onClick={() => onChangePanel('git')}
52
- title="Source Control"
53
- >
54
- <GitBranch size={16} />
55
- {hasGitChanges && (
56
- <span
57
- className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-info"
58
- title="Ada perubahan git"
59
- />
60
- )}
61
- </button>
62
- <button
63
- className={getBtnClass('terminal')}
64
- onClick={() => onChangePanel('terminal')}
65
- title="Terminal (Bun)"
66
- >
67
- <Terminal size={16} />
68
- </button>
69
- <button
70
- className={getBtnClass('user-browser')}
71
- onClick={() => onChangePanel('user-browser')}
72
- title="Browser (Anda)"
73
- >
74
- <Globe size={16} />
75
- </button>
76
- <button
77
- className={getBtnClass('browser')}
78
- onClick={() => onChangePanel('browser')}
79
- title="Browser Agent"
80
- >
81
- <Bot size={16} />
82
- </button>
83
- <button
84
- className={getBtnClass('usage')}
85
- onClick={() => onChangePanel('usage')}
86
- title="Usage"
87
- >
88
- <BarChart3 size={16} />
89
- </button>
40
+ {RIGHT_PANEL_TYPES.map((panel) => {
41
+ const isActive = isPanelOpen && activePanel === panel;
42
+ return (
43
+ <button
44
+ key={panel}
45
+ className={`relative w-full h-10 flex items-center justify-center transition-colors border-l-2 ${
46
+ isActive
47
+ ? 'text-ink border-ink bg-ink/5'
48
+ : 'text-ink/40 border-transparent hover:text-ink hover:bg-ink/5'
49
+ }`}
50
+ onClick={() => onChangePanel(panel)}
51
+ title={PANEL_META[panel].title}
52
+ >
53
+ {PANEL_META[panel].icon}
54
+ {panel === 'git' && hasGitChanges && (
55
+ <span
56
+ className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-info"
57
+ title="Ada perubahan git"
58
+ />
59
+ )}
60
+ </button>
61
+ );
62
+ })}
90
63
  </div>
91
-
64
+
92
65
  <div className="flex-1" />
93
66
  </nav>
94
67
  );