ompchamber 1.1.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 (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.57b39ffa7f.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 +181 -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 +83 -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.57b39ffa7f.js.LICENSE.txt} +0 -0
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The side-question form — what the chat composer becomes while BTW mode is on.
8
+ *
9
+ * Stacked cards, top to bottom: the topic's turns (only once there is something
10
+ * to show, or an error to explain), the ask card (a question of its own), and
11
+ * the side session's composer (follow-ups, attachments, model chip). Nothing it
12
+ * produces enters the chat transcript — that is the point of a side question —
13
+ * and promotion is the one explicit action that moves an answer into the chat,
14
+ * as a session branched from it.
15
+ */
16
+
17
+ import { useState } from 'preact/hooks';
18
+ import type { BtwMode } from '@/client/hooks/chat/btw/mode';
19
+ import { BtwAskCard } from '@/client/components/workspace/btw-panel/AskCard';
20
+ import { BtwMessages } from '@/client/components/workspace/btw-panel/Messages';
21
+ import { BtwComposer } from '@/client/components/workspace/btw-panel/Composer';
22
+ import { btwImagesFrom, readBtwAttachments, type BtwAttachment } from '@/client/components/workspace/btw-panel/attachments';
23
+
24
+ export interface BtwFormProps {
25
+ mode: BtwMode;
26
+ /** The chat's own model, shown until the topic reports the one it ran on. */
27
+ modelName?: string;
28
+ provider?: string;
29
+ }
30
+
31
+ export function BtwForm({ mode, modelName, provider }: BtwFormProps) {
32
+ const [attachments, setAttachments] = useState<BtwAttachment[]>([]);
33
+ const lastTurn = mode.activeTopic?.turns[mode.activeTopic.turns.length - 1];
34
+ // `busy` is the command in flight (a promote POST, say): promoting again from
35
+ // the same click burst would be a second request for work already running.
36
+ const canPromote = Boolean(mode.activeTopic) && !mode.runningTopicId && !mode.busy && lastTurn?.status === 'complete';
37
+ const showTurns = (mode.activeTopic?.turns.length ?? 0) > 0 || Boolean(mode.error);
38
+
39
+ return (
40
+ <div className="flex flex-col gap-2">
41
+ {showTurns && (
42
+ <div className="bg-paper border border-ink/15 rounded-2xl shadow-xl flex flex-col max-h-[45vh] overflow-hidden">
43
+ <BtwMessages topic={mode.activeTopic} liveAnswer={mode.liveAnswer} error={mode.error} />
44
+ </div>
45
+ )}
46
+
47
+ <BtwAskCard
48
+ value={mode.askDraft}
49
+ onChange={mode.setAskDraft}
50
+ onSubmit={mode.submitAsk}
51
+ onNewQuestion={mode.resetQuestion}
52
+ onClose={mode.exit}
53
+ running={Boolean(mode.runningTopicId)}
54
+ topics={mode.topics}
55
+ activeTopicId={mode.activeTopic?.id ?? null}
56
+ onSelectTopic={mode.selectTopic}
57
+ onDeleteTopic={mode.removeTopic}
58
+ canPromote={canPromote}
59
+ onPromote={() => void mode.promote()}
60
+ />
61
+
62
+ <BtwComposer
63
+ value={mode.followUpDraft}
64
+ onChange={mode.setFollowUpDraft}
65
+ onSubmit={() => {
66
+ // Hand the images over and clear them: leaving them staged would
67
+ // re-send the same picture with every later follow-up.
68
+ mode.submitFollowUp(mode.followUpDraft, btwImagesFrom(attachments));
69
+ setAttachments([]);
70
+ }}
71
+ onStop={mode.abort}
72
+ running={Boolean(mode.runningTopicId)}
73
+ modelLabel={mode.activeTopic?.model?.name ?? modelName}
74
+ provider={mode.activeTopic?.model?.provider ?? provider}
75
+ attachments={attachments}
76
+ onFilesSelected={(files) => {
77
+ void readBtwAttachments(files).then((next) => setAttachments((prev) => [...prev, ...next]));
78
+ }}
79
+ onRemoveAttachment={(id) => setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))}
80
+ />
81
+ </div>
82
+ );
83
+ }
@@ -1,5 +1,7 @@
1
1
  import type { Dispatch, SetStateAction } from 'preact/compat';
2
2
  import { ChatInput } from '@/client/components/workspace/chat-timeline/chat-input/index';
3
+ import { BtwForm } from '@/client/components/workspace/btw-panel/index';
4
+ import { useBtwMode } from '@/client/hooks/chat/btw/mode';
3
5
  import { GeneratingIndicator } from '@/client/components/workspace/chat-timeline/GeneratingIndicator';
4
6
  import { QueueList } from '@/client/components/workspace/chat-timeline/QueueList';
5
7
  import type { Attachment, QueuedMessage } from '@/shared/types';
@@ -7,6 +9,8 @@ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
7
9
 
8
10
  interface ComposerDockProps {
9
11
  isMobile: boolean;
12
+ /** Chamber session the side-question panel scopes to. */
13
+ sessionId: string | null;
10
14
  isGenerating: boolean;
11
15
  modelName?: string;
12
16
  generatingVerb: string;
@@ -45,6 +49,7 @@ interface ComposerDockProps {
45
49
  */
46
50
  export function ComposerDock({
47
51
  isMobile,
52
+ sessionId,
48
53
  isGenerating,
49
54
  modelName,
50
55
  generatingVerb,
@@ -75,12 +80,19 @@ export function ComposerDock({
75
80
  sessionThinkingLevel,
76
81
  variant,
77
82
  }: ComposerDockProps) {
83
+ // Side questions take over the composer entirely: the chat's own input is
84
+ // hidden while the form is open, so a stray Enter cannot send to the wrong
85
+ // conversation.
86
+ const btw = useBtwMode(sessionId, appSettings);
87
+
78
88
  return (
79
89
  <div
80
90
  className={`bg-transparent border-t-0 flex-shrink-0 space-y-2 ${isMobile ? 'px-3 pt-1' : 'p-4 pt-1'}`}
81
91
  style={isMobile ? { paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom, 0px))' } : undefined}
82
92
  >
83
- <div className="mx-auto w-full max-w-[970px]">
93
+ {/* `relative` keeps the queue/indicator stack anchored above the input
94
+ while the side-question form takes the composer's place. */}
95
+ <div className="relative mx-auto w-full max-w-[970px]">
84
96
  {isGenerating && (
85
97
  <GeneratingIndicator
86
98
  modelName={modelName}
@@ -105,26 +117,30 @@ export function ComposerDock({
105
117
  })}
106
118
  isSteering
107
119
  />
108
- <ChatInput
109
- value={inputValue}
110
- onChange={setInputValue}
111
- rootPath={rootPath}
112
- attachments={attachments}
113
- onAttachmentsChange={setAttachments}
114
- onSend={onSend}
115
- isGenerating={isGenerating}
116
- onStop={onStop}
117
- appSettings={appSettings}
118
- onThinkingLevelChange={onThinkingLevelChange}
119
- onModelChange={onModelChange}
120
- accessMode={accessMode}
121
- onAccessModeChange={onAccessModeChange}
122
- composerModelRef={composerModelRef}
123
- deferredComposerPickRef={deferredComposerPickRef}
124
- sessionModel={sessionModel}
125
- sessionThinkingLevel={sessionThinkingLevel}
126
- variant={variant}
127
- />
120
+ {btw.open ? (
121
+ <BtwForm mode={btw} modelName={modelName} provider={provider} />
122
+ ) : (
123
+ <ChatInput
124
+ value={inputValue}
125
+ onChange={setInputValue}
126
+ rootPath={rootPath}
127
+ attachments={attachments}
128
+ onAttachmentsChange={setAttachments}
129
+ onSend={onSend}
130
+ isGenerating={isGenerating}
131
+ onStop={onStop}
132
+ appSettings={appSettings}
133
+ onThinkingLevelChange={onThinkingLevelChange}
134
+ onModelChange={onModelChange}
135
+ accessMode={accessMode}
136
+ onAccessModeChange={onAccessModeChange}
137
+ composerModelRef={composerModelRef}
138
+ deferredComposerPickRef={deferredComposerPickRef}
139
+ sessionModel={sessionModel}
140
+ sessionThinkingLevel={sessionThinkingLevel}
141
+ variant={variant}
142
+ />
143
+ )}
128
144
  </div>
129
145
  </div>
130
146
  );
@@ -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
  );
@@ -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';
@@ -9,6 +10,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';
11
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';
12
16
 
13
17
  export function ChatInput({
14
18
  value,
@@ -230,61 +234,61 @@ export function ChatInput({
230
234
  if (clipboard.files.length > 0) {
231
235
  e.preventDefault();
232
236
  const files = Array.from(clipboard.files);
233
- 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));
234
240
  }
235
241
  };
236
242
 
237
- const addFiles = (files: File[]) => {
238
- const newAttachments = files.map(file => ({
239
- id: Math.random().toString(36).substring(7),
240
- file,
241
- preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : ''
242
- }));
243
- setAttachments(prev => [...prev, ...newAttachments]);
244
- // Read image payloads as base64 so the omp model can receive them.
245
- for (const att of newAttachments) {
246
- if (!att.file.type.startsWith('image/')) continue;
247
- const reader = new FileReader();
248
- reader.onload = () => {
249
- const result = reader.result as string;
250
- const base64 = result.split(',')[1];
251
- if (!base64) return;
252
- setAttachments(prev => prev.map(a => (a.id === att.id ? { ...a, dataBase64: base64 } : a)));
253
- };
254
- reader.readAsDataURL(att.file);
255
- }
256
- };
257
-
258
- const removeAttachment = (id: string) => {
259
- setAttachments(prev => {
260
- const att = prev.find(p => p.id === id);
261
- if (att && att.preview) URL.revokeObjectURL(att.preview);
262
- return prev.filter(p => p.id !== id);
263
- });
264
- };
265
-
266
- const handleSendClick = (options?: { steering?: boolean }) => {
267
- if (!value.trim() && attachments.length === 0) return;
268
- onSend(attachments, options);
269
- setAttachments([]);
270
- };
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
+ });
271
254
 
272
255
  const isMobile = variant === 'mobile';
273
256
 
274
257
  return (
275
- <div className={`relative border border-ink/20 rounded-md bg-paper focus-within:border-ink transition-colors flex flex-col shadow-sm ${className}`}>
276
-
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
+
277
266
  <AttachmentToolbar
278
267
  attachments={attachments}
279
- onFilesSelected={addFiles}
268
+ onFilesSelected={(files) => acceptFiles(files)}
280
269
  onRemove={removeAttachment}
281
270
  />
282
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
+
283
287
  {/* Textarea */}
284
288
  <ComposerTextarea
285
289
  value={value}
286
290
  onChange={onChange}
287
- onSend={handleSendClick}
291
+ onSend={submit}
288
292
  disabled={disabled}
289
293
  appSettings={appSettings}
290
294
  rootPath={rootPath}
@@ -310,7 +314,7 @@ export function ChatInput({
310
314
  onThinkingLevelChange={onThinkingLevelChange}
311
315
  isGenerating={isGenerating}
312
316
  onStop={onStop}
313
- onSend={() => handleSendClick()}
317
+ onSend={() => submit()}
314
318
  sendDisabled={disabled || (!value.trim() && attachments.length === 0)}
315
319
  accessMode={accessMode}
316
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,
@@ -127,18 +131,14 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
127
131
 
128
132
  useSessionTitle(sessionId, sessionData?.title, onSessionTitle);
129
133
 
130
- const handleScrollTo = useCallback((id: string) => {
131
- const el = document.getElementById(id);
132
- if (el) {
133
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
134
- }
135
- }, []);
134
+ const handleJumpTurn = useCallback((turn: UserTurnRef) => {
135
+ void jumpToUserTurn(turn.id, turn.index);
136
+ }, [jumpToUserTurn]);
136
137
 
137
138
  const handleNewChat = useCallback((content: string) => setNewChatInitialContent(content), []);
138
139
 
139
140
  const modelNames = useModelNames();
140
141
  const providerNames = useProviderNames();
141
- const userMessages = useMemo(() => localMessages.filter(m => m.role === 'user'), [localMessages]);
142
142
  const sessionProvider = typeof sessionData?.model === 'object' ? sessionData.model.provider : undefined;
143
143
  const sessionModelName = typeof sessionData?.model === 'object'
144
144
  ? (modelNames[sessionData.model.modelId] ?? sessionData.model.modelId)
@@ -210,8 +210,9 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
210
210
  ) : (
211
211
  <TimelineBody
212
212
  isMobile={isMobile}
213
- userMessages={userMessages}
214
- onScrollTo={handleScrollTo}
213
+ userTurns={userTurns}
214
+ jumpingTurn={jumpingTurn}
215
+ onJumpTurn={handleJumpTurn}
215
216
  scrollRef={scrollRef}
216
217
  contentRef={contentRef}
217
218
  handleScroll={handleScroll}
@@ -240,6 +241,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
240
241
  {!activeSubagent && (
241
242
  <ComposerDock
242
243
  isMobile={isMobile}
244
+ sessionId={sessionId}
243
245
  isGenerating={isGenerating}
244
246
  modelName={sessionModelName}
245
247
  generatingVerb={generatingVerb}
@@ -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>