ompchamber 1.1.0 → 1.2.1

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 (192) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b938f4a693.css +2 -0
  3. package/dist/client/static/js/77.8022a800b5.js +139 -0
  4. package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
  5. package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
  6. package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
  7. package/dist/client/static/js/async/66.86544ad741.js +93 -0
  8. package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
  9. package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
  10. package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
  11. package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
  12. package/dist/client/static/js/async/e.6443e739a2.js +79 -0
  13. package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
  14. package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
  15. package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
  16. package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
  17. package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
  18. package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
  19. package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
  20. package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
  21. package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
  22. package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
  23. package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
  24. package/dist/client/static/js/async/{5b.a5ccbc19dc.js → q.b453193538.js} +10 -10
  25. package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
  26. package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
  27. package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
  28. package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
  29. package/dist/client/static/js/async/vt.20406a5604.js +1 -0
  30. package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
  31. package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
  32. package/dist/client/static/js/async/yx.af2936c368.js +10 -0
  33. package/dist/client/static/js/index.5fe7a2cb64.js +34 -0
  34. package/package.json +1 -1
  35. package/src/client/components/common/file-icon/index.tsx +4 -1
  36. package/src/client/components/common/image-viewer/index.tsx +224 -0
  37. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
  38. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
  39. package/src/client/components/settings/Modal.tsx +1 -0
  40. package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
  41. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
  42. package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
  43. package/src/client/components/workspace/btw-panel/Composer.tsx +183 -0
  44. package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
  45. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
  46. package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
  47. package/src/client/components/workspace/btw-panel/index.tsx +88 -0
  48. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +37 -21
  49. package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
  50. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
  51. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
  52. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
  53. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +44 -40
  54. package/src/client/components/workspace/chat-timeline/index.tsx +11 -9
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
  56. package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
  57. package/src/client/components/workspace/editor/index.tsx +45 -36
  58. package/src/client/hooks/chat/btw/index.ts +180 -0
  59. package/src/client/hooks/chat/btw/intercept.ts +43 -0
  60. package/src/client/hooks/chat/btw/mode.ts +179 -0
  61. package/src/client/hooks/chat/composer/attachments.ts +198 -0
  62. package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
  63. package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
  64. package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
  65. package/src/client/hooks/chat/composer/drop-references.ts +106 -0
  66. package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
  67. package/src/client/hooks/chat/composer/file-drop.ts +226 -0
  68. package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
  69. package/src/client/hooks/chat/composer/file-reads.ts +238 -0
  70. package/src/client/hooks/chat/composer/index.ts +7 -1
  71. package/src/client/hooks/chat/composer/pipeline.ts +181 -0
  72. package/src/client/hooks/chat/omp/index.ts +3 -3
  73. package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
  74. package/src/client/hooks/chat/timeline/actions.ts +16 -1
  75. package/src/client/hooks/chat/timeline/index.ts +16 -8
  76. package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
  77. package/src/client/hooks/chat/timeline/send.ts +29 -65
  78. package/src/client/hooks/chat/timeline/session-load.ts +2 -1
  79. package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
  80. package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
  81. package/src/client/hooks/editor/file-actions.ts +49 -0
  82. package/src/client/hooks/editor/use-file-editor.ts +38 -27
  83. package/src/client/hooks/editor/use-file-transfer.ts +78 -0
  84. package/src/client/tailwind.css +22 -0
  85. package/src/server/db.server.ts +21 -287
  86. package/src/server/lib/btw/frames.test.ts +68 -0
  87. package/src/server/lib/btw/frames.ts +56 -0
  88. package/src/server/lib/btw/prompt.ts +28 -0
  89. package/src/server/lib/btw/registry.server.ts +141 -0
  90. package/src/server/lib/btw/runtime.server.ts +324 -0
  91. package/src/server/lib/btw/schema.server.ts +44 -0
  92. package/src/server/lib/btw/service.server.ts +171 -0
  93. package/src/server/lib/btw/session-copy.server.ts +213 -0
  94. package/src/server/lib/btw/session-copy.test.ts +166 -0
  95. package/src/server/lib/btw/store.server.ts +240 -0
  96. package/src/server/lib/db/schema.ts +110 -0
  97. package/src/server/lib/db/seed.ts +160 -0
  98. package/src/server/lib/db/workspace-sync.ts +70 -0
  99. package/src/server/lib/fs/root.ts +37 -0
  100. package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
  101. package/src/server/lib/omp/rpc/manager.ts +27 -123
  102. package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
  103. package/src/server/lib/omp/session/auto-title.server.ts +152 -0
  104. package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
  105. package/src/server/lib/omp/session/blobs.server.ts +76 -0
  106. package/src/server/lib/omp/session/file-scan.ts +174 -0
  107. package/src/server/lib/omp/session/files.test.ts +147 -0
  108. package/src/server/lib/omp/session/files.ts +52 -35
  109. package/src/server/lib/omp/session/locator.ts +2 -2
  110. package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
  111. package/src/server/lib/omp/session/merge-stored.ts +171 -0
  112. package/src/server/lib/omp/session/messages.ts +34 -0
  113. package/src/server/lib/omp/session/reader.ts +2 -2
  114. package/src/server/lib/omp/session/subagent-presence.ts +9 -6
  115. package/src/server/lib/queue/delivery.server.test.ts +57 -0
  116. package/src/server/lib/queue/delivery.server.ts +25 -3
  117. package/src/server/plugins/ssr.ts +29 -2
  118. package/src/server/routes/btw/events.ts +70 -0
  119. package/src/server/routes/btw/index.ts +8 -0
  120. package/src/server/routes/btw/topics.ts +87 -0
  121. package/src/server/routes/btw/ws.ts +136 -0
  122. package/src/server/routes/chat/index.ts +2 -0
  123. package/src/server/routes/chat/session.ts +1 -115
  124. package/src/server/routes/chat/turns.ts +87 -0
  125. package/src/server/routes/fs/index.ts +4 -1
  126. package/src/server/routes/fs/raw.ts +85 -0
  127. package/src/server/routes/fs/read.ts +58 -3
  128. package/src/server/routes/index.ts +7 -1
  129. package/src/shared/lib/chat/attachments.test.ts +208 -0
  130. package/src/shared/lib/chat/attachments.ts +200 -15
  131. package/src/shared/lib/chat/btw/client.ts +94 -0
  132. package/src/shared/lib/chat/composer/client.test.ts +48 -0
  133. package/src/shared/lib/chat/composer/client.ts +6 -1
  134. package/src/shared/lib/chat/composer/composer.test.ts +39 -1
  135. package/src/shared/lib/chat/composer/trigger.ts +45 -1
  136. package/src/shared/lib/chat/omp/agent-events.ts +33 -80
  137. package/src/shared/lib/chat/omp/socket.ts +11 -6
  138. package/src/shared/lib/chat/omp/sse.ts +11 -6
  139. package/src/shared/lib/chat/omp/tool-results.ts +102 -0
  140. package/src/shared/lib/chat/omp/transport.ts +19 -3
  141. package/src/shared/lib/chat/text-file.ts +148 -0
  142. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
  143. package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
  144. package/src/shared/lib/chat/timeline/turns.ts +7 -0
  145. package/src/shared/lib/fs/file-kind.ts +50 -0
  146. package/src/shared/lib/fs/paths.ts +19 -1
  147. package/src/shared/lib/markdown/math.test.ts +20 -3
  148. package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
  149. package/src/shared/lib/omp/session/mapper.ts +24 -4
  150. package/src/shared/lib/omp/session/messages-map.ts +16 -3
  151. package/src/shared/lib/omp/session/messages-parse.ts +2 -2
  152. package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
  153. package/src/shared/types/btw.ts +65 -0
  154. package/src/shared/types/chat.ts +39 -1
  155. package/src/shared/types/index.ts +1 -0
  156. package/src/shared/types/omp/agent.ts +14 -1
  157. package/src/shared/types/omp/session.ts +4 -1
  158. package/src/shared/types/settings/state.ts +5 -0
  159. package/dist/client/static/css/index.b859cc1cc5.css +0 -2
  160. package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
  161. package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
  162. package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
  163. package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
  164. package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
  165. package/dist/client/static/js/async/e.93214d622b.js +0 -79
  166. package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
  167. package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
  168. package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
  169. package/dist/client/static/js/async/jl.874d352278.js +0 -1
  170. package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
  171. package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
  172. package/dist/client/static/js/async/q.23af2a358f.js +0 -21
  173. package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
  174. package/dist/client/static/js/async/s.222ee974f6.js +0 -2
  175. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  176. package/dist/client/static/js/async/vt.42416b707d.js +0 -1
  177. package/dist/client/static/js/async/x1.da8687159b.js +0 -90
  178. package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
  179. package/dist/client/static/js/index.6e065017eb.js +0 -33
  180. package/dist/client/static/js/jp.50817896f3.js +0 -139
  181. /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
  182. /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
  183. /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
  184. /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
  185. /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
  186. /package/dist/client/static/js/async/{5b.a5ccbc19dc.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
  187. /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
  188. /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
  189. /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
  190. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
  191. /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
  192. /package/dist/client/static/js/{index.6e065017eb.js.LICENSE.txt → index.5fe7a2cb64.js.LICENSE.txt} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ompchamber",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Developer web console and diagnostic chamber for AI Oh-My-Pi — Elysia + Preact on Bun.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
@@ -9,6 +9,7 @@ import {
9
9
  FolderOpen,
10
10
  } from 'lucide-preact';
11
11
  import { BRAND_ICONS, BRAND_VIEWBOX, type BrandIconName } from '@/client/components/common/file-icon/brand-paths';
12
+ import { getImageMimeType } from '@/shared/lib/fs/file-kind';
12
13
 
13
14
  interface FileIconProps {
14
15
  name: string;
@@ -110,7 +111,9 @@ export function FileIcon({ name, isFolder, isOpen, size = 14, className = '' }:
110
111
  if (lowerName.endsWith('.md') || lowerName.endsWith('.txt')) {
111
112
  return <FileText size={size} className={`text-ink/70 ${className}`} />;
112
113
  }
113
- if (lowerName.endsWith('.png') || lowerName.endsWith('.jpg') || lowerName.endsWith('.jpeg') || lowerName.endsWith('.svg') || lowerName.endsWith('.ico') || lowerName.endsWith('.webp')) {
114
+ // SVG is text the editor edits as code, but it is still a picture to the eye,
115
+ // so the icon stays the image glyph for it.
116
+ if (getImageMimeType(lowerName) || lowerName.endsWith('.svg')) {
114
117
  return <FileImage size={size} color="#c084fc" className={className} />;
115
118
  }
116
119
  if (lowerName.endsWith('.sh') || lowerName.endsWith('.bash')) {
@@ -0,0 +1,224 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The picture surface used wherever a file is opened and turns out to be an
8
+ * image — the desktop editor panel, the phone's full-screen editor, and the
9
+ * chat `read` tool card. An image has no text, so this replaces the code
10
+ * surface entirely: an `<img>` against the raw-byte route, a checker backdrop
11
+ * for transparency, and zoom/pan that mirror the diagram viewer so the two
12
+ * full-size viewers behave the same way.
13
+ */
14
+
15
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
16
+ import type { TargetedPointerEvent, TargetedWheelEvent } from 'preact';
17
+ import { AlertTriangle, Loader2 } from 'lucide-preact';
18
+
19
+ const MIN_SCALE = 0.1;
20
+ const MAX_SCALE = 16;
21
+ /** Zoom step for the buttons and the +/- keys. */
22
+ const ZOOM_STEP = 1.4;
23
+ /** Wheel `deltaY` → scale exponent, matching the diagram viewer's feel. */
24
+ const WHEEL_SENSITIVITY = 0.0015;
25
+ /** Pixels kept clear around a fitted picture. */
26
+ const FIT_MARGIN = 48;
27
+
28
+ interface ImageViewerProps {
29
+ /** Raw-byte URL; a different URL is a different picture. */
30
+ src: string;
31
+ name: string;
32
+ className?: string;
33
+ }
34
+
35
+ interface Drag {
36
+ pointerId: number;
37
+ x: number;
38
+ y: number;
39
+ }
40
+
41
+ /**
42
+ * The stateful surface, keyed on `src` by `ImageViewer` — remounting on a new
43
+ * URL is what guarantees a refresh cannot inherit the previous picture's fit,
44
+ * pan offset, or decode error.
45
+ */
46
+ function ImageSurface({ src, name, className = '' }: ImageViewerProps) {
47
+ const stageRef = useRef<HTMLDivElement>(null);
48
+ const imageRef = useRef<HTMLImageElement>(null);
49
+ const dragRef = useRef<Drag | null>(null);
50
+
51
+ const [natural, setNatural] = useState<{ width: number; height: number } | null>(null);
52
+ const [scale, setScale] = useState(1);
53
+ const [offset, setOffset] = useState({ x: 0, y: 0 });
54
+ const [dragging, setDragging] = useState(false);
55
+ const [error, setError] = useState<string | null>(null);
56
+
57
+ /** Read the decoded size off the element; false while it is not decodable yet. */
58
+ const measure = useCallback((): boolean => {
59
+ const image = imageRef.current;
60
+ if (!image || !image.complete || image.naturalWidth === 0) return false;
61
+ setNatural({ width: image.naturalWidth, height: image.naturalHeight });
62
+ return true;
63
+ }, []);
64
+
65
+ // A cached picture can be complete before its `load` handler is attached, so
66
+ // the size is read once on mount as well — otherwise a re-opened image sat
67
+ // at 100% with no dimensions reported and Fit had nothing to fit to.
68
+ useEffect(() => {
69
+ measure();
70
+ }, [measure]);
71
+
72
+ /** Scale that fits the whole picture without upscaling it past 1. */
73
+ const fitToView = useCallback(() => {
74
+ const image = imageRef.current;
75
+ const stage = stageRef.current?.getBoundingClientRect();
76
+ const width = image?.naturalWidth ?? 0;
77
+ const height = image?.naturalHeight ?? 0;
78
+ if (!stage || !width || !height) return;
79
+ setScale(
80
+ Math.min(
81
+ 1,
82
+ Math.max(MIN_SCALE, (stage.width - FIT_MARGIN) / width, (stage.height - FIT_MARGIN) / height),
83
+ ),
84
+ );
85
+ setOffset({ x: 0, y: 0 });
86
+ }, []);
87
+
88
+ // Fit once per picture. Re-measuring on every render would fight an
89
+ // in-progress zoom, and `natural` only changes for a new decode.
90
+ useEffect(() => {
91
+ if (natural) fitToView();
92
+ }, [natural, fitToView]);
93
+
94
+ const zoomBy = useCallback((factor: number) => {
95
+ setScale((current) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, current * factor)));
96
+ }, []);
97
+
98
+ const handleWheel = useCallback(
99
+ (event: TargetedWheelEvent<HTMLDivElement>) => {
100
+ // Without this the editor panel scrolls while zooming.
101
+ event.preventDefault();
102
+ zoomBy(Math.exp(-event.deltaY * WHEEL_SENSITIVITY));
103
+ },
104
+ [zoomBy],
105
+ );
106
+
107
+ const handlePointerDown = useCallback((event: TargetedPointerEvent<HTMLDivElement>) => {
108
+ if (event.button !== 0 || !natural) return;
109
+ dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
110
+ event.currentTarget.setPointerCapture(event.pointerId);
111
+ setDragging(true);
112
+ }, [natural]);
113
+
114
+ const handlePointerMove = useCallback((event: TargetedPointerEvent<HTMLDivElement>) => {
115
+ const drag = dragRef.current;
116
+ if (!drag || drag.pointerId !== event.pointerId) return;
117
+ const dx = event.clientX - drag.x;
118
+ const dy = event.clientY - drag.y;
119
+ drag.x = event.clientX;
120
+ drag.y = event.clientY;
121
+ setOffset((current) => ({ x: current.x + dx, y: current.y + dy }));
122
+ }, []);
123
+
124
+ const endDrag = useCallback((event: TargetedPointerEvent<HTMLDivElement>) => {
125
+ if (dragRef.current?.pointerId !== event.pointerId) return;
126
+ dragRef.current = null;
127
+ setDragging(false);
128
+ }, []);
129
+
130
+ const handleDoubleClick = useCallback(() => {
131
+ if (Math.abs(scale - 1) < 0.01) fitToView();
132
+ else {
133
+ setScale(1);
134
+ setOffset({ x: 0, y: 0 });
135
+ }
136
+ }, [scale, fitToView]);
137
+
138
+ useEffect(() => {
139
+ const onKeyDown = (event: globalThis.KeyboardEvent) => {
140
+ if (event.key !== '+' && event.key !== '=' && event.key !== '-' && event.key !== '_' && event.key !== '0') return;
141
+ // The stage owns these keys only while it has focus; a window-level
142
+ // listener would steal `-`/`0` from the composer and the file search box.
143
+ const target = event.target as HTMLElement | null;
144
+ if (!target || !stageRef.current?.contains(target)) return;
145
+ event.preventDefault();
146
+ if (event.key === '0') fitToView();
147
+ else if (event.key === '+' || event.key === '=') zoomBy(ZOOM_STEP);
148
+ else zoomBy(1 / ZOOM_STEP);
149
+ };
150
+ window.addEventListener('keydown', onKeyDown);
151
+ return () => window.removeEventListener('keydown', onKeyDown);
152
+ }, [zoomBy, fitToView]);
153
+
154
+ const percent = Math.round(scale * 100);
155
+ const dimensions = natural ? `${natural.width} × ${natural.height} px` : '';
156
+
157
+ return (
158
+ <div className={`relative flex h-full w-full flex-col bg-paper ${className}`}>
159
+ <div
160
+ ref={stageRef}
161
+ tabIndex={-1}
162
+ onWheel={handleWheel}
163
+ onPointerDown={handlePointerDown}
164
+ onPointerMove={handlePointerMove}
165
+ onPointerUp={endDrag}
166
+ onPointerCancel={endDrag}
167
+ onDblClick={handleDoubleClick}
168
+ className={`image-stage relative flex-1 overflow-hidden outline-none ${dragging ? 'cursor-grabbing' : 'cursor-grab'}`}
169
+ >
170
+ {error ? (
171
+ <div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center">
172
+ <AlertTriangle size={20} className="text-error" />
173
+ <span className="font-sans text-xs text-error">Could not read {name}</span>
174
+ <span className="break-all font-mono text-[11px] text-ink/50">{error}</span>
175
+ </div>
176
+ ) : (
177
+ <>
178
+ {!natural && (
179
+ <div className="absolute inset-0 flex items-center justify-center gap-2 text-xs text-ink/40">
180
+ <Loader2 size={14} className="animate-spin" />
181
+ <span>Loading image…</span>
182
+ </div>
183
+ )}
184
+ <div className="absolute inset-0 flex items-center justify-center">
185
+ <img
186
+ ref={imageRef}
187
+ src={src}
188
+ alt={name}
189
+ draggable={false}
190
+ onLoad={measure}
191
+ onError={() => setError('The file could not be decoded as an image.')}
192
+ className="image-canvas max-h-none max-w-none select-none"
193
+ style={{
194
+ transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${scale})`,
195
+ visibility: natural ? 'visible' : 'hidden',
196
+ }}
197
+ />
198
+ </div>
199
+ </>
200
+ )}
201
+ </div>
202
+
203
+ <div className="flex flex-shrink-0 items-center justify-between border-t border-ink/10 bg-canvas px-3 py-1.5 font-mono text-[10px] text-ink/50">
204
+ <span>{dimensions}</span>
205
+ <div className="flex items-center gap-3">
206
+ <button type="button" onClick={() => zoomBy(1 / ZOOM_STEP)} className="hover:text-ink" title="Zoom out (-)">
207
+ −
208
+ </button>
209
+ <span className="w-10 text-center tabular-nums">{percent}%</span>
210
+ <button type="button" onClick={() => zoomBy(ZOOM_STEP)} className="hover:text-ink" title="Zoom in (+)">
211
+ +
212
+ </button>
213
+ <button type="button" onClick={fitToView} className="hover:text-ink" title="Fit to view (0)">
214
+ Fit
215
+ </button>
216
+ </div>
217
+ </div>
218
+ </div>
219
+ );
220
+ }
221
+
222
+ export function ImageViewer(props: ImageViewerProps) {
223
+ return <ImageSurface key={props.src} {...props} />;
224
+ }
@@ -2,6 +2,7 @@ import { useState } from 'preact/hooks';
2
2
  import { AlertTriangle, Loader2 } from 'lucide-preact';
3
3
  import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
4
4
  import { CodeSurface } from '@/client/components/common/code-surface';
5
+ import { ImageViewer } from '@/client/components/common/image-viewer';
5
6
  import { EditorHeader } from '@/client/components/mobile/mobile-right-sidebar/Header';
6
7
  import { useScrollbarFade, scrollbarFadeClass } from '@/client/hooks/ui/scrollbar-fade';
7
8
  import { useFileEditor } from '@/client/hooks/editor/use-file-editor';
@@ -48,6 +49,7 @@ export function MobileFullEditor({ file, onClose, onFileSaved }: MobileFullEdito
48
49
  isPreview={isPreview}
49
50
  wordWrap={wordWrap}
50
51
  copied={editor.copied}
52
+ isImage={editor.isImage}
51
53
  onClose={onClose}
52
54
  onTogglePreview={() => setIsPreview(!isPreview)}
53
55
  onToggleWrap={() => setWordWrap(!wordWrap)}
@@ -57,46 +59,52 @@ export function MobileFullEditor({ file, onClose, onFileSaved }: MobileFullEdito
57
59
  onDownload={editor.download}
58
60
  />
59
61
 
60
- {/* Editor Content Area - Desktop styled: bg-paper text-ink */}
61
- <div onScroll={handleScroll} className={`flex-1 min-h-0 overflow-auto relative bg-paper text-ink select-text ${scrollbarFadeClass(isScrolling)}`}>
62
- {editor.isLoading ? (
63
- <div className="flex items-center justify-center h-full text-ink/40 text-xs gap-2">
64
- <Loader2 size={14} className="animate-spin" />
65
- <span>Loading file content...</span>
66
- </div>
67
- ) : editor.loadError ? (
68
- <div className="flex flex-col items-center justify-center h-full gap-2 px-6 text-center">
69
- <AlertTriangle size={20} className="text-error" />
70
- <span className="text-xs text-error font-sans">Could not read {file.name}</span>
71
- <span className="text-[11px] text-ink/50 font-mono break-all">{editor.loadError}</span>
72
- </div>
73
- ) : isMd && isPreview ? (
74
- /* Markdown Preview Mode */
75
- <div className="p-4 bg-paper text-ink min-h-full font-sans prose prose-sm max-w-none">
76
- <MarkdownRenderer content={content} />
77
- </div>
78
- ) : (
79
- /* Code Editor with Line Numbers - Desktop styled: gutter bg-canvas border-r border-ink/10 */
80
- <CodeSurface
81
- value={content}
82
- onValueChange={editor.onChange}
83
- language={lang}
84
- wordWrap={wordWrap}
85
- rootClassName="flex min-h-full bg-paper"
86
- gutterClassName="w-10 py-3 pr-2 select-none text-right text-[10px] text-ink/30 bg-canvas border-r border-ink/10 font-mono leading-[20px] flex-shrink-0"
87
- editorWrapperClassName="flex-1 p-3 overflow-x-auto min-w-0 bg-paper text-ink code-surface"
88
- editorClassName="focus:outline-none"
89
- editorStyle={{
90
- fontFamily: 'monospace',
91
- fontSize: `${fontSize}px`,
92
- lineHeight: '20px',
93
- backgroundColor: 'transparent',
94
- minHeight: '100%',
95
- color: 'var(--theme-ink)',
96
- }}
97
- />
98
- )}
99
- </div>
62
+ {/* Editor Content Area - Desktop styled: bg-paper text-ink. An image owns
63
+ its own pan/zoom, so it replaces the scrolling text container rather
64
+ than living inside it. */}
65
+ {editor.isImage && editor.imageUrl ? (
66
+ <ImageViewer src={editor.imageUrl} name={file.name} className="flex-1 min-h-0" />
67
+ ) : (
68
+ <div onScroll={handleScroll} className={`flex-1 min-h-0 overflow-auto relative bg-paper text-ink select-text ${scrollbarFadeClass(isScrolling)}`}>
69
+ {editor.isLoading ? (
70
+ <div className="flex items-center justify-center h-full text-ink/40 text-xs gap-2">
71
+ <Loader2 size={14} className="animate-spin" />
72
+ <span>Loading file content...</span>
73
+ </div>
74
+ ) : editor.loadError ? (
75
+ <div className="flex flex-col items-center justify-center h-full gap-2 px-6 text-center">
76
+ <AlertTriangle size={20} className="text-error" />
77
+ <span className="text-xs text-error font-sans">Could not read {file.name}</span>
78
+ <span className="text-[11px] text-ink/50 font-mono break-all">{editor.loadError}</span>
79
+ </div>
80
+ ) : isMd && isPreview ? (
81
+ /* Markdown Preview Mode */
82
+ <div className="p-4 bg-paper text-ink min-h-full font-sans prose prose-sm max-w-none">
83
+ <MarkdownRenderer content={content} />
84
+ </div>
85
+ ) : (
86
+ /* Code Editor with Line Numbers - Desktop styled: gutter bg-canvas border-r border-ink/10 */
87
+ <CodeSurface
88
+ value={content}
89
+ onValueChange={editor.onChange}
90
+ language={lang}
91
+ wordWrap={wordWrap}
92
+ rootClassName="flex min-h-full bg-paper"
93
+ gutterClassName="w-10 py-3 pr-2 select-none text-right text-[10px] text-ink/30 bg-canvas border-r border-ink/10 font-mono leading-[20px] flex-shrink-0"
94
+ editorWrapperClassName="flex-1 p-3 overflow-x-auto min-w-0 bg-paper text-ink code-surface"
95
+ editorClassName="focus:outline-none"
96
+ editorStyle={{
97
+ fontFamily: 'monospace',
98
+ fontSize: `${fontSize}px`,
99
+ lineHeight: '20px',
100
+ backgroundColor: 'transparent',
101
+ minHeight: '100%',
102
+ color: 'var(--theme-ink)',
103
+ }}
104
+ />
105
+ )}
106
+ </div>
107
+ )}
100
108
 
101
109
  {/* Bottom Status Bar - Desktop styled: bg-canvas border-t border-ink/10 */}
102
110
  <footer
@@ -109,9 +117,15 @@ export function MobileFullEditor({ file, onClose, onFileSaved }: MobileFullEdito
109
117
  }}
110
118
  >
111
119
  <div className="flex items-center space-x-3">
112
- <span>{linesCount} lines</span>
113
- <span>{content.length} chars</span>
114
- <span className="uppercase">{lang}</span>
120
+ {editor.isImage ? (
121
+ <span className="uppercase">Image</span>
122
+ ) : (
123
+ <>
124
+ <span>{linesCount} lines</span>
125
+ <span>{content.length} chars</span>
126
+ <span className="uppercase">{lang}</span>
127
+ </>
128
+ )}
115
129
  </div>
116
130
  <div className="flex items-center space-x-2">
117
131
  {editor.saveStatus === 'saving' && (
@@ -8,6 +8,8 @@ interface EditorHeaderProps {
8
8
  isPreview: boolean;
9
9
  wordWrap: boolean;
10
10
  copied: boolean;
11
+ /** Raster image: wrap and font zoom are text-only, so both are hidden. */
12
+ isImage?: boolean;
11
13
  onClose: () => void;
12
14
  onTogglePreview: () => void;
13
15
  onToggleWrap: () => void;
@@ -24,6 +26,7 @@ export function EditorHeader({
24
26
  isPreview,
25
27
  wordWrap,
26
28
  copied,
29
+ isImage = false,
27
30
  onClose,
28
31
  onTogglePreview,
29
32
  onToggleWrap,
@@ -71,32 +74,36 @@ export function EditorHeader({
71
74
  </button>
72
75
  )}
73
76
 
74
- <button
75
- type="button"
76
- onClick={onToggleWrap}
77
- className={`p-1.5 rounded hover:bg-ink/10 transition-colors cursor-pointer ${wordWrap ? 'text-ink' : ''}`}
78
- title="Toggle word wrap"
79
- >
80
- <WrapText size={15} />
81
- </button>
77
+ {!isImage && (
78
+ <>
79
+ <button
80
+ type="button"
81
+ onClick={onToggleWrap}
82
+ className={`p-1.5 rounded hover:bg-ink/10 transition-colors cursor-pointer ${wordWrap ? 'text-ink' : ''}`}
83
+ title="Toggle word wrap"
84
+ >
85
+ <WrapText size={15} />
86
+ </button>
82
87
 
83
- <button
84
- type="button"
85
- onClick={onZoomOut}
86
- className="p-1.5 rounded hover:bg-ink/10 transition-colors cursor-pointer"
87
- title="Decrease font size"
88
- >
89
- <ZoomOut size={15} />
90
- </button>
88
+ <button
89
+ type="button"
90
+ onClick={onZoomOut}
91
+ className="p-1.5 rounded hover:bg-ink/10 transition-colors cursor-pointer"
92
+ title="Decrease font size"
93
+ >
94
+ <ZoomOut size={15} />
95
+ </button>
91
96
 
92
- <button
93
- type="button"
94
- onClick={onZoomIn}
95
- className="p-1.5 rounded hover:bg-ink/10 transition-colors cursor-pointer"
96
- title="Increase font size"
97
- >
98
- <ZoomIn size={15} />
99
- </button>
97
+ <button
98
+ type="button"
99
+ onClick={onZoomIn}
100
+ className="p-1.5 rounded hover:bg-ink/10 transition-colors cursor-pointer"
101
+ title="Increase font size"
102
+ >
103
+ <ZoomIn size={15} />
104
+ </button>
105
+ </>
106
+ )}
100
107
 
101
108
  <button
102
109
  type="button"
@@ -40,6 +40,7 @@ const DEFAULT_SETTINGS: SettingsState = {
40
40
  editorFont: 'JetBrains Mono',
41
41
  streamResponses: true,
42
42
  streamTransport: 'websocket',
43
+ autoSessionTitle: true,
43
44
  expandedThinking: true,
44
45
  detailedToolCalls: false,
45
46
  notificationsEnabled: true,
@@ -1,6 +1,7 @@
1
1
  import type { SettingsState } from '@/shared/types';
2
2
  import { ChatFollowUpSection } from '@/client/components/settings/categories/chat-settings/FollowUpSection';
3
3
  import { ChatKeybindingsSection } from '@/client/components/settings/categories/chat-settings/KeybindingsSection';
4
+ import { ChatTitleSection } from '@/client/components/settings/categories/chat-settings/TitleSection';
4
5
  import { ChatTransportSection } from '@/client/components/settings/categories/chat-settings/TransportSection';
5
6
 
6
7
  interface ChatSettingsProps {
@@ -14,10 +15,13 @@ export function ChatSettings({ settings, onUpdate }: ChatSettingsProps) {
14
15
  {/* 1. Streaming Transport (WebSocket vs SSE) */}
15
16
  <ChatTransportSection settings={settings} onUpdate={onUpdate} />
16
17
 
17
- {/* 2. Follow-up Behavior (Interactive Selector Cards) */}
18
+ {/* 2. Session Titles (automatic generation) */}
19
+ <ChatTitleSection settings={settings} onUpdate={onUpdate} />
20
+
21
+ {/* 3. Follow-up Behavior (Interactive Selector Cards) */}
18
22
  <ChatFollowUpSection settings={settings} onUpdate={onUpdate} />
19
23
 
20
- {/* 3. Keyboard Shortcuts Configurator */}
24
+ {/* 4. Keyboard Shortcuts Configurator */}
21
25
  <ChatKeybindingsSection settings={settings} onUpdate={onUpdate} />
22
26
  </div>
23
27
  );
@@ -0,0 +1,99 @@
1
+ import { Check, Sparkles, Type } from 'lucide-preact';
2
+ import type { SettingsState } from '@/shared/types';
3
+
4
+ interface ChatTitleSectionProps {
5
+ settings: SettingsState;
6
+ onUpdate: (updater: Partial<SettingsState> | ((prev: SettingsState) => SettingsState)) => void;
7
+ }
8
+
9
+ const OPTIONS: Array<{
10
+ enabled: boolean;
11
+ label: string;
12
+ badge: string;
13
+ icon: typeof Sparkles;
14
+ description: string;
15
+ note: string;
16
+ }> = [
17
+ {
18
+ enabled: true,
19
+ label: 'Generate Automatically',
20
+ badge: 'Default',
21
+ icon: Sparkles,
22
+ description:
23
+ 'After the first run settles, the session is named from the conversation using omp\'s own tiny title model. Sessions stop showing "New Session - <timestamp>" in the sidebar.',
24
+ note: 'One small model call per session',
25
+ },
26
+ {
27
+ enabled: false,
28
+ label: 'Manual Only',
29
+ badge: 'Opt-out',
30
+ icon: Type,
31
+ description:
32
+ 'Sessions keep their timestamp placeholder until you rename them from the sidebar. Choose this when every title should be deliberate, or to avoid the extra model call.',
33
+ note: 'No background model calls',
34
+ },
35
+ ];
36
+
37
+ export function ChatTitleSection({ settings, onUpdate }: ChatTitleSectionProps) {
38
+ const active = settings.autoSessionTitle ?? true;
39
+
40
+ return (
41
+ <section className="space-y-3">
42
+ <div>
43
+ <h4 className="text-sm font-semibold text-ink">Session Titles</h4>
44
+ <p className="text-[11px] text-ink/60 mt-0.5">
45
+ How a session gets its display name. A name you set yourself is never replaced.
46
+ </p>
47
+ </div>
48
+
49
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
50
+ {OPTIONS.map(({ enabled, label, badge, icon: Icon, description, note }) => {
51
+ const selected = active === enabled;
52
+ const select = () => onUpdate({ autoSessionTitle: enabled });
53
+ return (
54
+ <div
55
+ key={label}
56
+ onClick={select}
57
+ role="button"
58
+ tabIndex={0}
59
+ onKeyDown={(e) => e.key === 'Enter' && select()}
60
+ className={`p-3.5 rounded-lg border text-left transition-all cursor-pointer flex flex-col justify-between select-none ${
61
+ selected
62
+ ? 'border-ink bg-ink/5 ring-1 ring-ink/20 shadow-xs'
63
+ : 'border-ink/15 bg-paper hover:border-ink/30 hover:bg-ink/[0.02]'
64
+ }`}
65
+ >
66
+ <div>
67
+ <div className="flex items-center justify-between mb-2">
68
+ <div className="flex items-center gap-2">
69
+ <div
70
+ className={`w-7 h-7 rounded-md flex items-center justify-center ${
71
+ selected ? 'bg-ink text-paper' : 'bg-ink/5 text-ink/70'
72
+ }`}
73
+ >
74
+ <Icon size={15} />
75
+ </div>
76
+ <span className="font-semibold text-xs text-ink">{label}</span>
77
+ </div>
78
+ <span className="text-[10px] font-mono uppercase px-1.5 py-0.5 rounded bg-ink/10 text-ink/70 font-medium">
79
+ {badge}
80
+ </span>
81
+ </div>
82
+ <p className="text-[11px] text-ink/70 leading-relaxed">{description}</p>
83
+ </div>
84
+
85
+ <div className="mt-3 pt-2.5 border-t border-ink/10 flex items-center justify-between text-[11px]">
86
+ <span className="text-ink/50">{note}</span>
87
+ {selected && (
88
+ <span className="flex items-center gap-1 text-ink font-medium">
89
+ <Check size={12} /> Active
90
+ </span>
91
+ )}
92
+ </div>
93
+ </div>
94
+ );
95
+ })}
96
+ </div>
97
+ </section>
98
+ );
99
+ }