ompchamber 0.6.0 → 0.8.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 (125) hide show
  1. package/README.md +24 -8
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/static/css/index.5c0447cefe.css +2 -0
  4. package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
  5. package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
  6. package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
  7. package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
  8. package/dist/client/static/js/async/c.797d933882.js +1 -0
  9. package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
  10. package/dist/client/static/js/async/{g.8515cbc6f7.js → g.d1c2fe09aa.js} +2 -2
  11. package/dist/client/static/js/async/g.d1c2fe09aa.js.LICENSE.txt +11 -0
  12. package/dist/client/static/js/async/{m.4b29d34f93.js → m.0896dab823.js} +2 -2
  13. package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
  14. package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
  15. package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
  16. package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
  17. package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
  18. package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
  19. package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
  20. package/dist/client/static/js/async/x4.69ec69c61b.js +6 -0
  21. package/dist/client/static/js/async/xn.6360379f71.js +2 -0
  22. package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
  23. package/dist/client/static/js/index.dc5f5fabe8.js +33 -0
  24. package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
  25. package/package.json +3 -1
  26. package/src/cli/lib/args.js +1 -1
  27. package/src/cli/lib/commands/restart.js +11 -0
  28. package/src/cli/lib/commands/update.js +21 -7
  29. package/src/cli/lib/runtime.js +6 -3
  30. package/src/client/components/common/MarkdownRenderer.tsx +69 -27
  31. package/src/client/components/common/code-editor/history.test.ts +118 -0
  32. package/src/client/components/common/code-editor/history.ts +101 -0
  33. package/src/client/components/common/code-editor/index.tsx +56 -66
  34. package/src/client/components/common/code-surface/index.tsx +84 -11
  35. package/src/client/components/common/code-surface/measure.ts +66 -17
  36. package/src/client/components/common/diagram-viewer/index.tsx +258 -0
  37. package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
  38. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -0
  39. package/src/client/components/layout/session-sidebar/SessionItem.tsx +10 -3
  40. package/src/client/components/settings/LazyModal.tsx +1 -1
  41. package/src/client/components/settings/Modal.tsx +2 -1
  42. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
  43. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
  44. package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
  45. package/src/client/components/workspace/chat-timeline/index.tsx +121 -107
  46. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
  47. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
  56. package/src/client/components/workspace/file-explorer/index.tsx +18 -5
  57. package/src/client/hooks/chat/omp/index.ts +4 -109
  58. package/src/client/hooks/chat/omp/prompt-send.ts +161 -0
  59. package/src/client/hooks/chat/omp/session-list.tsx +4 -0
  60. package/src/client/hooks/chat/timeline/actions.ts +0 -9
  61. package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
  62. package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
  63. package/src/client/hooks/chat/timeline/index.ts +6 -8
  64. package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
  65. package/src/client/hooks/chat/timeline/send.ts +16 -29
  66. package/src/client/hooks/chat/timeline/session-load.ts +48 -106
  67. package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
  68. package/src/client/hooks/editor/use-code-window.ts +118 -0
  69. package/src/client/hooks/ui/input-required-alert.ts +43 -0
  70. package/src/client/hooks/ui/notification-sound.ts +65 -0
  71. package/src/client/hooks/ui/theme.ts +22 -6
  72. package/src/client/tailwind.css +38 -12
  73. package/src/server/index.ts +4 -3
  74. package/src/server/lib/lifecycle/bun.ts +18 -0
  75. package/src/server/lib/lifecycle/launch-mode.test.ts +32 -0
  76. package/src/server/lib/lifecycle/launch-mode.ts +40 -0
  77. package/src/server/lib/lifecycle/restart.test.ts +21 -0
  78. package/src/server/lib/lifecycle/restart.ts +111 -0
  79. package/src/server/lib/omp/rpc/manager.ts +14 -3
  80. package/src/server/lib/omp/rpc/session-commands.ts +15 -1
  81. package/src/server/lib/omp/rpc/session-registry.ts +18 -0
  82. package/src/server/lib/omp/session/sidebar-data.server.ts +7 -1
  83. package/src/server/lib/updates/apply.ts +8 -5
  84. package/src/server/lib/updates/install.ts +7 -4
  85. package/src/server/routes/health/probe.ts +2 -1
  86. package/src/shared/lib/chat/ask-questions.test.ts +169 -0
  87. package/src/shared/lib/chat/ask-questions.ts +264 -0
  88. package/src/shared/lib/code/lazy-window.test.ts +161 -0
  89. package/src/shared/lib/code/lazy-window.ts +156 -0
  90. package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
  91. package/src/shared/lib/code/syntax-highlight.ts +14 -1
  92. package/src/shared/lib/code/windowed-highlight.ts +63 -0
  93. package/src/shared/lib/markdown/mermaid.ts +8 -6
  94. package/src/shared/lib/omp/session/stream-state.server.ts +16 -1
  95. package/src/shared/types/omp/agent.ts +2 -2
  96. package/src/shared/types/workspace.ts +4 -0
  97. package/dist/client/static/css/index.51f68210a5.css +0 -2
  98. package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
  99. package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
  100. package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
  101. package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
  102. package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
  103. package/dist/client/static/js/async/rf.d626afc367.js +0 -2
  104. package/dist/client/static/js/async/t.cda4891526.js +0 -1
  105. package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
  106. package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
  107. package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
  108. package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
  109. package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
  110. package/dist/client/static/js/index.47d3b04d5f.js +0 -33
  111. package/dist/client/static/js/index.47d3b04d5f.js.LICENSE.txt +0 -4
  112. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
  113. /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
  114. /package/dist/client/static/js/async/{g.8515cbc6f7.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
  115. /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
  116. /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.0896dab823.js.LICENSE.txt} +0 -0
  117. /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
  118. /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
  119. /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
  120. /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
  121. /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → x4.69ec69c61b.js.LICENSE.txt} +0 -0
  122. /package/dist/client/static/js/async/{uf.24ca3d49c3.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
  123. /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → xnl.feb0021e50.js.LICENSE.txt} +0 -0
  124. /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.dc5f5fabe8.js.LICENSE.txt} +0 -0
  125. /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
@@ -0,0 +1,113 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Answer bookkeeping for the inline ask card.
8
+ *
9
+ * omp asks a multi-question `ask` one question at a time and blocks on each, so
10
+ * only the question currently in flight owns a dialog. The card still lets the
11
+ * user answer any of them, in any order: a choice made for a question omp has
12
+ * not reached yet is held here and released the moment its dialog arrives. Every
13
+ * sent dialog is remembered by id, so a re-render (or a replayed request after a
14
+ * page reload) can never answer the same frame twice.
15
+ *
16
+ * Multi-select questions are excluded from that queue: omp re-asks them once per
17
+ * toggle and only the LAST one settles the question, so each click is sent
18
+ * immediately and only the display state accumulates here.
19
+ */
20
+
21
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
22
+ import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
23
+ import type { AskAnswer, AskQuestion } from '@/shared/lib/chat/ask-questions';
24
+ import type { ExtensionDialogResponse } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/Lazy';
25
+
26
+ interface UseAskDraftsOptions {
27
+ toolId: string;
28
+ questions: AskQuestion[];
29
+ /** Dialogs per question, oldest first — the last one is the live dialog. */
30
+ groups: ExtensionUiDialogRequest[][];
31
+ /** Answers omp already recorded in the tool result, per question. */
32
+ recorded: (AskAnswer | null)[];
33
+ respond: (request: ExtensionUiDialogRequest, response: ExtensionDialogResponse) => void;
34
+ }
35
+
36
+ export interface AskDrafts {
37
+ /** Committed single-choice answers, by question index. */
38
+ drafts: Record<number, string>;
39
+ /** Labels the user has toggled on a multi-select question, for display only. */
40
+ multiSelected: Record<number, string[]>;
41
+ commit: (index: number, value: string) => void;
42
+ toggleMulti: (index: number, label: string) => void;
43
+ }
44
+
45
+ /** The live dialog for a question: the most recent frame it raised. */
46
+ export function liveFrame(groups: ExtensionUiDialogRequest[][], index: number): ExtensionUiDialogRequest | null {
47
+ const frames = groups[index];
48
+ return frames && frames.length > 0 ? frames[frames.length - 1] : null;
49
+ }
50
+
51
+ // omp records answers only in the FINAL tool result, so a reload mid-ask would
52
+ // otherwise repaint every question already answered as still waiting. The draft
53
+ // cache keeps the text across a reload; it is per-tab and dies with the session.
54
+ function draftKey(toolId: string): string {
55
+ return `ompchamber.ask-draft:${toolId}`;
56
+ }
57
+
58
+ function loadDrafts(toolId: string): Record<number, string> {
59
+ try {
60
+ const raw = sessionStorage.getItem(draftKey(toolId));
61
+ return raw ? (JSON.parse(raw) as Record<number, string>) : {};
62
+ } catch {
63
+ return {};
64
+ }
65
+ }
66
+
67
+ export function useAskDrafts({ toolId, questions, groups, recorded, respond }: UseAskDraftsOptions): AskDrafts {
68
+ const [drafts, setDrafts] = useState<Record<number, string>>(() => loadDrafts(toolId));
69
+ const [multiSelected, setMultiSelected] = useState<Record<number, string[]>>({});
70
+ const sentRef = useRef<Set<string>>(new Set());
71
+
72
+ useEffect(() => {
73
+ for (let index = 0; index < questions.length; index += 1) {
74
+ if (recorded[index] || questions[index].multi) continue;
75
+ const value = drafts[index];
76
+ if (!value) continue;
77
+ const frame = liveFrame(groups, index);
78
+ if (!frame || frame.method !== 'select' || sentRef.current.has(frame.id)) continue;
79
+ sentRef.current.add(frame.id);
80
+ respond(frame, { value });
81
+ }
82
+ }, [questions, groups, drafts, recorded, respond]);
83
+
84
+ useEffect(() => {
85
+ try {
86
+ sessionStorage.setItem(draftKey(toolId), JSON.stringify(drafts));
87
+ } catch {
88
+ // Storage disabled (private mode): the card still works for this mount.
89
+ }
90
+ }, [toolId, drafts]);
91
+
92
+ const commit = useCallback((index: number, value: string) => {
93
+ setDrafts((current) => (current[index] === value ? current : { ...current, [index]: value }));
94
+ }, []);
95
+
96
+ const toggleMulti = useCallback((index: number, label: string) => {
97
+ const frame = liveFrame(groups, index);
98
+ if (!frame || sentRef.current.has(frame.id)) return;
99
+ // One omp dialog per toggle: answering it re-asks the question with the
100
+ // updated set, so the next click lands on the dialog that just arrived.
101
+ sentRef.current.add(frame.id);
102
+ respond(frame, { value: label });
103
+ setMultiSelected((current) => {
104
+ const selected = current[index] ?? [];
105
+ return {
106
+ ...current,
107
+ [index]: selected.includes(label) ? selected.filter((item) => item !== label) : [...selected, label],
108
+ };
109
+ });
110
+ }, [groups, respond]);
111
+
112
+ return { drafts, multiSelected, commit, toggleMulti };
113
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Inline card for omp's `ask` tool — the whole conversation with the user, in
8
+ * the timeline instead of a modal.
9
+ *
10
+ * omp asks one question per dialog and blocks on it, so a modal could only ever
11
+ * show the question in flight and lost every earlier one on the way; the card
12
+ * keeps all of them in view, marks the ones already answered, and ends as the
13
+ * summary of what the agent asked and what it was told.
14
+ */
15
+
16
+ import { HelpCircle, XCircle } from 'lucide-preact';
17
+ import type { ToolCallData } from '@/shared/types';
18
+ import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
19
+ import { parseAskQuestions, parseAskResult } from '@/shared/lib/chat/ask-questions';
20
+ import { useAskFrames } from '@/client/hooks/chat/timeline/ask-frames';
21
+ import { liveFrame, useAskDrafts } from '@/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts';
22
+ import { QuestionBlock } from '@/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock';
23
+ import { FallbackOutput } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/FallbackOutput';
24
+
25
+ // Stable empties: fresh `[]` literals would defeat the question block's props.
26
+ const NO_FRAMES: ExtensionUiDialogRequest[] = [];
27
+ const NO_GROUPS: ExtensionUiDialogRequest[][] = [];
28
+ const NO_LABELS: string[] = [];
29
+
30
+ export function AskPanel({ tool }: { tool: ToolCallData }) {
31
+ const questions = parseAskQuestions(tool);
32
+ const recorded = parseAskResult(tool, questions);
33
+ const { framesByTool, respond } = useAskFrames();
34
+ const groups = framesByTool.get(tool.id) ?? NO_GROUPS;
35
+ const { drafts, multiSelected, commit, toggleMulti } = useAskDrafts({
36
+ toolId: tool.id,
37
+ questions,
38
+ groups,
39
+ recorded,
40
+ respond,
41
+ });
42
+
43
+ // An `ask` whose arguments the timeline never saw (or a non-questions shape)
44
+ // still has a result worth showing.
45
+ if (questions.length === 0) {
46
+ return tool.output ? <FallbackOutput text={tool.output} /> : null;
47
+ }
48
+
49
+ const waiting = groups.some((frames, index) => frames.length > 0 && !recorded[index]);
50
+ // omp asks in order and blocks on each, so the earliest question holding a
51
+ // dialog is the only open one: everything before it is answered, whether or
52
+ // not this client still has the text.
53
+ const askingIndex = groups.findIndex((frames) => frames.length > 0);
54
+
55
+ return (
56
+ <div className="space-y-2">
57
+ {waiting && (
58
+ <div className="flex items-center gap-1.5 text-[10.5px] text-ink/50">
59
+ <HelpCircle size={11} className="shrink-0" />
60
+ <span>Input needed — the agent is blocked until you answer.</span>
61
+ </div>
62
+ )}
63
+
64
+ {questions.map((question, index) => (
65
+ <QuestionBlock
66
+ key={question.id ?? index}
67
+ index={index}
68
+ total={questions.length}
69
+ question={question}
70
+ frames={groups[index] ?? NO_FRAMES}
71
+ recorded={recorded[index] ?? null}
72
+ draft={drafts[index]}
73
+ passed={askingIndex !== -1 && index < askingIndex}
74
+ multiSelected={multiSelected[index] ?? NO_LABELS}
75
+ onAnswer={(value) => commit(index, value)}
76
+ onToggle={(label) => toggleMulti(index, label)}
77
+ onRespond={(response) => {
78
+ const frame = liveFrame(groups, index);
79
+ if (frame) respond(frame, response);
80
+ }}
81
+ />
82
+ ))}
83
+
84
+ {(tool.status === 'error' || tool.status === 'aborted') && tool.output && (
85
+ <div className="flex items-start gap-2 rounded-md border border-ink/8 bg-ink/[0.02] px-2.5 py-1.5 text-[10.5px] text-ink/60">
86
+ <XCircle size={11} className="mt-0.5 shrink-0 text-ink/40" />
87
+ <span className="font-mono select-text">{tool.output}</span>
88
+ </div>
89
+ )}
90
+ </div>
91
+ );
92
+ }
@@ -1,6 +1,6 @@
1
1
  import { CornerDownLeft, X } from 'lucide-preact';
2
2
 
3
- interface AskDialogFooterProps {
3
+ interface ExtensionDialogFooterProps {
4
4
  method: string;
5
5
  optionsLength: number;
6
6
  selectedOption: string | null;
@@ -9,14 +9,14 @@ interface AskDialogFooterProps {
9
9
  onSubmit: () => void;
10
10
  }
11
11
 
12
- export function AskDialogFooter({
12
+ export function ExtensionDialogFooter({
13
13
  method,
14
14
  optionsLength,
15
15
  selectedOption,
16
16
  hasCustomAnswer = false,
17
17
  onCancel,
18
18
  onSubmit,
19
- }: AskDialogFooterProps) {
19
+ }: ExtensionDialogFooterProps) {
20
20
  const isSelectDisabled = method === 'select' && !selectedOption && !hasCustomAnswer;
21
21
 
22
22
  return (
@@ -1,7 +1,7 @@
1
1
  import type { ReactNode } from 'preact/compat';
2
2
  import { Sparkles, X } from 'lucide-preact';
3
3
 
4
- interface AskDialogHeaderProps {
4
+ interface ExtensionDialogHeaderProps {
5
5
  title?: string;
6
6
  methodLabel: string;
7
7
  badge: string;
@@ -9,13 +9,13 @@ interface AskDialogHeaderProps {
9
9
  onCancel: () => void;
10
10
  }
11
11
 
12
- export function AskDialogHeader({
12
+ export function ExtensionDialogHeader({
13
13
  title,
14
14
  methodLabel,
15
15
  badge,
16
16
  icon,
17
17
  onCancel,
18
- }: AskDialogHeaderProps) {
18
+ }: ExtensionDialogHeaderProps) {
19
19
  return (
20
20
  <div className="flex items-center justify-between border-b border-ink/8 bg-canvas/40 px-5 py-3.5">
21
21
  <div className="flex items-center gap-3 min-w-0">
@@ -1,24 +1,24 @@
1
1
  import { Suspense, lazy } from 'preact/compat';
2
2
  import type { ExtensionUiDialogRequest } from '@/client/hooks/chat/omp';
3
- import type { ExtensionDialogResponse } from '@/client/components/workspace/chat-timeline/tool-renderers/ask-dialog/index';
3
+ import type { ExtensionDialogResponse } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index';
4
4
 
5
- const AskDialogImpl = lazy(() =>
6
- import('@/client/components/workspace/chat-timeline/tool-renderers/ask-dialog/index').then((m) => ({
7
- default: m.AskDialog,
5
+ const ExtensionDialogImpl = lazy(() =>
6
+ import('@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index').then((m) => ({
7
+ default: m.ExtensionDialog,
8
8
  }))
9
9
  );
10
10
 
11
- interface AskDialogProps {
11
+ interface ExtensionDialogProps {
12
12
  request: ExtensionUiDialogRequest;
13
13
  onRespond: (request: ExtensionUiDialogRequest, response: ExtensionDialogResponse) => void;
14
14
  }
15
15
 
16
- export function AskDialog(props: AskDialogProps) {
16
+ export function ExtensionDialog(props: ExtensionDialogProps) {
17
17
  return (
18
18
  <Suspense fallback={null}>
19
- <AskDialogImpl {...props} />
19
+ <ExtensionDialogImpl {...props} />
20
20
  </Suspense>
21
21
  );
22
22
  }
23
23
 
24
- export type { ExtensionDialogResponse } from '@/client/components/workspace/chat-timeline/tool-renderers/ask-dialog/index';
24
+ export type { ExtensionDialogResponse } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index';
@@ -1,7 +1,7 @@
1
1
  import type { RefObject } from 'preact/compat';
2
2
  import { CheckCircle2, Circle } from 'lucide-preact';
3
3
 
4
- interface AskDialogSelectBodyProps {
4
+ interface ExtensionDialogSelectBodyProps {
5
5
  options: string[];
6
6
  optionDetails: { description?: string }[];
7
7
  selectedOption: string | null;
@@ -12,7 +12,7 @@ interface AskDialogSelectBodyProps {
12
12
  onConfirmOption: (option: string) => void;
13
13
  }
14
14
 
15
- export function AskDialogSelectBody({
15
+ export function ExtensionDialogSelectBody({
16
16
  options,
17
17
  optionDetails,
18
18
  selectedOption,
@@ -21,7 +21,7 @@ export function AskDialogSelectBody({
21
21
  customInputRef,
22
22
  onSelect,
23
23
  onConfirmOption,
24
- }: AskDialogSelectBodyProps) {
24
+ }: ExtensionDialogSelectBodyProps) {
25
25
  return (
26
26
  <div className="space-y-2">
27
27
  <div className="flex items-center justify-between text-[11px] font-medium tracking-wider text-ink/50 uppercase">
@@ -2,9 +2,9 @@
2
2
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
3
3
  import { AlertCircle, CheckCircle2, FileText, HelpCircle, ListFilter, Terminal } from 'lucide-preact';
4
4
  import type { ExtensionUiDialogRequest } from '@/client/hooks/chat/omp';
5
- import { AskDialogHeader } from '@/client/components/workspace/chat-timeline/tool-renderers/ask-dialog/Header';
6
- import { AskDialogSelectBody } from '@/client/components/workspace/chat-timeline/tool-renderers/ask-dialog/SelectBody';
7
- import { AskDialogFooter } from '@/client/components/workspace/chat-timeline/tool-renderers/ask-dialog/Footer';
5
+ import { ExtensionDialogHeader } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/Header';
6
+ import { ExtensionDialogSelectBody } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/SelectBody';
7
+ import { ExtensionDialogFooter } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/Footer';
8
8
  import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
9
9
 
10
10
  export type ExtensionDialogResponse =
@@ -12,12 +12,18 @@ export type ExtensionDialogResponse =
12
12
  | { confirmed: boolean }
13
13
  | { cancelled: true };
14
14
 
15
- interface AskDialogProps {
15
+ interface ExtensionDialogProps {
16
16
  request: ExtensionUiDialogRequest;
17
17
  onRespond: (request: ExtensionUiDialogRequest, response: ExtensionDialogResponse) => void;
18
18
  }
19
19
 
20
- export function AskDialog({ request, onRespond }: AskDialogProps) {
20
+ /**
21
+ * Blocking extension dialog that has no card of its own to render in: an
22
+ * approval gate under `always-ask`, or a picker an extension raised. The `ask`
23
+ * tool's own dialogs render inline on its tool card instead (see ask-panel) —
24
+ * a modal would only ever show the question currently in flight.
25
+ */
26
+ export function ExtensionDialog({ request, onRespond }: ExtensionDialogProps) {
21
27
  const [value, setValue] = useState(request.method === 'editor' ? request.prefill ?? '' : '');
22
28
  const [selectedOption, setSelectedOption] = useState<string | null>(null);
23
29
  const [customValue, setCustomValue] = useState('');
@@ -202,7 +208,7 @@ export function AskDialog({ request, onRespond }: AskDialogProps) {
202
208
 
203
209
  return (
204
210
  <div
205
- className="ask-dialog-backdrop fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
211
+ className="extension-dialog-backdrop fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
206
212
  style={{
207
213
  background: 'color-mix(in srgb, var(--theme-ink) 55%, transparent)',
208
214
  backdropFilter: 'blur(8px)',
@@ -217,12 +223,12 @@ export function AskDialog({ request, onRespond }: AskDialogProps) {
217
223
  aria-modal="true"
218
224
  aria-label={request.title || methodMeta.label}
219
225
  tabIndex={-1}
220
- className="ask-dialog-panel w-full max-w-[560px] overflow-hidden rounded-2xl border border-ink/15 bg-paper shadow-2xl outline-none"
226
+ className="extension-dialog-panel w-full max-w-[560px] overflow-hidden rounded-2xl border border-ink/15 bg-paper shadow-2xl outline-none"
221
227
  style={{
222
228
  boxShadow: '0 24px 64px -8px color-mix(in srgb, var(--theme-ink) 28%, transparent)',
223
229
  }}
224
230
  >
225
- <AskDialogHeader
231
+ <ExtensionDialogHeader
226
232
  title={request.title}
227
233
  methodLabel={methodMeta.label}
228
234
  badge={methodMeta.badge}
@@ -240,7 +246,7 @@ export function AskDialog({ request, onRespond }: AskDialogProps) {
240
246
 
241
247
  {/* Select Options */}
242
248
  {request.method === 'select' && (
243
- <AskDialogSelectBody
249
+ <ExtensionDialogSelectBody
244
250
  options={options}
245
251
  optionDetails={optionDetails}
246
252
  selectedOption={selectedOption}
@@ -307,7 +313,7 @@ export function AskDialog({ request, onRespond }: AskDialogProps) {
307
313
  )}
308
314
  </div>
309
315
 
310
- <AskDialogFooter
316
+ <ExtensionDialogFooter
311
317
  method={request.method}
312
318
  optionsLength={options.length}
313
319
  selectedOption={selectedOption}
@@ -19,7 +19,7 @@ import { AstEdit } from '@/client/components/workspace/chat-timeline/tool-render
19
19
  import { Resolve } from '@/client/components/workspace/chat-timeline/tool-renderers/panels/Resolve';
20
20
  import { ManageSkill } from '@/client/components/workspace/chat-timeline/tool-renderers/panels/ManageSkill';
21
21
  import { SearchFs } from '@/client/components/workspace/chat-timeline/tool-renderers/panels/SearchFs';
22
- import { Ask } from '@/client/components/workspace/chat-timeline/tool-renderers/panels/Ask';
22
+ import { AskPanel } from '@/client/components/workspace/chat-timeline/tool-renderers/ask-panel';
23
23
  import { Think } from '@/client/components/workspace/chat-timeline/tool-renderers/panels/Think';
24
24
  import { Read } from '@/client/components/workspace/chat-timeline/tool-renderers/panels/Read';
25
25
  import { Edit } from '@/client/components/workspace/chat-timeline/tool-renderers/panels/Edit';
@@ -142,7 +142,7 @@ export function ToolDetailsPanel({ tool }: { tool: ToolCallData }): ReactNode {
142
142
  return <Memory tool={tool} />;
143
143
  }
144
144
  if (key === 'goal' || key === 'yield') return <Goal tool={tool} />;
145
- if (key === 'ask') return <Ask tool={tool} />;
145
+ if (key === 'ask') return <AskPanel tool={tool} />;
146
146
  if (key === 'think') return <Think tool={tool} />;
147
147
 
148
148
  // Dynamic MCP / custom tools — `mcp__<tool>` names and xd:// device writes
@@ -63,15 +63,28 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
63
63
  loadFiles();
64
64
  }, [refreshKey, rootPath, enabled, activeRepo, expandedPathsReady]);
65
65
 
66
- // Auto refresh: re-read the directory tree on a cadence so external changes
67
- // (terminal output, agent edits) surface without a manual refresh. Silent —
68
- // the loading spinner stays reserved for the user's own refresh button.
69
- usePanelRefresh(() => loadFiles({ silent: true }), enabled && expandedPathsReady);
66
+ // The listing and its git decorations describe the same working tree, so one
67
+ // refresh drives both. Re-reading only the tree left every folder dot stale:
68
+ // `useGitStatus` has no poll of its own in this panel, so a new/changed file
69
+ // appeared under a folder that still claimed a clean status until the user
70
+ // hit refresh or refocused the window.
71
+ const refreshListing = () => {
72
+ loadFiles({ silent: true });
73
+ refreshGitStatus();
74
+ };
75
+ // Auto refresh: re-read on a cadence so external changes (terminal output,
76
+ // agent edits) surface without a manual refresh. Silent — the loading
77
+ // spinner stays reserved for the user's own refresh button.
78
+ usePanelRefresh(refreshListing, enabled && expandedPathsReady);
70
79
  // AI edits land between poll ticks: re-read right after a file-mutating tool
71
80
  // (edit / write / ast_edit / bash) finishes, not up to 2s later.
72
- useFileMutationRefresh(() => loadFiles({ silent: true }), enabled && expandedPathsReady);
81
+ useFileMutationRefresh(refreshListing, enabled && expandedPathsReady);
73
82
 
74
83
  const loadChildren = (path: string) => {
84
+ // Expanding IS a directory load: fetch the children AND re-read git status,
85
+ // so a freshly listed row never renders bare next to a status map that
86
+ // still predates its change (the panel poll alone can lag a fresh edit).
87
+ refreshGitStatus();
75
88
  return fetch(listUrl(path))
76
89
  .then(r => r.json())
77
90
  .then(data => {
@@ -1,8 +1,8 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
2
  import type { ChatMessageData, OmpAgentCallbacks, OmpAgentHandle, OmpAgentState, StreamTransport } from '@/shared/types';
3
3
  import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
4
- import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
5
4
  import { type ToolResultRecord, useOmpAgentStream } from '@/client/hooks/chat/omp/stream';
5
+ import { useOmpPromptSender } from '@/client/hooks/chat/omp/prompt-send';
6
6
  import { stopAgentSession } from '@/shared/lib/chat/omp/abort';
7
7
 
8
8
  /**
@@ -98,114 +98,9 @@ export function useOmpAgent(sessionId: string | null, callbacks: OmpAgentCallbac
98
98
  };
99
99
  }, [sessionId, disconnect, connect]);
100
100
 
101
- /** Send a prompt to the omp session via the RPC bridge. */
102
- const sendPrompt = useCallback(async (
103
- message: string,
104
- images?: { data: string; mimeType: string }[],
105
- options?: { accessMode?: ApprovalMode },
106
- ) => {
107
- const sid = sessionIdRef.current;
108
- if (!sid) return false;
109
- setState((prev) => ({ ...prev, isGenerating: true, error: null }));
110
- try {
111
- // Mirror omp-web handleSend: warm the session process up with get_state
112
- // (spawns it on first use), then attach the event stream before sending
113
- // the prompt so no agent events are missed.
114
- const warmup = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
115
- method: 'POST',
116
- headers: { 'Content-Type': 'application/json' },
117
- // The mode rides the warmup too: this is the request that lazily spawns
118
- // an idle session, and omp only accepts --approval-mode at spawn time.
119
- body: JSON.stringify({
120
- type: 'get_state',
121
- ...(options?.accessMode ? { accessMode: options.accessMode } : {}),
122
- }),
123
- });
124
- if (warmup.ok) connect(sid);
125
-
126
- const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
127
- method: 'POST',
128
- headers: { 'Content-Type': 'application/json' },
129
- body: JSON.stringify({
130
- type: 'prompt',
131
- message,
132
- ...(images?.length ? { images } : {}),
133
- ...(options?.accessMode ? { accessMode: options.accessMode } : {}),
134
- }),
135
- });
136
- const body = (await res.json().catch(() => ({}))) as { success?: boolean; error?: string };
137
- if (!res.ok || body.error) {
138
- // `session_busy`: the ack timed out behind a still-running turn, so the
139
- // prompt may already be accepted — never resend it automatically.
140
- setState((prev) => ({ ...prev, isGenerating: false, error: body.error ?? `HTTP ${res.status}` }));
141
- return false;
142
- }
143
- return true;
144
- } catch (e) {
145
- setState((prev) => ({ ...prev, isGenerating: false, error: e instanceof Error ? e.message : String(e) }));
146
- return false;
147
- }
148
- }, [connect]);
149
-
150
- /** Spawn a brand-new omp session and send the first prompt: ensure_session
151
- * first (returns omp's real session id), attach the event stream, then send
152
- * the prompt through the existing session route so no agent events are
153
- * missed. Model/thinking picks ride the ensure_session body so omp applies
154
- * them BEFORE the first prompt (and their JSONL change entries are written
155
- * up front, not after the run starts). Returns the new session id on
156
- * success, or null on failure — the caller adopts the id as the active
157
- * session. */
158
- const sendNewPrompt = useCallback(async (
159
- message: string,
160
- cwd: string,
161
- images?: { data: string; mimeType: string }[],
162
- composerOptions?: { model?: { provider: string; modelId: string } | null; thinkingLevel?: string | null; accessMode?: ApprovalMode },
163
- ): Promise<{ sessionId: string; model: { provider: string; modelId: string } | null } | null> => {
164
- setState((prev) => ({ ...prev, isGenerating: true, error: null }));
165
- try {
166
- const created = await fetch('/api/agent/new', {
167
- method: 'POST',
168
- headers: { 'Content-Type': 'application/json' },
169
- body: JSON.stringify({
170
- type: 'ensure_session',
171
- cwd,
172
- ...(composerOptions?.model ? composerOptions.model : {}),
173
- ...(composerOptions?.thinkingLevel ? { thinkingLevel: composerOptions.thinkingLevel } : {}),
174
- ...(composerOptions?.accessMode ? { accessMode: composerOptions.accessMode } : {}),
175
- }),
176
- });
177
- const createdBody = (await created.json().catch(() => ({}))) as {
178
- success?: boolean;
179
- sessionId?: string;
180
- model?: { provider: string; modelId: string } | null;
181
- error?: string;
182
- };
183
- if (!created.ok || !createdBody.sessionId) {
184
- setState((prev) => ({ ...prev, isGenerating: false, error: createdBody.error ?? `HTTP ${created.status}` }));
185
- return null;
186
- }
187
- const sid = createdBody.sessionId;
188
- connect(sid);
189
- const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
190
- method: 'POST',
191
- headers: { 'Content-Type': 'application/json' },
192
- body: JSON.stringify({
193
- type: 'prompt',
194
- message,
195
- ...(images?.length ? { images } : {}),
196
- }),
197
- });
198
- const body = (await res.json().catch(() => ({}))) as { success?: boolean; error?: string };
199
- if (!res.ok || body.error) {
200
- setState((prev) => ({ ...prev, isGenerating: false, error: body.error ?? `HTTP ${res.status}` }));
201
- return null;
202
- }
203
- return { sessionId: sid, model: createdBody.model ?? null };
204
- } catch (e) {
205
- setState((prev) => ({ ...prev, isGenerating: false, error: e instanceof Error ? e.message : String(e) }));
206
- return null;
207
- }
208
- }, [connect]);
101
+ // Prompt delivery (warm-up + stream attach + the dispatch-time sidebar
102
+ // signal) lives in its own module so this hook stays under the size ceiling.
103
+ const { sendPrompt, sendNewPrompt } = useOmpPromptSender({ sessionIdRef, connect, setState });
209
104
 
210
105
  /** Abort the running agent turn. */
211
106
  const abort = useCallback(async () => {