ompchamber 1.0.0 → 1.2.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 (233) 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.0d6ae50fe8.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.57b39ffa7f.js +34 -0
  34. package/package.json +1 -1
  35. package/src/client/components/common/ComposerPicker.tsx +16 -4
  36. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  37. package/src/client/components/common/file-icon/index.tsx +4 -1
  38. package/src/client/components/common/image-viewer/index.tsx +224 -0
  39. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  40. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  41. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  42. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  43. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  44. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  45. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  46. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  47. package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
  48. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  49. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
  50. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
  51. package/src/client/components/settings/Modal.tsx +1 -0
  52. package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
  53. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
  54. package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
  55. package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
  56. package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
  57. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
  58. package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
  59. package/src/client/components/workspace/btw-panel/index.tsx +83 -0
  60. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +39 -20
  61. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  62. package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
  63. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  64. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
  65. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
  66. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
  67. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +67 -43
  68. package/src/client/components/workspace/chat-timeline/index.tsx +14 -9
  69. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  70. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
  71. package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
  72. package/src/client/components/workspace/editor/index.tsx +45 -36
  73. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  74. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  75. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  76. package/src/client/hooks/chat/btw/index.ts +180 -0
  77. package/src/client/hooks/chat/btw/intercept.ts +43 -0
  78. package/src/client/hooks/chat/btw/mode.ts +179 -0
  79. package/src/client/hooks/chat/composer/attachments.ts +198 -0
  80. package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
  81. package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
  82. package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
  83. package/src/client/hooks/chat/composer/drop-references.ts +106 -0
  84. package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
  85. package/src/client/hooks/chat/composer/file-drop.ts +226 -0
  86. package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
  87. package/src/client/hooks/chat/composer/file-reads.ts +238 -0
  88. package/src/client/hooks/chat/composer/index.ts +35 -9
  89. package/src/client/hooks/chat/composer/pipeline.ts +181 -0
  90. package/src/client/hooks/chat/omp/index.ts +3 -3
  91. package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
  92. package/src/client/hooks/chat/timeline/actions.ts +40 -4
  93. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  94. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  95. package/src/client/hooks/chat/timeline/index.ts +29 -9
  96. package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
  97. package/src/client/hooks/chat/timeline/send.ts +45 -70
  98. package/src/client/hooks/chat/timeline/session-load.ts +2 -1
  99. package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
  100. package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
  101. package/src/client/hooks/editor/file-actions.ts +49 -0
  102. package/src/client/hooks/editor/use-file-editor.ts +38 -27
  103. package/src/client/hooks/editor/use-file-transfer.ts +78 -0
  104. package/src/client/hooks/workspace/available-width.ts +33 -0
  105. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  106. package/src/client/tailwind.css +22 -0
  107. package/src/server/db.server.ts +21 -287
  108. package/src/server/lib/btw/frames.test.ts +68 -0
  109. package/src/server/lib/btw/frames.ts +56 -0
  110. package/src/server/lib/btw/prompt.ts +28 -0
  111. package/src/server/lib/btw/registry.server.ts +141 -0
  112. package/src/server/lib/btw/runtime.server.ts +324 -0
  113. package/src/server/lib/btw/schema.server.ts +44 -0
  114. package/src/server/lib/btw/service.server.ts +171 -0
  115. package/src/server/lib/btw/session-copy.server.ts +213 -0
  116. package/src/server/lib/btw/session-copy.test.ts +166 -0
  117. package/src/server/lib/btw/store.server.ts +240 -0
  118. package/src/server/lib/db/schema.ts +110 -0
  119. package/src/server/lib/db/seed.ts +160 -0
  120. package/src/server/lib/db/workspace-sync.ts +70 -0
  121. package/src/server/lib/fs/root.ts +37 -0
  122. package/src/server/lib/models/preferences.server.ts +110 -0
  123. package/src/server/lib/models/registry.server.ts +206 -0
  124. package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
  125. package/src/server/lib/omp/rpc/manager.ts +27 -123
  126. package/src/server/lib/omp/rpc/utility.ts +7 -1
  127. package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
  128. package/src/server/lib/omp/session/auto-title.server.ts +152 -0
  129. package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
  130. package/src/server/lib/omp/session/blobs.server.ts +76 -0
  131. package/src/server/lib/omp/session/file-scan.ts +174 -0
  132. package/src/server/lib/omp/session/files.test.ts +147 -0
  133. package/src/server/lib/omp/session/files.ts +52 -35
  134. package/src/server/lib/omp/session/locator.ts +2 -2
  135. package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
  136. package/src/server/lib/omp/session/merge-stored.ts +171 -0
  137. package/src/server/lib/omp/session/messages.ts +34 -0
  138. package/src/server/lib/omp/session/reader.ts +2 -2
  139. package/src/server/lib/omp/session/subagent-presence.ts +9 -6
  140. package/src/server/lib/queue/delivery.server.test.ts +260 -0
  141. package/src/server/lib/queue/delivery.server.ts +63 -11
  142. package/src/server/plugins/ssr.ts +29 -2
  143. package/src/server/routes/btw/events.ts +70 -0
  144. package/src/server/routes/btw/index.ts +8 -0
  145. package/src/server/routes/btw/topics.ts +87 -0
  146. package/src/server/routes/btw/ws.ts +136 -0
  147. package/src/server/routes/chat/index.ts +2 -0
  148. package/src/server/routes/chat/session.ts +1 -115
  149. package/src/server/routes/chat/turns.ts +87 -0
  150. package/src/server/routes/fs/index.ts +4 -1
  151. package/src/server/routes/fs/raw.ts +85 -0
  152. package/src/server/routes/fs/read.ts +58 -3
  153. package/src/server/routes/index.ts +7 -1
  154. package/src/server/routes/models/root.ts +88 -215
  155. package/src/server/routes/settings/commands.ts +59 -18
  156. package/src/shared/lib/chat/attachments.test.ts +208 -0
  157. package/src/shared/lib/chat/attachments.ts +200 -15
  158. package/src/shared/lib/chat/btw/client.ts +94 -0
  159. package/src/shared/lib/chat/composer/client.test.ts +48 -0
  160. package/src/shared/lib/chat/composer/client.ts +23 -9
  161. package/src/shared/lib/chat/composer/commands.ts +290 -0
  162. package/src/shared/lib/chat/composer/composer.test.ts +195 -0
  163. package/src/shared/lib/chat/composer/filter.ts +21 -7
  164. package/src/shared/lib/chat/composer/trigger.ts +154 -21
  165. package/src/shared/lib/chat/omp/agent-events.ts +33 -80
  166. package/src/shared/lib/chat/omp/socket.ts +11 -6
  167. package/src/shared/lib/chat/omp/sse.ts +11 -6
  168. package/src/shared/lib/chat/omp/tool-results.ts +102 -0
  169. package/src/shared/lib/chat/omp/transport.ts +19 -3
  170. package/src/shared/lib/chat/text-file.ts +148 -0
  171. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
  172. package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
  173. package/src/shared/lib/chat/timeline/turns.ts +7 -0
  174. package/src/shared/lib/fs/file-kind.ts +50 -0
  175. package/src/shared/lib/fs/paths.ts +19 -1
  176. package/src/shared/lib/markdown/math.test.ts +20 -3
  177. package/src/shared/lib/models/preferences.test.ts +131 -0
  178. package/src/shared/lib/models/preferences.ts +88 -0
  179. package/src/shared/lib/models/provider-models.ts +8 -1
  180. package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
  181. package/src/shared/lib/omp/session/mapper.ts +24 -4
  182. package/src/shared/lib/omp/session/messages-map.ts +16 -3
  183. package/src/shared/lib/omp/session/messages-parse.ts +2 -2
  184. package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
  185. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  186. package/src/shared/lib/omp/subagent/label.ts +84 -0
  187. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  188. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  189. package/src/shared/lib/workspace/right-panels.ts +50 -11
  190. package/src/shared/lib/workspace/session-state/store.ts +3 -0
  191. package/src/shared/types/btw.ts +65 -0
  192. package/src/shared/types/chat.ts +77 -66
  193. package/src/shared/types/index.ts +2 -0
  194. package/src/shared/types/models.ts +101 -0
  195. package/src/shared/types/omp/agent.ts +14 -1
  196. package/src/shared/types/omp/session.ts +4 -1
  197. package/src/shared/types/omp/subagent.ts +4 -0
  198. package/src/shared/types/settings/command.ts +13 -0
  199. package/src/shared/types/settings/state.ts +5 -0
  200. package/dist/client/static/css/index.e880517fcd.css +0 -2
  201. package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
  202. package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
  203. package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
  204. package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
  205. package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
  206. package/dist/client/static/js/async/e.93214d622b.js +0 -79
  207. package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
  208. package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
  209. package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
  210. package/dist/client/static/js/async/jl.874d352278.js +0 -1
  211. package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
  212. package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
  213. package/dist/client/static/js/async/q.23af2a358f.js +0 -21
  214. package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
  215. package/dist/client/static/js/async/s.222ee974f6.js +0 -2
  216. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  217. package/dist/client/static/js/async/vt.42416b707d.js +0 -1
  218. package/dist/client/static/js/async/x1.da8687159b.js +0 -90
  219. package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
  220. package/dist/client/static/js/index.0de15c5e17.js +0 -33
  221. package/dist/client/static/js/jp.50817896f3.js +0 -139
  222. /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
  223. /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
  224. /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
  225. /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
  226. /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
  227. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
  228. /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
  229. /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
  230. /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
  231. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
  232. /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
  233. /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
@@ -1,13 +1,40 @@
1
1
  import { useState } from 'preact/hooks';
2
+ import type { UserTurnRef } from '@/shared/types';
3
+ import { formatMessageStamp } from '@/shared/lib/format/time';
2
4
 
3
5
  interface MinimapShortcutsProps {
4
- userMessages: any[];
5
- onScrollTo: (id: string) => void;
6
+ /** Every user turn of the session, oldest first — not just the mounted
7
+ * window, so the rail lists the whole conversation while the timeline holds
8
+ * one page of it. */
9
+ turns: UserTurnRef[];
10
+ /** A jump is paging history to reach the clicked turn. */
11
+ jumping: boolean;
12
+ onJumpTurn: (turn: UserTurnRef) => void;
6
13
  }
7
14
 
8
- export function MinimapShortcuts({ userMessages, onScrollTo }: MinimapShortcutsProps) {
15
+ /** Row pitch the rail keeps while it fits: a 1px line with 3px of breathing
16
+ * room on each side — the spacing the hover wave was drawn around. */
17
+ const ROW_PITCH_MAX = '7px';
18
+ /** Rows never collapse below this, so a tick stays clickable. */
19
+ const ROW_PITCH_MIN = '2px';
20
+ /** Target ceiling for the rail as a share of the viewport: a session long
21
+ * enough to overflow it tightens its rows rather than growing past the
22
+ * timeline. Past the minimum pitch the class-level `max-h` takes over, so a
23
+ * session with hundreds of turns still keeps every tick on screen. */
24
+ const RAIL_TARGET_HEIGHT = '60vh';
25
+
26
+ /**
27
+ * Jump rail: one tick per user turn of the FULL history. A turn outside the
28
+ * mounted window is still clickable — the click pages older windows in until
29
+ * that row exists, then centers it.
30
+ */
31
+ export function MinimapShortcuts({ turns, jumping, onJumpTurn }: MinimapShortcutsProps) {
9
32
  const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
10
- if (userMessages.length === 0) return null;
33
+ if (turns.length === 0) return null;
34
+
35
+ // Rows are laid out in normal flow (never absolutely positioned), so their
36
+ // hit boxes cannot overlap and each tick keeps its own hover.
37
+ const rowPitch = `clamp(${ROW_PITCH_MIN}, calc(${RAIL_TARGET_HEIGHT} / ${turns.length}), ${ROW_PITCH_MAX})`;
11
38
 
12
39
  const lineWidth = (idx: number): string => {
13
40
  if (hoveredIdx === null) return 'w-3';
@@ -19,25 +46,35 @@ export function MinimapShortcuts({ userMessages, onScrollTo }: MinimapShortcutsP
19
46
 
20
47
  return (
21
48
  <div
22
- className="absolute right-0.5 top-1/2 -translate-y-1/2 z-20 flex flex-col items-center py-4 w-6"
49
+ className={`absolute right-0.5 top-1/2 -translate-y-1/2 z-20 flex flex-col items-center py-4 w-6 max-h-[min(60vh,100%)] ${jumping ? 'animate-pulse' : ''}`}
50
+ role="navigation"
51
+ aria-label={`Jump to your message (${turns.length} turns)`}
23
52
  onMouseLeave={() => setHoveredIdx(null)}
24
53
  >
25
- {userMessages.map((msg, idx) => {
54
+ {turns.map((turn, idx) => {
26
55
  const isHovered = hoveredIdx === idx;
27
56
  return (
28
57
  <div
29
- key={`minimap-${msg.id}`}
30
- className="relative flex items-center justify-center w-full py-[3px] cursor-pointer"
58
+ key={`minimap-${turn.id}`}
59
+ className="relative flex min-h-0 items-center justify-center w-full cursor-pointer"
60
+ style={{ height: rowPitch }}
31
61
  onMouseEnter={() => setHoveredIdx(idx)}
32
- onClick={() => onScrollTo(msg.id)}
62
+ onClick={() => onJumpTurn(turn)}
33
63
  >
34
64
  <button
65
+ type="button"
35
66
  className={`block h-px rounded-full transition-all duration-200 ease-out ${lineWidth(idx)} ${isHovered ? 'bg-ink h-[2px]' : 'bg-ink/25'}`}
36
67
  aria-label={`Jump to your message #${idx + 1}`}
37
68
  />
38
- <div className={`absolute right-full mr-3 top-1/2 transform -translate-y-1/2 w-64 p-3 bg-ink text-canvas text-xs rounded-md shadow-lg pointer-events-none transition-opacity duration-200 z-30 ${isHovered ? 'opacity-100' : 'opacity-0'}`}>
39
- <div className="line-clamp-3 whitespace-pre-wrap leading-relaxed">{msg.content}</div>
40
- <div className="absolute top-1/2 -right-1 transform -translate-y-1/2 w-2 h-2 bg-ink rotate-45"></div>
69
+ <div
70
+ className={`absolute right-full mr-3 top-1/2 -translate-y-1/2 w-64 p-3 bg-ink text-canvas text-xs rounded-md shadow-lg pointer-events-none transition-opacity duration-200 z-30 ${isHovered ? 'opacity-100' : 'opacity-0'}`}
71
+ >
72
+ <div className="flex items-center justify-between gap-2 pb-1.5 mb-1.5 border-b border-canvas/20 font-mono text-[10px] uppercase tracking-wider text-canvas/60">
73
+ <span>#{idx + 1} of {turns.length}</span>
74
+ {formatMessageStamp(turn) && <span>{formatMessageStamp(turn)}</span>}
75
+ </div>
76
+ <div className="line-clamp-3 whitespace-pre-wrap leading-relaxed">{turn.preview.trim() || '(no text)'}</div>
77
+ <div className="absolute top-1/2 -right-1 -translate-y-1/2 w-2 h-2 bg-ink rotate-45" />
41
78
  </div>
42
79
  </div>
43
80
  );
@@ -8,6 +8,7 @@ import { RunFooter } from '@/client/components/workspace/chat-timeline/RunFooter
8
8
  import { useSubagentTranscript } from '@/client/hooks/chat/subagent';
9
9
  import { useModelNames } from '@/client/hooks/models/use-model-names';
10
10
  import { isNoticeRow } from '@/shared/lib/chat/notice-row';
11
+ import { subagentHeaderTitle } from '@/shared/lib/omp/subagent/label';
11
12
  import { previousNonNoticeIndex, resolveRunFooters, streamingRowIndex } from '@/shared/lib/chat/timeline/run-footer';
12
13
  import { formatCompactTokens } from '@/shared/lib/format/number';
13
14
 
@@ -62,6 +63,7 @@ export function SubagentView({ sessionId, subagent, onBack, provider, providerNa
62
63
 
63
64
  const progress = status?.progress ?? subagent.progress;
64
65
  const modelName = progress?.resolvedModel ?? subagent.agent;
66
+ const headerTitle = subagentHeaderTitle(status ?? subagent);
65
67
  const compactTokens = formatCompactTokens(progress?.tokens);
66
68
  const meta = compactTokens ? `${compactTokens} tok` : '';
67
69
  const rawTask = status?.task ?? status?.assignment ?? status?.description
@@ -85,7 +87,7 @@ export function SubagentView({ sessionId, subagent, onBack, provider, providerNa
85
87
  <div className="min-w-0 flex-1">
86
88
  <div className="flex items-center gap-2 min-w-0">
87
89
  <span className="font-mono text-[12px] font-semibold text-ink truncate">
88
- {status?.agent ?? subagent.agent}
90
+ {headerTitle}
89
91
  </span>
90
92
  <span className="shrink-0 rounded bg-ink/5 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-ink/50">
91
93
  {STATUS_LABEL[effectiveStatus]}
@@ -3,12 +3,15 @@ import { ArrowDown } from 'lucide-preact';
3
3
  import { MessageList } from '@/client/components/workspace/chat-timeline/MessageList';
4
4
  import { MinimapShortcuts } from '@/client/components/workspace/chat-timeline/MinimapShortcuts';
5
5
  import { LoadingOlderIndicator } from '@/client/components/workspace/chat-timeline/SessionSkeleton';
6
- import type { ChatMessageData } from '@/shared/types';
6
+ import type { ChatMessageData, UserTurnRef } from '@/shared/types';
7
7
 
8
8
  interface TimelineBodyProps {
9
9
  isMobile: boolean;
10
- userMessages: ChatMessageData[];
11
- onScrollTo: (id: string) => void;
10
+ /** Every user turn of the session (full history), for the jump rail. */
11
+ userTurns: UserTurnRef[];
12
+ /** A jump is paging history to reach the clicked turn. */
13
+ jumpingTurn: boolean;
14
+ onJumpTurn: (turn: UserTurnRef) => void;
12
15
  scrollRef: RefObject<HTMLDivElement>;
13
16
  contentRef: (node: HTMLDivElement | null) => void;
14
17
  handleScroll: () => void;
@@ -38,8 +41,9 @@ interface TimelineBodyProps {
38
41
  */
39
42
  export function TimelineBody({
40
43
  isMobile,
41
- userMessages,
42
- onScrollTo,
44
+ userTurns,
45
+ jumpingTurn,
46
+ onJumpTurn,
43
47
  scrollRef,
44
48
  contentRef,
45
49
  handleScroll,
@@ -66,7 +70,7 @@ export function TimelineBody({
66
70
  {/* Minimap Shortcuts — desktop only: the rail needs side room a
67
71
  phone does not have. */}
68
72
  {!isMobile && (
69
- <MinimapShortcuts userMessages={userMessages} onScrollTo={onScrollTo} />
73
+ <MinimapShortcuts turns={userTurns} jumping={jumpingTurn} onJumpTurn={onJumpTurn} />
70
74
  )}
71
75
 
72
76
  {/* Timeline Body */}
@@ -2,6 +2,7 @@ import { useRef } from 'preact/hooks';
2
2
  import type { ChangeEvent } from 'preact/compat';
3
3
  import { File as FileIcon, Paperclip, X } from 'lucide-preact';
4
4
  import type { Attachment } from '@/shared/types';
5
+ import { attachmentName } from '@/shared/lib/chat/attachments';
5
6
 
6
7
  interface AttachmentToolbarProps {
7
8
  attachments: Attachment[];
@@ -39,7 +40,7 @@ export function AttachmentToolbar({ attachments, onFilesSelected, onRemove }: At
39
40
  <FileIcon size={12} />
40
41
  </div>
41
42
  )}
42
- <span className="truncate max-w-[120px] font-mono text-[10px] text-ink/80">{att.file.name}</span>
43
+ <span className="truncate max-w-[120px] font-mono text-[10px] text-ink/80">{attachmentName(att)}</span>
43
44
  <button
44
45
  onClick={() => onRemove(att.id)}
45
46
  className="absolute right-0.5 top-1/2 -translate-y-1/2 p-0.5 text-ink/40 hover:text-error hover:bg-error/10 rounded transition-colors"
@@ -0,0 +1,17 @@
1
+ import { UploadCloud } from 'lucide-preact';
2
+
3
+ /**
4
+ * Covers the composer while a file drag hovers it. `pointer-events-none` is
5
+ * load-bearing: the overlay sits above the card, so without it the drop would
6
+ * land on the overlay and never reach the card's own handler.
7
+ */
8
+ export function DropOverlay({ visible }: { visible: boolean }) {
9
+ if (!visible) return null;
10
+ return (
11
+ <div className="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 rounded-md border-2 border-dashed border-ink/40 bg-canvas/90 text-ink">
12
+ <UploadCloud size={18} className="text-ink/60" />
13
+ <span className="text-[11px] font-semibold">Drop files to attach</span>
14
+ <span className="text-[10px] text-ink/50">Folders are expanded into their files</span>
15
+ </div>
16
+ );
17
+ }
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
2
2
  import type { ClipboardEvent, SetStateAction } from 'preact/compat';
3
+ import { AlertTriangle, X } from 'lucide-preact';
3
4
  import type { AIModelOption, Attachment, ModelEntry } from '@/shared/types';
4
5
  import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
5
6
  import { ComposerToolbar } from '@/client/components/workspace/chat-timeline/chat-input/Toolbar';
@@ -8,6 +9,10 @@ import { AttachmentToolbar } from '@/client/components/workspace/chat-timeline/c
8
9
  import { selectableThinkingLevels } from '@/shared/lib/models/thinking-levels';
9
10
  import { fetchModelsData, subscribeModelsUpdated } from '@/shared/lib/models/client';
10
11
  import { resolveThinkingLevel, selectionFor } from '@/client/components/workspace/chat-timeline/chat-input/selection';
12
+ import { NO_PENDING_PICK } from '@/client/hooks/chat/timeline/deferred-model';
13
+ import { primeFileReads } from '@/client/hooks/chat/composer/file-reads';
14
+ import { useComposerPipeline } from '@/client/hooks/chat/composer/pipeline';
15
+ import { DropOverlay } from '@/client/components/workspace/chat-timeline/chat-input/DropOverlay';
11
16
 
12
17
  export function ChatInput({
13
18
  value,
@@ -31,6 +36,12 @@ export function ChatInput({
31
36
  /** Written on every model/thinking pick so the send path can snapshot the
32
37
  * selection into queued items without lifting ChatInput state. */
33
38
  composerModelRef,
39
+ /** A pick the user made while a turn was streaming, still waiting for the
40
+ * next prompt. While set, the session's own model must NOT be adopted over
41
+ * it — the composer shows what the next prompt will actually run. Omitted
42
+ * by composers that never stream (the New Chat modal), where there is
43
+ * nothing to defer and adoption must behave as before. */
44
+ deferredComposerPickRef = NO_PENDING_PICK,
34
45
  }: {
35
46
  value: string;
36
47
  onChange: (v: string) => void;
@@ -58,6 +69,7 @@ export function ChatInput({
58
69
  */
59
70
  variant?: 'desktop' | 'mobile';
60
71
  composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
72
+ deferredComposerPickRef?: { current: { provider?: string; modelId?: string; thinkingLevel?: string } | null };
61
73
  }) {
62
74
  const [internalAttachments, setInternalAttachments] = useState<Attachment[]>([]);
63
75
 
@@ -102,12 +114,18 @@ export function ChatInput({
102
114
  // this effect re-runs right after a spawn (sessionModel changes while the
103
115
  // session's own thinking_level_change entry is not readable yet), and any
104
116
  // concrete fallback here claims a level the run never used.
117
+ //
118
+ // A pick still waiting for the next prompt wins: the session's model is what
119
+ // the RUNNING turn uses, and adopting it would erase the user's choice from
120
+ // the composer even though it is what the next prompt will run with.
105
121
  useEffect(() => {
106
122
  if (!sessionModel?.provider || !sessionModel.modelId) return;
123
+ if (deferredComposerPickRef.current) return;
107
124
  let active = true;
108
125
  fetchModelsData()
109
126
  .then((data) => {
110
127
  if (!active || !sessionModel) return;
128
+ if (deferredComposerPickRef.current) return;
111
129
  const match = (data.modelList || []).find(
112
130
  (m) => m.id === sessionModel.modelId && m.provider === sessionModel.provider
113
131
  );
@@ -119,18 +137,21 @@ export function ChatInput({
119
137
  return () => {
120
138
  active = false;
121
139
  };
122
- }, [sessionModel?.provider, sessionModel?.modelId]);
140
+ }, [sessionModel?.provider, sessionModel?.modelId, deferredComposerPickRef]);
123
141
 
124
142
  // Adopt the active session's last-used thinking level. Runs after the model
125
143
  // sync effect above so it overrides the catalog default for this session.
144
+ // Same rule as the model: a pick waiting for the next prompt is not
145
+ // overridden by the level the running turn recorded.
126
146
  useEffect(() => {
127
147
  if (!sessionThinkingLevel) return;
148
+ if (deferredComposerPickRef.current?.thinkingLevel) return;
128
149
  setSelectedModel(prev => {
129
150
  if (!prev) return prev;
130
151
  const level = resolveThinkingLevel(prev.thinkingLevels, sessionThinkingLevel, prev.thinkingLevel);
131
152
  return level === prev.thinkingLevel ? prev : { ...prev, thinkingLevel: level };
132
153
  });
133
- }, [sessionThinkingLevel]);
154
+ }, [sessionThinkingLevel, deferredComposerPickRef]);
134
155
 
135
156
  // Sync selected model from server and event listener
136
157
  useEffect(() => {
@@ -139,6 +160,9 @@ export function ChatInput({
139
160
  try {
140
161
  const data = await fetchModelsData();
141
162
  if (!active) return;
163
+ // A pick waiting for the next prompt owns the composer's selection:
164
+ // every branch below would replace it with a session/catalog default.
165
+ if (deferredComposerPickRef.current) return;
142
166
  if (Array.isArray(data.modelList) && data.modelList.length > 0) {
143
167
  // 1. The active session's last-used model is authoritative; even when
144
168
  // it is missing from the catalog, never replace it with the default.
@@ -183,7 +207,7 @@ export function ChatInput({
183
207
  active = false;
184
208
  unsubscribe();
185
209
  };
186
- }, []);
210
+ }, [deferredComposerPickRef]);
187
211
 
188
212
  // Thinking Dropdown State - Reactive with selectedModel
189
213
  const thinkingLevels = useMemo(() => {
@@ -210,61 +234,61 @@ export function ChatInput({
210
234
  if (clipboard.files.length > 0) {
211
235
  e.preventDefault();
212
236
  const files = Array.from(clipboard.files);
213
- addFiles(files);
237
+ // Same rule as a drop: the clipboard's file permission is released when
238
+ // this handler returns, so the reads are started here, in its tick.
239
+ acceptFiles(files, false, [], primeFileReads(files));
214
240
  }
215
241
  };
216
242
 
217
- const addFiles = (files: File[]) => {
218
- const newAttachments = files.map(file => ({
219
- id: Math.random().toString(36).substring(7),
220
- file,
221
- preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : ''
222
- }));
223
- setAttachments(prev => [...prev, ...newAttachments]);
224
- // Read image payloads as base64 so the omp model can receive them.
225
- for (const att of newAttachments) {
226
- if (!att.file.type.startsWith('image/')) continue;
227
- const reader = new FileReader();
228
- reader.onload = () => {
229
- const result = reader.result as string;
230
- const base64 = result.split(',')[1];
231
- if (!base64) return;
232
- setAttachments(prev => prev.map(a => (a.id === att.id ? { ...a, dataBase64: base64 } : a)));
233
- };
234
- reader.readAsDataURL(att.file);
235
- }
236
- };
237
-
238
- const removeAttachment = (id: string) => {
239
- setAttachments(prev => {
240
- const att = prev.find(p => p.id === id);
241
- if (att && att.preview) URL.revokeObjectURL(att.preview);
242
- return prev.filter(p => p.id !== id);
243
- });
244
- };
245
-
246
- const handleSendClick = (options?: { steering?: boolean }) => {
247
- if (!value.trim() && attachments.length === 0) return;
248
- onSend(attachments, options);
249
- setAttachments([]);
250
- };
243
+ // Attach-and-send pipeline: drop handling, the inline notice, and the send
244
+ // guard all live in the hook, because a drop registers files before this
245
+ // component re-renders and the send decision cannot read a stale prop.
246
+ const { notice, dismissNotice, isDragging, dropProps, acceptFiles, removeAttachment, submit } = useComposerPipeline({
247
+ attachments,
248
+ setAttachments,
249
+ value,
250
+ disabled,
251
+ rootPath,
252
+ onSend,
253
+ });
251
254
 
252
255
  const isMobile = variant === 'mobile';
253
256
 
254
257
  return (
255
- <div className={`relative border border-ink/20 rounded-md bg-paper focus-within:border-ink transition-colors flex flex-col shadow-sm ${className}`}>
256
-
258
+ <div
259
+ className={`relative border rounded-md bg-paper transition-colors flex flex-col shadow-sm ${
260
+ isDragging ? 'border-ink/40' : 'border-ink/20 focus-within:border-ink'
261
+ } ${className}`}
262
+ {...dropProps}
263
+ >
264
+ <DropOverlay visible={isDragging} />
265
+
257
266
  <AttachmentToolbar
258
267
  attachments={attachments}
259
- onFilesSelected={addFiles}
268
+ onFilesSelected={(files) => acceptFiles(files)}
260
269
  onRemove={removeAttachment}
261
270
  />
262
271
 
272
+ {notice && (
273
+ <div className="flex items-start gap-1.5 border-b border-error/20 bg-error/5 px-3 py-1.5 text-[11px] text-error">
274
+ <AlertTriangle size={11} className="mt-0.5 shrink-0" />
275
+ <span className="min-w-0 flex-1 break-words">{notice}</span>
276
+ <button
277
+ type="button"
278
+ onClick={() => dismissNotice()}
279
+ className="shrink-0 text-error/60 hover:text-error transition-colors"
280
+ title="Dismiss"
281
+ >
282
+ <X size={11} />
283
+ </button>
284
+ </div>
285
+ )}
286
+
263
287
  {/* Textarea */}
264
288
  <ComposerTextarea
265
289
  value={value}
266
290
  onChange={onChange}
267
- onSend={handleSendClick}
291
+ onSend={submit}
268
292
  disabled={disabled}
269
293
  appSettings={appSettings}
270
294
  rootPath={rootPath}
@@ -290,7 +314,7 @@ export function ChatInput({
290
314
  onThinkingLevelChange={onThinkingLevelChange}
291
315
  isGenerating={isGenerating}
292
316
  onStop={onStop}
293
- onSend={() => handleSendClick()}
317
+ onSend={() => submit()}
294
318
  sendDisabled={disabled || (!value.trim() && attachments.length === 0)}
295
319
  accessMode={accessMode}
296
320
  onSelectAccess={onAccessModeChange}
@@ -3,6 +3,7 @@ import { useSearchParams } from '@/client/lib/router/search-params';
3
3
  import { ExtensionDialog } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/Lazy';
4
4
  import { AskFramesContext, splitAskFrames, type AskFramesHandle } from '@/client/hooks/chat/timeline/ask-frames';
5
5
  import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
6
+ import type { UserTurnRef } from '@/shared/types/chat';
6
7
  import type { ExtensionDialogResponse } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/Lazy';
7
8
  import { EmptyWorkspacePrompt } from '@/client/components/workspace/chat-timeline/EmptyWorkspacePrompt';
8
9
  import { SessionSkeleton } from '@/client/components/workspace/chat-timeline/SessionSkeleton';
@@ -47,6 +48,9 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
47
48
  hasMore,
48
49
  loadingOlder, loadOlderError, sessionLoading,
49
50
  loadOlder,
51
+ userTurns,
52
+ jumpingTurn,
53
+ jumpToUserTurn,
50
54
  generatingVerb,
51
55
  messageQueue,
52
56
  removeMessage,
@@ -75,6 +79,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
75
79
  accessMode,
76
80
  handleAccessModeChange,
77
81
  composerModelRef,
82
+ deferredComposerPickRef,
78
83
  extensionDialogs,
79
84
  resolveExtensionDialog,
80
85
  respondToExtensionUi,
@@ -126,18 +131,14 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
126
131
 
127
132
  useSessionTitle(sessionId, sessionData?.title, onSessionTitle);
128
133
 
129
- const handleScrollTo = useCallback((id: string) => {
130
- const el = document.getElementById(id);
131
- if (el) {
132
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
133
- }
134
- }, []);
134
+ const handleJumpTurn = useCallback((turn: UserTurnRef) => {
135
+ void jumpToUserTurn(turn.id, turn.index);
136
+ }, [jumpToUserTurn]);
135
137
 
136
138
  const handleNewChat = useCallback((content: string) => setNewChatInitialContent(content), []);
137
139
 
138
140
  const modelNames = useModelNames();
139
141
  const providerNames = useProviderNames();
140
- const userMessages = useMemo(() => localMessages.filter(m => m.role === 'user'), [localMessages]);
141
142
  const sessionProvider = typeof sessionData?.model === 'object' ? sessionData.model.provider : undefined;
142
143
  const sessionModelName = typeof sessionData?.model === 'object'
143
144
  ? (modelNames[sessionData.model.modelId] ?? sessionData.model.modelId)
@@ -180,6 +181,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
180
181
  accessMode={accessMode}
181
182
  onAccessModeChange={handleAccessModeChange}
182
183
  composerModelRef={composerModelRef}
184
+ deferredComposerPickRef={deferredComposerPickRef}
183
185
  sessionModel={typeof sessionData?.model === 'object' ? sessionData.model : null}
184
186
  sessionThinkingLevel={sessionData?.thinkingLevel}
185
187
  generatingVerb={generatingVerb}
@@ -208,8 +210,9 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
208
210
  ) : (
209
211
  <TimelineBody
210
212
  isMobile={isMobile}
211
- userMessages={userMessages}
212
- onScrollTo={handleScrollTo}
213
+ userTurns={userTurns}
214
+ jumpingTurn={jumpingTurn}
215
+ onJumpTurn={handleJumpTurn}
213
216
  scrollRef={scrollRef}
214
217
  contentRef={contentRef}
215
218
  handleScroll={handleScroll}
@@ -238,6 +241,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
238
241
  {!activeSubagent && (
239
242
  <ComposerDock
240
243
  isMobile={isMobile}
244
+ sessionId={sessionId}
241
245
  isGenerating={isGenerating}
242
246
  modelName={sessionModelName}
243
247
  generatingVerb={generatingVerb}
@@ -263,6 +267,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
263
267
  accessMode={accessMode}
264
268
  onAccessModeChange={handleAccessModeChange}
265
269
  composerModelRef={composerModelRef}
270
+ deferredComposerPickRef={deferredComposerPickRef}
266
271
  sessionModel={typeof sessionData?.model === 'object' ? sessionData.model : null}
267
272
  sessionThinkingLevel={sessionData?.thinkingLevel}
268
273
  variant={variant}
@@ -124,9 +124,9 @@ export function Goal({ tool }: { tool: ToolCallData }) {
124
124
  {payload.summary && (
125
125
  <div>
126
126
  <span className="text-[10px] font-mono text-ink/45 uppercase tracking-wider">Summary</span>
127
- <p className="mt-0.5 font-sans text-[12px] text-ink font-medium leading-relaxed">
128
- {payload.summary}
129
- </p>
127
+ <div className="mt-0.5 rounded border border-ink/6 bg-ink/[0.03] p-2">
128
+ <MarkdownRenderer content={payload.summary} className="font-medium" />
129
+ </div>
130
130
  </div>
131
131
  )}
132
132
 
@@ -1,9 +1,11 @@
1
1
  import { useEffect, useMemo, useState } from 'preact/hooks';
2
- import { FileCode, FileText, Folder, FolderOpen, Info, Loader2 } from 'lucide-preact';
2
+ import { FileCode, FileImage, FileText, Folder, FolderOpen, Info, Loader2 } from 'lucide-preact';
3
3
  import { CopyButton } from '@/client/components/common/CopyButton';
4
4
  import { getLanguageFromPath, highlightCode } from '@/shared/lib/code/syntax-highlight';
5
5
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
6
6
  import { parseDirListing, parseNumberedCode } from '@/shared/lib/code/parser';
7
+ import { getImageMimeType } from '@/shared/lib/fs/file-kind';
8
+ import { buildFsRawUrl } from '@/shared/lib/fs/paths';
7
9
  import type { ToolCallData } from '@/shared/types/chat';
8
10
  import { MAX_OUTPUT_LINES, truncateTailLines } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/truncate';
9
11
  import { extractLineMeta } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/read-line-meta';
@@ -23,9 +25,13 @@ export function Read({ tool, targetFilePath, output }: ReadPanelProps) {
23
25
  const inputPath = getToolInputPath(tool?.input);
24
26
  const filePath = targetFilePath || inputPath || tool?.target || '';
25
27
  const cleanFetchPath = filePath.split('?')[0].split('#')[0].replace(/:\d+(?:-\d+)?$/, '');
28
+ // An image read has no text to show: the panel paints the picture instead of
29
+ // the code surface, and `/api/fs/raw` is what carries its bytes.
30
+ const isImage = getImageMimeType(cleanFetchPath) !== null;
31
+ const imageUrl = isImage ? buildFsRawUrl({ path: cleanFetchPath }) : null;
26
32
 
27
33
  useEffect(() => {
28
- if (cleanFetchPath && !output && lazyContent === null && !loadingFile) {
34
+ if (cleanFetchPath && !isImage && !output && lazyContent === null && !loadingFile) {
29
35
  setLoadingFile(true);
30
36
  fetch(`/api/fs/read?path=${encodeURIComponent(cleanFetchPath)}`)
31
37
  .then((res) => res.json())
@@ -39,7 +45,7 @@ export function Read({ tool, targetFilePath, output }: ReadPanelProps) {
39
45
  setLoadingFile(false);
40
46
  });
41
47
  }
42
- }, [cleanFetchPath, output, lazyContent, loadingFile]);
48
+ }, [cleanFetchPath, output, lazyContent, loadingFile, isImage]);
43
49
 
44
50
  const details = (tool?.details ?? {}) as Record<string, any>;
45
51
  const rawContent =
@@ -107,6 +113,7 @@ export function Read({ tool, targetFilePath, output }: ReadPanelProps) {
107
113
 
108
114
  const isDir = dirInfo.isDirectory;
109
115
  const lang = getLanguageFromPath(filePath);
116
+ const kindLabel = isDir ? 'Directory' : isImage ? 'Image' : lang;
110
117
 
111
118
  const visibleEntries = dirInfo.entries.slice(-MAX_OUTPUT_LINES);
112
119
  const skippedEntries = dirInfo.entries.length - visibleEntries.length;
@@ -126,17 +133,17 @@ export function Read({ tool, targetFilePath, output }: ReadPanelProps) {
126
133
  <div className="flex items-center justify-between rounded-lg border border-ink/8 bg-paper px-3 py-2">
127
134
  <div className="flex min-w-0 items-center gap-2">
128
135
  <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-ink/5 text-ink/70">
129
- {isDir ? <FolderOpen size={12} /> : <FileCode size={12} />}
136
+ {isDir ? <FolderOpen size={12} /> : isImage ? <FileImage size={12} /> : <FileCode size={12} />}
130
137
  </span>
131
138
  <span className="truncate font-mono text-[11px] font-medium text-ink">
132
139
  {filePath || (isDir ? 'Directory Listing' : 'File Content')}
133
140
  </span>
134
141
  <span className="rounded bg-ink/5 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-ink/50">
135
- {isDir ? 'Directory' : lang}
142
+ {kindLabel}
136
143
  </span>
137
144
  </div>
138
145
 
139
- {rawContent && (
146
+ {rawContent && !isImage && (
140
147
  <CopyButton
141
148
  text={parsedCode.hasLineNumbers ? parsedCode.cleanCode : (rawContent || '')}
142
149
  className="flex shrink-0 cursor-pointer items-center gap-1 rounded px-1.5 py-0.5 text-[10.5px] text-ink/50 transition-colors hover:bg-ink/5 hover:text-ink"
@@ -146,7 +153,18 @@ export function Read({ tool, targetFilePath, output }: ReadPanelProps) {
146
153
  )}
147
154
  </div>
148
155
 
149
- {loadingFile ? (
156
+ {isImage && imageUrl ? (
157
+ /* An image read has no code to show: paint the bytes. */
158
+ <div className="overflow-hidden rounded-lg border border-ink/8 bg-paper">
159
+ <div className="flex max-h-80 items-center justify-center overflow-auto bg-canvas/60 p-3">
160
+ <img
161
+ src={imageUrl}
162
+ alt={cleanFetchPath}
163
+ className="max-h-72 max-w-full object-contain"
164
+ />
165
+ </div>
166
+ </div>
167
+ ) : loadingFile ? (
150
168
  <div className="flex items-center gap-2 rounded-lg border border-ink/8 bg-paper px-3 py-3 font-mono text-[11px] text-ink/50">
151
169
  <Loader2 size={12} className="animate-spin" />
152
170
  <span>Loading file content...</span>