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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ompchamber",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
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>",
@@ -1,10 +1,12 @@
1
1
  import { useEffect, useRef } from 'preact/hooks';
2
2
  import type { ReactElement } from 'preact/compat';
3
- import type { ComposerMatchItem, ComposerPickItem, ComposerPickKind } from '@/shared/types';
3
+ import type { ComposerMatchItem, ComposerPickItem, ComposerPickKind, ComposerTriggerPhase } from '@/shared/types';
4
4
 
5
5
  export interface ComposerPickerProps {
6
6
  open: boolean;
7
7
  kind: ComposerPickKind;
8
+ /** `args` swaps the header for the subcommand hint of the typed command. */
9
+ phase?: ComposerTriggerPhase;
8
10
  items: ComposerMatchItem[];
9
11
  activeIndex: number;
10
12
  loading: boolean;
@@ -37,6 +39,7 @@ function OptionName({ item }: { item: ComposerMatchItem }) {
37
39
  export function ComposerPicker({
38
40
  open,
39
41
  kind,
42
+ phase = 'name',
40
43
  items,
41
44
  activeIndex,
42
45
  loading,
@@ -56,9 +59,18 @@ export function ComposerPicker({
56
59
  if (!open) return null;
57
60
 
58
61
  const isMention = kind === 'mention';
59
- const headerText = isMention ? 'Type to search files and agents' : 'Type to search commands and skills';
60
- const ariaLabel = isMention ? 'Files and agents' : 'Commands and skills';
61
- const emptyText = isMention ? 'No matching files or agents' : 'No matching commands or skills';
62
+ const isArgs = !isMention && phase === 'args';
63
+ const headerText = isMention
64
+ ? 'Type to search files and agents'
65
+ : isArgs
66
+ ? 'Pick a subcommand'
67
+ : 'Type to search commands and skills';
68
+ const ariaLabel = isMention ? 'Files and agents' : isArgs ? 'Subcommands' : 'Commands and skills';
69
+ const emptyText = isMention
70
+ ? 'No matching files or agents'
71
+ : isArgs
72
+ ? 'No matching subcommands'
73
+ : 'No matching commands or skills';
62
74
 
63
75
  return (
64
76
  <div
@@ -117,6 +117,7 @@ export function ComposerTextarea({
117
117
  <ComposerPicker
118
118
  open={composer.isOpen}
119
119
  kind={composer.trigger?.kind ?? 'mention'}
120
+ phase={composer.trigger?.phase ?? 'name'}
120
121
  items={composer.matches}
121
122
  activeIndex={composer.activeIndex}
122
123
  loading={composer.loading}
@@ -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
+ }
@@ -34,7 +34,7 @@ export function RightActivityBar({ activePanel, onChangePanel, isPanelOpen, hasA
34
34
  const hasGitChanges = changes.length > 0;
35
35
 
36
36
  return (
37
- <nav className="w-12 flex-shrink-0 border-l border-ink/10 bg-paper flex flex-col items-center py-3 space-y-2 z-10">
37
+ <nav className="w-11 flex-shrink-0 border-l border-ink/10 bg-paper flex flex-col items-center py-3 space-y-2 z-10">
38
38
  {/* Top Icons */}
39
39
  <div className="flex flex-col items-center space-y-1 w-full">
40
40
  {RIGHT_PANEL_TYPES.map((panel) => {
@@ -3,8 +3,8 @@ import { Separator } from '@/client/components/layout/desktop-layout/resizer';
3
3
  /** Hairline drag handle shared by both resizable groups in the desktop layout. */
4
4
  export function ResizeHandle() {
5
5
  return (
6
- <Separator className="relative w-1 outline-none group flex justify-center cursor-col-resize z-10">
7
- <div className="h-full w-[1px] bg-ink/10 group-hover:bg-ink/40 group-active:bg-ink/60 group-hover:w-0.5 transition-all" />
6
+ <Separator className="relative w-[3px] outline-none group flex justify-center cursor-col-resize z-10">
7
+ <div className="h-full w-[1px] bg-ink/10 group-hover:bg-ink/40 group-active:bg-ink/60 group-hover:w-[3px] transition-all" />
8
8
  </Separator>
9
9
  );
10
10
  }
@@ -1,4 +1,4 @@
1
- import { LayoutTemplate, MoreHorizontal, PanelRight, PanelRightClose, Smartphone } from 'lucide-preact';
1
+ import { LayoutTemplate, MoreHorizontal, PanelLeft, PanelRight, PanelRightClose, Smartphone } from 'lucide-preact';
2
2
  import { PWAInstallButton } from '@/client/components/common/PWAInstallButton';
3
3
  import { StreamStatusDot } from '@/client/components/common/StreamStatusDot';
4
4
  import type { AgentStreamStatus } from '@/shared/lib/chat/omp/status';
@@ -13,6 +13,7 @@ interface TopNavbarProps {
13
13
  onSwitchToMobile?: () => void;
14
14
  onToggleEditor: () => void;
15
15
  onToggleRightPanel: () => void;
16
+ onToggleLeftPanel: () => void;
16
17
  }
17
18
 
18
19
  export function TopNavbar({
@@ -24,10 +25,11 @@ export function TopNavbar({
24
25
  onSwitchToMobile,
25
26
  onToggleEditor,
26
27
  onToggleRightPanel,
28
+ onToggleLeftPanel,
27
29
  }: TopNavbarProps) {
28
30
  return (
29
31
  <header
30
- className="h-12 flex-shrink-0 border-b border-ink/10 bg-paper flex items-center justify-between pr-4 z-20 titlebar-drag-region select-none"
32
+ className="h-14 flex-shrink-0 border-b border-ink/10 bg-paper flex items-center justify-between pr-4 z-20 titlebar-drag-region select-none"
31
33
  style={{
32
34
  paddingLeft: !showLeftPanel ? 'max(1rem, env(titlebar-area-x, 0px))' : undefined,
33
35
  paddingRight: 'max(1rem, calc(100vw - env(titlebar-area-width, 100vw)))',
@@ -36,6 +38,15 @@ export function TopNavbar({
36
38
  <div className="flex items-center space-x-2 px-4 min-w-0">
37
39
  {!showLeftPanel && (
38
40
  <>
41
+ <button
42
+ type="button"
43
+ onClick={onToggleLeftPanel}
44
+ className="p-1.5 rounded hover:bg-ink/10 transition-colors text-ink/60 hover:text-ink cursor-pointer flex-shrink-0 titlebar-no-drag"
45
+ title="Expand Sidebar"
46
+ aria-label="Expand Sidebar"
47
+ >
48
+ <PanelLeft size={16} />
49
+ </button>
39
50
  <span className="font-bold text-sm tracking-tight hidden sm:flex items-center flex-shrink-0">
40
51
  <span className="text-transparent bg-clip-text bg-gradient-to-r from-orange-600 to-amber-500 font-extrabold text-[15px] tracking-tighter">OMP</span>
41
52
  <span className="ml-[1px]">Chamber</span>
@@ -6,15 +6,32 @@ import { ChatTimeline } from '@/client/components/workspace/chat-timeline/index'
6
6
  import { getDesktopPanelView } from '@/client/components/common/lazy-panels';
7
7
  import { RightActivityBar } from '@/client/components/layout/RightActivityBar';
8
8
  import { ResizeHandle } from '@/client/components/layout/desktop-layout/ResizeHandle';
9
- import { DEFAULT_RIGHT_PANEL_WIDTHS, MAX_RIGHT_PANEL_WIDTH, MIN_RIGHT_PANEL_WIDTHS, RIGHT_PANEL_TYPES, type RightPanelType } from '@/shared/lib/workspace/right-panels';
10
- import { DEFAULT_PANEL_WIDTHS, MAX_EDITOR_PANEL_WIDTH, MIN_CHAT_PANEL_WIDTH, MIN_EDITOR_PANEL_WIDTH, type EditorWidthMode, type PanelWidths } from '@/shared/lib/workspace/panel-widths';
9
+ import { useAvailableWidth } from '@/client/hooks/workspace/available-width';
10
+ import {
11
+ DEFAULT_RIGHT_PANEL_FRACTIONS,
12
+ DEFAULT_RIGHT_PANEL_WIDTHS,
13
+ MIN_RIGHT_PANEL_WIDTHS,
14
+ RIGHT_PANEL_TYPES,
15
+ type RightPanelType,
16
+ } from '@/shared/lib/workspace/right-panels';
17
+ import {
18
+ DEFAULT_EDITOR_FRACTIONS,
19
+ DEFAULT_EDITOR_WIDTHS,
20
+ MIN_CHAT_PANEL_WIDTH,
21
+ MIN_EDITOR_PANEL_WIDTH,
22
+ resolvePanelWidth,
23
+ type EditorWidthMode,
24
+ type PanelWidths,
25
+ } from '@/shared/lib/workspace/panel-widths';
11
26
 
12
27
  const Editor = lazy(() => import('@/client/components/workspace/editor/index').then((m) => ({ default: m.Editor })));
13
28
 
14
29
  function PanelSuspense({ children }: { children: ReactNode }) {
15
30
  return (
16
31
  <Suspense fallback={
17
- <div className="w-full h-full flex items-center justify-center text-ink/40 text-xs">Loading…</div>
32
+ <div className="w-full h-full flex items-center justify-center text-ink/40">
33
+ <span className="text-xs font-mono">Loading…</span>
34
+ </div>
18
35
  }>
19
36
  {children}
20
37
  </Suspense>
@@ -72,9 +89,12 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
72
89
  onPanelWidths,
73
90
  } = props;
74
91
 
75
- // Read inside effects without making a drag re-trigger them.
76
- const panelWidthsRef = useRef(panelWidths);
77
- panelWidthsRef.current = panelWidths;
92
+ // The group's own area: the base every stored fraction is a share of, and the
93
+ // budget the chat floor is subtracted from. Measured through the group's
94
+ // element, not a wrapper — the wrapper also spans the activity bar, which is
95
+ // not area any panel may take.
96
+ const groupRef = useRef<HTMLDivElement>(null);
97
+ const available = useAvailableWidth(groupRef);
78
98
 
79
99
  // One cached lazy per view, scoped to this layout instance: switching the
80
100
  // right panel toggles CSS visibility instead of unmounting, so a view's
@@ -86,18 +106,55 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
86
106
  [viewScope],
87
107
  );
88
108
 
109
+ // Separators present in this group, which also consume width.
110
+ const handleCount = (showEditor ? 1 : 0) + (showRightPanel ? 1 : 0);
111
+
112
+ const editorWidth = resolvePanelWidth({
113
+ stored: panelWidths[editorWidthMode],
114
+ defaultFraction: DEFAULT_EDITOR_FRACTIONS[editorWidthMode],
115
+ defaultPx: DEFAULT_EDITOR_WIDTHS[editorWidthMode],
116
+ available,
117
+ min: MIN_EDITOR_PANEL_WIDTH,
118
+ // The right panel renders after the editor, so it absorbs the remainder.
119
+ // Reserving this view's own floor is enough: the editor's share is what
120
+ // the ceiling subtracts.
121
+ siblingMin: showRightPanel ? MIN_RIGHT_PANEL_WIDTHS[activeRightPanel] : 0,
122
+ handleCount,
123
+ });
124
+
125
+ const rightWidth = resolvePanelWidth({
126
+ stored: panelWidths.right?.[activeRightPanel],
127
+ defaultFraction: DEFAULT_RIGHT_PANEL_FRACTIONS[activeRightPanel],
128
+ defaultPx: DEFAULT_RIGHT_PANEL_WIDTHS[activeRightPanel],
129
+ available,
130
+ min: MIN_RIGHT_PANEL_WIDTHS[activeRightPanel],
131
+ // The editor took its share first; only its floor is reserved here.
132
+ siblingMin: showEditor ? MIN_EDITOR_PANEL_WIDTH : 0,
133
+ handleCount,
134
+ });
135
+
89
136
  /**
90
137
  * Widths of the fixed panels as they are right now, keyed by slot: the
91
138
  * editor (source or diff, whichever is showing) and the right panel under
92
139
  * the view it currently holds. The chat column is the group's filler, so it
93
140
  * has no width of its own to report.
141
+ *
142
+ * Each is stored as a share of the area it was measured in, so the layout
143
+ * follows a window resize instead of holding a pixel value that was only
144
+ * right on the display it was dragged on.
94
145
  */
95
146
  const readPanelWidths = (): PanelWidths => {
96
147
  const patch: PanelWidths = {};
97
148
  const editor = editorPanelRef.current?.getSize()?.inPixels;
98
149
  const right = rightPanelRef.current?.getSize()?.inPixels;
99
- if (editor != null && editor > 0) patch[editorWidthMode] = Math.round(editor);
100
- if (right != null && right > 0) patch.right = { [activeRightPanel]: Math.round(right) };
150
+ if (editor != null && editor > 0) {
151
+ patch[editorWidthMode] = { px: Math.round(editor), ...(available ? { fraction: editor / available } : {}) };
152
+ }
153
+ if (right != null && right > 0) {
154
+ patch.right = {
155
+ [activeRightPanel]: { px: Math.round(right), ...(available ? { fraction: right / available } : {}) },
156
+ };
157
+ }
101
158
  return patch;
102
159
  };
103
160
 
@@ -106,6 +163,8 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
106
163
  <Group
107
164
  orientation="horizontal"
108
165
  id="ompchamber-layout"
166
+ className="flex-1 min-w-0"
167
+ groupRef={groupRef}
109
168
  onLayoutChanged={() => {
110
169
  // Persist only real drags: imperative restores and constraint
111
170
  // recomputes would otherwise rewrite the saved widths with the
@@ -117,55 +176,47 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
117
176
  <ChatTimeline className="w-full h-full" appSettings={appSettings} onSessionTitle={onSessionTitle} />
118
177
  </Panel>
119
178
 
120
- {showEditor && (
121
- <>
122
- <ResizeHandle />
123
- <Panel panelRef={editorPanelRef} id="editor-panel" defaultSize={panelWidths[editorWidthMode] ?? DEFAULT_PANEL_WIDTHS[editorWidthMode]} minSize={MIN_EDITOR_PANEL_WIDTH} maxSize={MAX_EDITOR_PANEL_WIDTH}>
124
- <PanelSuspense>
125
- <Editor
126
- className="w-full h-full"
127
- openedFiles={openedFiles}
128
- activeFileId={activeFileId}
129
- onSelectFile={onSetActiveFileId}
130
- onCloseFile={onCloseFile}
131
- refreshKey={refreshKey}
132
- onFileSaved={onRefreshWorkspace}
133
- />
134
- </PanelSuspense>
135
- </Panel>
136
- </>
137
- )}
179
+ {showEditor && <ResizeHandle />}
180
+ <Panel panelRef={editorPanelRef} id="editor-panel" defaultSize={editorWidth} minSize={MIN_EDITOR_PANEL_WIDTH} collapsed={!showEditor}>
181
+ <PanelSuspense>
182
+ <Editor
183
+ className="w-full h-full"
184
+ openedFiles={openedFiles}
185
+ activeFileId={activeFileId}
186
+ onSelectFile={onSetActiveFileId}
187
+ onCloseFile={onCloseFile}
188
+ refreshKey={refreshKey}
189
+ onFileSaved={onRefreshWorkspace}
190
+ />
191
+ </PanelSuspense>
192
+ </Panel>
138
193
 
139
- {showRightPanel && (
140
- <>
141
- <ResizeHandle />
142
- <Panel panelRef={rightPanelRef} id="right-panel" defaultSize={panelWidths.right?.[activeRightPanel] ?? DEFAULT_RIGHT_PANEL_WIDTHS[activeRightPanel]} minSize={MIN_RIGHT_PANEL_WIDTHS[activeRightPanel]} maxSize={MAX_RIGHT_PANEL_WIDTH} collapsible>
143
- <PanelSuspense>
144
- {rightViews.map(({ view, Comp }) => {
145
- const isActiveView = activeRightPanel === view;
146
- const base = {
147
- className: 'w-full h-full',
148
- // The terminal must stay enabled while hidden: swapping it
149
- // to the placeholder mid-command would unmount the live
150
- // stream. Everything else polls/re-reads only while shown.
151
- enabled: view === 'terminal' ? hasActiveContext : hasActiveContext && isActiveView,
152
- active: isActiveView,
153
- refreshKey,
154
- rootPath: activeProjectPath ?? undefined,
155
- onRefresh: onRefreshWorkspace,
156
- onOpenFile,
157
- onClose: onToggleRightPanel,
158
- };
159
- return (
160
- <div key={view} className={isActiveView ? 'w-full h-full' : 'hidden'}>
161
- <Comp {...base} />
162
- </div>
163
- );
164
- })}
165
- </PanelSuspense>
166
- </Panel>
167
- </>
168
- )}
194
+ {showRightPanel && <ResizeHandle />}
195
+ <Panel panelRef={rightPanelRef} id="right-panel" defaultSize={rightWidth} minSize={MIN_RIGHT_PANEL_WIDTHS[activeRightPanel]} collapsed={!showRightPanel}>
196
+ <PanelSuspense>
197
+ {rightViews.map(({ view, Comp }) => {
198
+ const isActiveView = activeRightPanel === view;
199
+ const base = {
200
+ className: 'w-full h-full',
201
+ // The terminal must stay enabled while hidden: swapping it
202
+ // to the placeholder mid-command would unmount the live
203
+ // stream. Everything else polls/re-reads only while shown.
204
+ enabled: view === 'terminal' ? hasActiveContext : hasActiveContext && isActiveView,
205
+ active: isActiveView,
206
+ refreshKey,
207
+ rootPath: activeProjectPath ?? undefined,
208
+ onRefresh: onRefreshWorkspace,
209
+ onOpenFile,
210
+ onClose: onToggleRightPanel,
211
+ };
212
+ return (
213
+ <div key={view} className={isActiveView ? 'w-full h-full' : 'hidden'}>
214
+ <Comp {...base} />
215
+ </div>
216
+ );
217
+ })}
218
+ </PanelSuspense>
219
+ </Panel>
169
220
  </Group>
170
221
 
171
222
  <RightActivityBar activePanel={activeRightPanel} onChangePanel={onChangeRightPanel} isPanelOpen={showRightPanel} hasActiveContext={hasActiveContext} activeProjectPath={activeProjectPath} refreshKey={refreshKey} />