ompchamber 3.1.1 → 3.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 (109) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +279 -280
  3. package/dist/client/static/js/index-01dv383b.css +1 -0
  4. package/dist/client/static/js/{index-qhzsybkn.js → index-1q6165r3.js} +641 -641
  5. package/package.json +1 -1
  6. package/src/cli/lib/output.js +15 -8
  7. package/src/client/components/common/Modal.tsx +14 -4
  8. package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
  9. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
  10. package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
  11. package/src/client/components/layout/session-sidebar/index.tsx +4 -2
  12. package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
  13. package/src/client/components/mobile/SessionSidebar.tsx +4 -0
  14. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +66 -29
  15. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +50 -12
  16. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
  17. package/src/client/components/settings/Modal.tsx +35 -6
  18. package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
  19. package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
  20. package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
  21. package/src/client/components/settings/categories/McpSettings.tsx +48 -31
  22. package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
  23. package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
  24. package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
  25. package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
  26. package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
  27. package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
  28. package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
  29. package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
  30. package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
  31. package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
  32. package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
  33. package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
  34. package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
  35. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
  36. package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
  37. package/src/client/components/settings/master-detail/index.tsx +68 -0
  38. package/src/client/components/workspace/SearchPanel.tsx +2 -1
  39. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
  40. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +2 -1
  41. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
  42. package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
  43. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
  44. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
  45. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
  46. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
  47. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
  50. package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
  51. package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
  52. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
  53. package/src/client/components/workspace/diff-panel/index.tsx +36 -7
  54. package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
  55. package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
  56. package/src/client/hooks/chat/omp/session-list.tsx +31 -1
  57. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
  58. package/src/client/hooks/chat/subagent/index.ts +37 -11
  59. package/src/client/hooks/editor/use-file-editor.ts +1 -1
  60. package/src/client/hooks/settings/master-detail.ts +33 -0
  61. package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
  62. package/src/server/lib/db/client.ts +5 -1
  63. package/src/server/lib/fs/git-diff.ts +34 -10
  64. package/src/server/lib/fs/git-repos.ts +81 -0
  65. package/src/server/lib/lifecycle/flock.ts +199 -0
  66. package/src/server/lib/lifecycle/identity.ts +5 -1
  67. package/src/server/lib/lifecycle/port-guard.ts +18 -3
  68. package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
  69. package/src/server/lib/lifecycle/proc/linux.ts +29 -7
  70. package/src/server/lib/lifecycle/proc/ps.ts +7 -0
  71. package/src/server/lib/lifecycle/proc/types.ts +17 -0
  72. package/src/server/lib/lifecycle/proc/win32.ts +5 -0
  73. package/src/server/lib/observer-ws.ts +150 -0
  74. package/src/server/lib/omp/rpc/constants.ts +9 -0
  75. package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
  76. package/src/server/lib/omp/session/file-scan.ts +7 -1
  77. package/src/server/lib/omp/session/files.ts +95 -96
  78. package/src/server/lib/omp/session/locator.ts +18 -8
  79. package/src/server/lib/omp/session/messages.ts +7 -7
  80. package/src/server/lib/omp/session/scan.ts +108 -0
  81. package/src/server/lib/omp/session/stats.ts +50 -50
  82. package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
  83. package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
  84. package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
  85. package/src/server/lib/terminal/runtime.server.ts +3 -0
  86. package/src/server/lib/terminal/session.server.ts +30 -3
  87. package/src/server/routes/agent/command.test.ts +69 -0
  88. package/src/server/routes/agent/command.ts +14 -0
  89. package/src/server/routes/agent/ws.test.ts +159 -0
  90. package/src/server/routes/agent/ws.ts +22 -97
  91. package/src/server/routes/btw/ws.ts +22 -95
  92. package/src/server/routes/fs/git.ts +61 -70
  93. package/src/server/routes/omp/session-stats.ts +1 -1
  94. package/src/server/routes/telemetry/tokens.ts +6 -4
  95. package/src/server/routes/terminal/run.ts +4 -8
  96. package/src/server/routes/terminal/ws.ts +24 -2
  97. package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
  98. package/src/shared/lib/chat/omp/agent-events.ts +10 -0
  99. package/src/shared/lib/code/language.ts +146 -0
  100. package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
  101. package/src/shared/lib/code/syntax-highlight.ts +0 -122
  102. package/src/shared/lib/fs/diff-parser.test.ts +5 -5
  103. package/src/shared/lib/fs/diff-parser.ts +67 -42
  104. package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
  105. package/src/shared/lib/omp/subagent/parse.ts +17 -0
  106. package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
  107. package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
  108. package/src/shared/types/omp/subagent.ts +6 -0
  109. package/dist/client/static/js/index-5yfre1kw.css +0 -1
@@ -6,9 +6,11 @@ import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
6
6
  interface SplitViewProps {
7
7
  rows: SplitDiffRow[];
8
8
  language: string;
9
+ /** Wrap long lines instead of scrolling horizontally. */
10
+ wordWrap: boolean;
9
11
  }
10
12
 
11
- export function SplitView({ rows, language }: SplitViewProps) {
13
+ export function SplitView({ rows, language, wordWrap }: SplitViewProps) {
12
14
  const syntaxReady = useSyntaxReady();
13
15
 
14
16
  const htmlByIndex = useMemo(() => {
@@ -47,7 +49,7 @@ export function SplitView({ rows, language }: SplitViewProps) {
47
49
  {/* The room under the last row belongs to the content, not the scroller:
48
50
  a scroll container's own bottom padding is not part of its scrollable
49
51
  overflow, so the padding has to sit inside it to have any effect. */}
50
- <div className="w-max min-w-full min-w-[700px] pb-4">
52
+ <div className={`pb-4 ${wordWrap ? 'w-full' : 'w-max min-w-full min-w-[700px]'}`}>
51
53
  <div className="grid grid-cols-2 sticky top-0 z-10 bg-canvas border-b border-ink/10 text-[11px] text-ink/70 font-sans select-none shadow-xs">
52
54
  <div className="px-3 py-1 font-medium border-r border-ink/10 flex items-center justify-between">
53
55
  <span>Original (HEAD / Base)</span>
@@ -57,7 +59,23 @@ export function SplitView({ rows, language }: SplitViewProps) {
57
59
  </div>
58
60
  </div>
59
61
 
60
- <table className="w-full border-collapse">
62
+ {/* Wrap needs `table-fixed`: auto layout sizes a column by its widest
63
+ cell's UNAMBIGUOUS minimum (`overflow-wrap: break-word` does not
64
+ lower it), so a long line would widen the table and scroll instead of
65
+ wrapping. Fixed layout takes its widths from the FIRST row — which
66
+ here is the `colSpan={4}` meta row, so every column came out equal
67
+ (measured 99px) and the line-number gutters grew to the width of a
68
+ code column. The colgroup states them instead: two 48px gutters and
69
+ the two content columns sharing the rest. */}
70
+ <table className={`border-collapse ${wordWrap ? 'table-fixed w-full' : 'w-full'}`}>
71
+ {wordWrap && (
72
+ <colgroup>
73
+ <col className="w-12" />
74
+ <col />
75
+ <col className="w-12" />
76
+ <col />
77
+ </colgroup>
78
+ )}
61
79
  <tbody>
62
80
  {rows.map((row, idx) => {
63
81
  if (row.isMeta) {
@@ -81,6 +99,8 @@ export function SplitView({ rows, language }: SplitViewProps) {
81
99
  {/* Left Line Number */}
82
100
  <td
83
101
  className={`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${
102
+ wordWrap ? 'align-top' : ''
103
+ } ${
84
104
  isLeftDel
85
105
  ? 'bg-error/15 text-error font-semibold'
86
106
  : left
@@ -93,7 +113,9 @@ export function SplitView({ rows, language }: SplitViewProps) {
93
113
 
94
114
  {/* Left Content (Original) */}
95
115
  <td
96
- className={`min-w-[320px] py-0.5 px-2 whitespace-pre border-r border-ink/10 text-ink ${
116
+ className={`py-0.5 px-2 border-r border-ink/10 text-ink ${
117
+ wordWrap ? 'whitespace-pre-wrap break-words align-top' : 'min-w-[320px] whitespace-pre'
118
+ } ${
97
119
  isLeftDel
98
120
  ? 'bg-error/10'
99
121
  : left
@@ -121,6 +143,8 @@ export function SplitView({ rows, language }: SplitViewProps) {
121
143
  {/* Right Line Number */}
122
144
  <td
123
145
  className={`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${
146
+ wordWrap ? 'align-top' : ''
147
+ } ${
124
148
  isRightAdd
125
149
  ? 'bg-success/15 text-success font-semibold'
126
150
  : right
@@ -133,7 +157,9 @@ export function SplitView({ rows, language }: SplitViewProps) {
133
157
 
134
158
  {/* Right Content (Modified) */}
135
159
  <td
136
- className={`min-w-[320px] py-0.5 px-2 whitespace-pre text-ink ${
160
+ className={`py-0.5 px-2 text-ink ${
161
+ wordWrap ? 'whitespace-pre-wrap break-words align-top' : 'min-w-[320px] whitespace-pre'
162
+ } ${
137
163
  isRightAdd
138
164
  ? 'bg-success/10'
139
165
  : right
@@ -1,14 +1,24 @@
1
- import { AlignJustify, Check, Code2, Columns, Copy, Minus, Plus, RefreshCw, Space, Undo2 } from 'lucide-preact';
1
+ import { AlignJustify, Check, Code2, Columns, Copy, Diff, FileCode, Minus, Plus, RefreshCw, Space, Undo2, WrapText } from 'lucide-preact';
2
2
  import { FileIcon } from '@/client/components/common/file-icon';
3
3
  import { getGitStatusInfo } from '@/shared/lib/fs/git-status';
4
4
 
5
5
  export type DiffViewMode = 'unified' | 'split';
6
6
 
7
+ /**
8
+ * How much of the file the diff shows. `full` is the default: the whole file
9
+ * with the changes marked inside it, which is what a reader wants when the
10
+ * hunks alone leave the edit without context. `diff` asks git for its own
11
+ * default context and shows only the changed regions.
12
+ */
13
+ export type DiffContentMode = 'full' | 'diff';
14
+
7
15
  interface DiffToolbarProps {
8
16
  filePath: string;
9
17
  status: string;
10
18
  isStaged: boolean;
11
19
  viewMode: DiffViewMode;
20
+ contentMode: DiffContentMode;
21
+ wordWrap: boolean;
12
22
  ignoreWhitespace: boolean;
13
23
  additions: number;
14
24
  deletions: number;
@@ -17,6 +27,8 @@ interface DiffToolbarProps {
17
27
  /** A stage/unstage/discard write is in flight; the toolbar locks its actions. */
18
28
  isBusy?: boolean;
19
29
  onSetViewMode: (mode: DiffViewMode) => void;
30
+ onSetContentMode: (mode: DiffContentMode) => void;
31
+ onToggleWordWrap: () => void;
20
32
  onToggleWhitespace: () => void;
21
33
  onStageUnstage: () => void;
22
34
  onDiscard: () => void;
@@ -33,6 +45,8 @@ export function DiffToolbar({
33
45
  status,
34
46
  isStaged,
35
47
  viewMode,
48
+ contentMode,
49
+ wordWrap,
36
50
  ignoreWhitespace,
37
51
  additions,
38
52
  deletions,
@@ -40,6 +54,8 @@ export function DiffToolbar({
40
54
  isCopied,
41
55
  isBusy = false,
42
56
  onSetViewMode,
57
+ onSetContentMode,
58
+ onToggleWordWrap,
43
59
  onToggleWhitespace,
44
60
  onStageUnstage,
45
61
  onDiscard,
@@ -73,11 +89,15 @@ export function DiffToolbar({
73
89
  </div>
74
90
 
75
91
  {/* Right: Controls & actions. `ml-auto` keeps them right-aligned where the
76
- identity block is hidden. This strip scrolls on its own when the panel
77
- is too narrow, so no action is ever clipped out of reach. Each segment
78
- sets its OWN mode — toggling meant clicking the already-active segment
79
- flipped the view to the other one. */}
80
- <div className="flex items-center space-x-0.5 @[440px]:space-x-1 text-ink/70 ml-auto min-w-0 overflow-x-auto no-scrollbar">
92
+ identity block is hidden. This strip WRAPS rather than scrolls: a
93
+ 320px panel cannot hold ten actions in one row, and the previous
94
+ `overflow-x-auto no-scrollbar` clipped the overflow invisibly —
95
+ measured 361px of content in a 296px box, with `Stage` entirely
96
+ outside it and no scrollbar to say so. Wrapping costs one extra row on
97
+ a narrow panel and keeps every action reachable. Each segment sets its
98
+ OWN mode — toggling meant clicking the already-active segment flipped
99
+ the view to the other one. */}
100
+ <div className="flex flex-wrap items-center justify-end gap-x-0.5 @[440px]:gap-x-1 gap-y-1 text-ink/70 ml-auto">
81
101
  {/* Change counts live here, not in the identity block: the editor tab
82
102
  carries the file name but never the counts, so this is the one piece
83
103
  of the header that must survive a narrow panel. */}
@@ -118,6 +138,57 @@ export function DiffToolbar({
118
138
  </button>
119
139
  </div>
120
140
 
141
+ {/* Content scope toggle (Full Code vs Diff Only). A separate group from
142
+ the view-mode toggle because the two are orthogonal: Unified/Split is
143
+ the shape of the rows, Full/Diff is how many of them there are. Each
144
+ segment SETS its mode — clicking the active one must not flip the
145
+ other, which is what a toggle would do. */}
146
+ <div className="flex items-center bg-canvas rounded border border-ink/15 p-0.5 mr-0.5 @[440px]:mr-1 flex-shrink-0">
147
+ <button
148
+ type="button"
149
+ onClick={() => onSetContentMode('full')}
150
+ className={`px-2 py-0.5 rounded text-[11px] font-medium flex items-center space-x-1 transition-colors cursor-pointer flex-shrink-0 ${
151
+ contentMode === 'full' ? 'bg-paper text-ink shadow-xs' : 'text-ink/60 hover:text-ink'
152
+ }`}
153
+ title="Full code (whole file, changes marked)"
154
+ aria-label="Full code (whole file, changes marked)"
155
+ aria-pressed={contentMode === 'full'}
156
+ >
157
+ <FileCode size={12} />
158
+ <span className="hidden @[420px]:inline">Full Code</span>
159
+ </button>
160
+ <button
161
+ type="button"
162
+ onClick={() => onSetContentMode('diff')}
163
+ className={`px-2 py-0.5 rounded text-[11px] font-medium flex items-center space-x-1 transition-colors cursor-pointer flex-shrink-0 ${
164
+ contentMode === 'diff' ? 'bg-paper text-ink shadow-xs' : 'text-ink/60 hover:text-ink'
165
+ }`}
166
+ title="Diff only (changed hunks)"
167
+ aria-label="Diff only (changed hunks)"
168
+ aria-pressed={contentMode === 'diff'}
169
+ >
170
+ <Diff size={12} />
171
+ <span className="hidden @[420px]:inline">Diff Only</span>
172
+ </button>
173
+ </div>
174
+
175
+ {/* Word wrap. On by default: a diff of a minified file or a long line
176
+ is unreadable when it runs off the panel, and horizontal scrolling
177
+ hides the change the reader opened the diff to see. Off keeps one
178
+ line = one row, which is what a column-aligned comparison needs. */}
179
+ <button
180
+ type="button"
181
+ onClick={onToggleWordWrap}
182
+ className={`p-1.5 rounded transition-colors cursor-pointer flex-shrink-0 ${
183
+ wordWrap ? 'bg-ink/10 text-ink' : 'text-ink/50 hover:text-ink hover:bg-ink/5'
184
+ }`}
185
+ title={wordWrap ? 'Word wrap on' : 'Word wrap off'}
186
+ aria-label={wordWrap ? 'Word wrap on' : 'Word wrap off'}
187
+ aria-pressed={wordWrap}
188
+ >
189
+ <WrapText size={13} />
190
+ </button>
191
+
121
192
  {/* Ignore whitespace toggle */}
122
193
  <button
123
194
  type="button"
@@ -6,9 +6,11 @@ import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
6
6
  interface UnifiedViewProps {
7
7
  lines: DiffLine[];
8
8
  language: string;
9
+ /** Wrap long lines instead of scrolling horizontally. */
10
+ wordWrap: boolean;
9
11
  }
10
12
 
11
- export function UnifiedView({ lines, language }: UnifiedViewProps) {
13
+ export function UnifiedView({ lines, language, wordWrap }: UnifiedViewProps) {
12
14
  const syntaxReady = useSyntaxReady();
13
15
 
14
16
  const htmlByIndex = useMemo(() => {
@@ -41,8 +43,13 @@ export function UnifiedView({ lines, language }: UnifiedViewProps) {
41
43
  {/* The room under the last row belongs to the content, not the scroller:
42
44
  a scroll container's own bottom padding is not part of its scrollable
43
45
  overflow, so the padding has to sit inside it to have any effect. */}
44
- <div className="pb-4">
45
- <table className="w-max min-w-full border-collapse">
46
+ <div className={`pb-4 ${wordWrap ? 'w-full' : 'w-max min-w-full'}`}>
47
+ {/* Wrap needs `table-fixed`: auto layout sizes a column by its widest
48
+ cell's UNAMBIGUOUS minimum (`overflow-wrap: break-word` does not
49
+ lower it), so a long line would still widen the table and scroll
50
+ instead of wrapping. Fixed layout takes the width from the row's
51
+ own box, which is what makes the cell's wrap rule effective. */}
52
+ <table className={`border-collapse ${wordWrap ? 'table-fixed w-full' : 'w-max min-w-full'}`}>
46
53
  <tbody>
47
54
  {lines.map((line, idx) => {
48
55
  if (line.type === 'meta') {
@@ -69,9 +76,13 @@ export function UnifiedView({ lines, language }: UnifiedViewProps) {
69
76
  : 'hover:bg-ink/[0.03]'
70
77
  }`}
71
78
  >
72
- {/* Old line number */}
79
+ {/* Old line number. Top-aligned when the row wraps, or the
80
+ number would float to the middle of a multi-line cell
81
+ instead of naming its first line. */}
73
82
  <td
74
83
  className={`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${
84
+ wordWrap ? 'align-top' : ''
85
+ } ${
75
86
  isDel
76
87
  ? 'bg-error/15 text-error font-semibold'
77
88
  : isAdd
@@ -85,6 +96,8 @@ export function UnifiedView({ lines, language }: UnifiedViewProps) {
85
96
  {/* New line number */}
86
97
  <td
87
98
  className={`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${
99
+ wordWrap ? 'align-top' : ''
100
+ } ${
88
101
  isAdd
89
102
  ? 'bg-success/15 text-success font-semibold'
90
103
  : isDel
@@ -96,7 +109,7 @@ export function UnifiedView({ lines, language }: UnifiedViewProps) {
96
109
  </td>
97
110
 
98
111
  {/* Change prefix & code text */}
99
- <td className="py-0.5 px-3 whitespace-pre text-ink pr-6">
112
+ <td className={`py-0.5 px-3 text-ink pr-6 ${wordWrap ? 'whitespace-pre-wrap break-words align-top' : 'whitespace-pre'}`}>
100
113
  <span
101
114
  className={`inline-block w-4 text-center select-none font-bold ${
102
115
  isAdd ? 'text-success' : isDel ? 'text-error' : 'opacity-0'
@@ -1,8 +1,8 @@
1
- import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
2
2
  import { useSessionUiState } from '@/client/hooks/workspace/session-state';
3
3
  import { parseUnifiedDiff } from '@/shared/lib/fs/diff-parser';
4
- import { getLanguageFromPath } from '@/shared/lib/code/syntax-highlight';
5
- import { DiffToolbar, type DiffViewMode } from '@/client/components/workspace/diff-panel/Toolbar';
4
+ import { getLanguageFromPath } from '@/shared/lib/code/language';
5
+ import { DiffToolbar, type DiffContentMode, type DiffViewMode } from '@/client/components/workspace/diff-panel/Toolbar';
6
6
  import { UnifiedView } from '@/client/components/workspace/diff-panel/UnifiedView';
7
7
  import { SplitView } from '@/client/components/workspace/diff-panel/SplitView';
8
8
  import { AlertCircle, RefreshCw } from 'lucide-preact';
@@ -30,6 +30,8 @@ export function DiffPanel({
30
30
  }: DiffPanelProps) {
31
31
  // Read and persist view mode & whitespace preference in session_ui_state
32
32
  const [viewMode, setViewMode] = useSessionUiState<DiffViewMode>('diff.viewMode', 'unified');
33
+ const [contentMode, setContentMode] = useSessionUiState<DiffContentMode>('diff.contentMode', 'full');
34
+ const [wordWrap, setWordWrap] = useSessionUiState<boolean>('diff.wordWrap', true);
33
35
  const [ignoreWhitespace, setIgnoreWhitespace] = useSessionUiState<boolean>('diff.ignoreWhitespace', false);
34
36
 
35
37
  const [rawDiff, setRawDiff] = useState<string>('');
@@ -66,6 +68,11 @@ export function DiffPanel({
66
68
  staged: staged ? '1' : '0',
67
69
  repo: repo || '.',
68
70
  });
71
+ // `fullContext=1` makes git emit the whole file rather than the hunks.
72
+ // Only the user's own toggle changes it, so it is safe as a callback
73
+ // dependency (unlike the staged/status copies above, which the action
74
+ // path updates while a fetch of its own is already in flight).
75
+ if (contentMode === 'full') params.set('fullContext', '1');
69
76
  if (statusForFetch) params.set('status', statusForFetch);
70
77
  if (root) params.set('root', root);
71
78
 
@@ -99,7 +106,7 @@ export function DiffPanel({
99
106
  } finally {
100
107
  if (seq === fetchSeqRef.current) setIsLoading(false);
101
108
  }
102
- }, [filePath, repo, root]);
109
+ }, [filePath, repo, root, contentMode]);
103
110
 
104
111
  useEffect(() => {
105
112
  setCurrentStatus(status);
@@ -112,13 +119,31 @@ export function DiffPanel({
112
119
  void fetchDiff();
113
120
  }, [fetchDiff]);
114
121
 
115
- const parsed = parseUnifiedDiff(rawDiff, ignoreWhitespace);
122
+ // Full-code mode parses the whole file, not the hunks, so the parse is keyed
123
+ // to its inputs: every render used to re-walk the diff (measured 21 ms on an
124
+ // 80k-line file), for a result that only changes when the diff or the
125
+ // whitespace toggle does.
126
+ // Only the rendered view is built: `views` keeps the shared flush (so the
127
+ // whitespace decision and the counts cannot diverge) while skipping the
128
+ // object graph nobody draws.
129
+ const parsed = useMemo(
130
+ () => parseUnifiedDiff(rawDiff, { ignoreWhitespace, views: viewMode === 'split' ? 'split' : 'unified' }),
131
+ [rawDiff, ignoreWhitespace, viewMode],
132
+ );
116
133
  const language = getLanguageFromPath(filePath);
117
134
 
118
135
  const handleSetViewMode = (mode: DiffViewMode) => {
119
136
  setViewMode(mode);
120
137
  };
121
138
 
139
+ const handleSetContentMode = (mode: DiffContentMode) => {
140
+ setContentMode(mode);
141
+ };
142
+
143
+ const handleToggleWordWrap = () => {
144
+ setWordWrap(!wordWrap);
145
+ };
146
+
122
147
  const handleToggleWhitespace = () => {
123
148
  setIgnoreWhitespace(!ignoreWhitespace);
124
149
  };
@@ -218,6 +243,8 @@ export function DiffPanel({
218
243
  status={currentStatus}
219
244
  isStaged={currentStaged}
220
245
  viewMode={viewMode}
246
+ contentMode={contentMode}
247
+ wordWrap={wordWrap}
221
248
  ignoreWhitespace={ignoreWhitespace}
222
249
  additions={parsed.additions}
223
250
  deletions={parsed.deletions}
@@ -225,6 +252,8 @@ export function DiffPanel({
225
252
  isCopied={isCopied}
226
253
  isBusy={isBusy}
227
254
  onSetViewMode={handleSetViewMode}
255
+ onSetContentMode={handleSetContentMode}
256
+ onToggleWordWrap={handleToggleWordWrap}
228
257
  onToggleWhitespace={handleToggleWhitespace}
229
258
  onStageUnstage={handleStageUnstage}
230
259
  onDiscard={handleDiscard}
@@ -268,9 +297,9 @@ export function DiffPanel({
268
297
  </button>
269
298
  </div>
270
299
  ) : viewMode === 'split' ? (
271
- <SplitView rows={parsed.splitRows} language={language} />
300
+ <SplitView rows={parsed.splitRows} language={language} wordWrap={wordWrap} />
272
301
  ) : (
273
- <UnifiedView lines={parsed.lines} language={language} />
302
+ <UnifiedView lines={parsed.lines} language={language} wordWrap={wordWrap} />
274
303
  )}
275
304
  </div>
276
305
 
@@ -68,14 +68,21 @@ export function EditorToolbar({
68
68
  : 'Save file (Ctrl+S)';
69
69
 
70
70
  return (
71
- <div className="flex items-center justify-between px-3 py-2 border-b border-ink/10 bg-paper flex-shrink-0">
71
+ // `@container`: the path below hides on a NARROW PANEL, not a narrow
72
+ // viewport. At the editor panel's 320px floor the action strip is 273px of
73
+ // `flex-shrink-0`, which left the path a measured 23px — a truncated
74
+ // fragment of a filename is not information, and the tab above already
75
+ // carries it.
76
+ <div className="@container flex items-center justify-between px-3 py-2 border-b border-ink/10 bg-paper flex-shrink-0">
72
77
  {/* Left status */}
73
- <div className="flex items-center space-x-2 text-xs font-mono text-ink/40 min-w-0">
78
+ <div className="hidden @[420px]:flex items-center space-x-2 text-xs font-mono text-ink/40 min-w-0">
74
79
  <span className="truncate max-w-[300px]" title={path}>{path}</span>
75
80
  </div>
76
81
 
77
- {/* Right actions */}
78
- <div className="flex items-center space-x-1 text-ink/40 flex-shrink-0">
82
+ {/* Right actions. `ml-auto` keeps them right-aligned once the path block
83
+ above is hidden, which `justify-between` alone would not do (a single
84
+ child sits at the start). */}
85
+ <div className="flex items-center space-x-1 text-ink/40 flex-shrink-0 ml-auto">
79
86
  {!isImage && (
80
87
  <div className="flex items-center pr-2 mr-1 border-r border-ink/10">
81
88
  <button
@@ -1,6 +1,7 @@
1
1
  import { useMemo, useState } from 'preact/hooks';
2
2
  import { ChevronDown, ChevronUp } from 'lucide-preact';
3
- import { getLanguageFromPath, highlightLines } from '@/shared/lib/code/syntax-highlight';
3
+ import { getLanguageFromPath } from '@/shared/lib/code/language';
4
+ import { highlightLines } from '@/shared/lib/code/syntax-highlight';
4
5
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
5
6
 
6
7
  interface DiffLineItem {
@@ -32,6 +32,16 @@ export interface SidebarDataHandle {
32
32
  initializing: boolean;
33
33
  /** Fire a refresh (dedup: while a load is in flight this is a no-op). */
34
34
  refresh: () => void;
35
+ /**
36
+ * User-initiated refresh: the same load as `refresh`, but it also raises
37
+ * `refreshing` until THAT load settles, so a toolbar button can spin while
38
+ * it runs. The background paths (idle poll, stream events, status ack) go
39
+ * through `refresh` and deliberately never raise the flag — a spinner that
40
+ * turns on by itself every 30s reads as a broken list.
41
+ */
42
+ refreshNow: () => void;
43
+ /** True while a `refreshNow` load is in flight. */
44
+ refreshing: boolean;
35
45
  /**
36
46
  * Mark a session's one-shot terminal badge as seen NOW: strips the check
37
47
  * optimistically for this mount and POSTs the server ack. No-op unless the
@@ -74,6 +84,22 @@ export function SidebarDataProvider({ children, initialFolders = [] }: { childre
74
84
  };
75
85
  }, [fetcher]);
76
86
 
87
+ // User-initiated refresh. Unlike `refresh` it does NOT skip an in-flight
88
+ // load — a click is an explicit "read it again now", and the fetcher aborts
89
+ // the superseded request rather than letting two answer. The spinner follows
90
+ // THIS load only: a token marks the newest click, so a background poll
91
+ // settling in between cannot stop a spin that is still running, and a
92
+ // superseded click cannot stop the newer one's.
93
+ const [refreshing, setRefreshing] = useState(false);
94
+ const refreshTokenRef = useRef(0);
95
+ const refreshNow = useCallback(() => {
96
+ const token = ++refreshTokenRef.current;
97
+ setRefreshing(true);
98
+ void fetcher.load('/api/sessions/list').finally(() => {
99
+ if (refreshTokenRef.current === token) setRefreshing(false);
100
+ });
101
+ }, [fetcher]);
102
+
77
103
  // Idle keep-alive: the throttled stream-event hook and useStreamPoll only
78
104
  // fire while THIS client streams, so changes made elsewhere (another tab,
79
105
  // a background omp process finishing, an archive from a second browser)
@@ -142,10 +168,12 @@ export function SidebarDataProvider({ children, initialFolders = [] }: { childre
142
168
  isMock: data?.isMock ?? false,
143
169
  initializing: !hasLoaded,
144
170
  refresh,
171
+ refreshNow,
172
+ refreshing,
145
173
  markSeen,
146
174
  hasSeen: (id) => seen.has(String(id)),
147
175
  };
148
- }, [fetcher.data, initialFolders, hasLoaded, refresh, markSeen]);
176
+ }, [fetcher.data, initialFolders, hasLoaded, refresh, refreshNow, refreshing, markSeen]);
149
177
 
150
178
  return <SidebarDataContext.Provider value={value}>{children}</SidebarDataContext.Provider>;
151
179
  }
@@ -163,6 +191,8 @@ export function useSidebarData(): SidebarDataHandle {
163
191
  isMock: false,
164
192
  initializing: true,
165
193
  refresh: () => {},
194
+ refreshNow: () => {},
195
+ refreshing: false,
166
196
  markSeen: () => {},
167
197
  hasSeen: () => false,
168
198
  };
@@ -51,6 +51,10 @@ export interface SessionSidebarController {
51
51
  folders: WorkspaceFolderData[];
52
52
  initializing: boolean;
53
53
  refresh: () => void;
54
+ /** User-initiated refresh — raises `refreshing` until that load settles. */
55
+ refreshNow: () => void;
56
+ /** True while a `refreshNow` load is in flight (drives the toolbar spinner). */
57
+ refreshing: boolean;
54
58
  activeSessionId: number | string | null;
55
59
  sessionParam: string | null;
56
60
  searchQuery: string;
@@ -71,7 +75,7 @@ export function useSessionSidebarController(
71
75
  options: SessionSidebarControllerOptions = {},
72
76
  ): SessionSidebarController {
73
77
  const { includePendingSessions = false, onSelectSession, onAfterSelect } = options;
74
- const { folders, initializing, refresh, markSeen, hasSeen } = useSidebarData();
78
+ const { folders, initializing, refresh, refreshNow, refreshing, markSeen, hasSeen } = useSidebarData();
75
79
  const [searchParams, setSearchParams] = useSearchParams();
76
80
  const sessionParam = searchParams.get('sessionId');
77
81
  const activeSessionId = sessionParam
@@ -232,6 +236,8 @@ export function useSessionSidebarController(
232
236
  folders,
233
237
  initializing,
234
238
  refresh: revalidate,
239
+ refreshNow,
240
+ refreshing,
235
241
  activeSessionId,
236
242
  sessionParam,
237
243
  searchQuery,
@@ -2,7 +2,7 @@
2
2
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
3
3
  import type { ChatMessageData, SubagentInfo } from '@/shared/types';
4
4
  import { isRecord } from '@/shared/lib/util/guards';
5
- import { parseSubagentLifecycle, parseSubagentProgress } from '@/shared/lib/omp/subagent/parse';
5
+ import { parseSubagentLifecycle, readSubagentProgressFrame } from '@/shared/lib/omp/subagent/parse';
6
6
  import { convertMessages, mergeMessages, requestHistoryPage, requestSubagentPage } from '@/shared/lib/omp/subagent/transcript-client';
7
7
 
8
8
  /**
@@ -86,6 +86,9 @@ export function useSubagentTranscript(
86
86
  const nextByteRef = useRef(0);
87
87
  const sessionFileRef = useRef<string | undefined>(undefined);
88
88
  const readyRef = useRef(false);
89
+ /** False once the live RPC has refused this subagent (its process is gone).
90
+ * Later tail fetches then read the on-disk transcript directly. */
91
+ const rpcAvailableRef = useRef(true);
89
92
  const flushingRef = useRef(false);
90
93
  const queuedRef = useRef(false);
91
94
  const flushTimerRef = useRef<number | null>(null);
@@ -103,8 +106,25 @@ export function useSubagentTranscript(
103
106
  flushingRef.current = true;
104
107
  const generation = generationRef.current;
105
108
  try {
106
- const page = await requestSubagentPage(sid, active.id, sessionFileRef.current, nextByteRef.current);
107
- if (generation !== generationRef.current || !page) return;
109
+ const fromByte = nextByteRef.current;
110
+ // Once the RPC has answered "not managed", stop probing it: the child is
111
+ // gone for the rest of this view's life, and every later tail would pay a
112
+ // refused round-trip before falling back anyway. The disk path below is
113
+ // the same transcript file the RPC was reading.
114
+ let page = rpcAvailableRef.current
115
+ ? await requestSubagentPage(sid, active.id, sessionFileRef.current, fromByte)
116
+ : null;
117
+ if (generation !== generationRef.current) return;
118
+ if (!page) {
119
+ // The session's omp process is gone (the child finished, the idle
120
+ // reclaim took it, or the chamber restarted) — the RPC is refused for
121
+ // an unmanaged session rather than respawning one for a read. Continue
122
+ // on disk; without this the view froze on whatever the last live page
123
+ // held, which is exactly the final turns of a just-finished subagent.
124
+ rpcAvailableRef.current = false;
125
+ page = await requestHistoryPage(sid, active.id, fromByte);
126
+ if (generation !== generationRef.current || !page) return;
127
+ }
108
128
  sessionFileRef.current = page.sessionFile || sessionFileRef.current;
109
129
  const converted = convertMessages(page.messages, true);
110
130
  if (page.reset) {
@@ -163,6 +183,7 @@ export function useSubagentTranscript(
163
183
  nextByteRef.current = 0;
164
184
  sessionFileRef.current = initial?.sessionFile;
165
185
  readyRef.current = false;
186
+ rpcAvailableRef.current = true;
166
187
 
167
188
  const scheduleTail = () => {
168
189
  if (flushTimerRef.current !== null) return;
@@ -188,8 +209,10 @@ export function useSubagentTranscript(
188
209
  if (!isCurrent()) return;
189
210
  if (!result && !viaHistory && fromByte === 0) {
190
211
  // The parent RPC dropped this subagent (already finished) — the
191
- // on-disk transcript is authoritative from here on.
212
+ // on-disk transcript is authoritative from here on, and the tail
213
+ // fetch must not keep probing a refused RPC.
192
214
  viaHistory = true;
215
+ rpcAvailableRef.current = false;
193
216
  result = await requestHistoryPage(sessionId, subagentId, 0);
194
217
  if (!isCurrent()) return;
195
218
  }
@@ -236,15 +259,18 @@ export function useSubagentTranscript(
236
259
  if (!detail) return;
237
260
  const payload = isRecord(detail.payload) ? detail.payload : null;
238
261
  if (!payload) return;
239
- const payloadId = typeof payload.id === 'string' ? payload.id : null;
240
- if (payloadId && payloadId !== subagentId) return;
241
- if (!payloadId) {
242
- const payloadIndex = typeof payload.index === 'number' ? payload.index : null;
262
+ // `subagent_progress` nests the child's AgentProgress under `progress`
263
+ // (SubagentProgressPayload), and only the NESTED snapshot carries the id
264
+ // plus the resolved model/thinking level — reading the wrapper alone
265
+ // dropped every field it reports.
266
+ const progress = readSubagentProgressFrame(payload);
267
+ if (!progress) return;
268
+ if (progress.id !== undefined) {
269
+ if (progress.id !== subagentId) return;
270
+ } else {
243
271
  // Index is only unique within a spawning batch — require the exact match.
244
- if (payloadIndex === null || payloadIndex !== (subagentRef.current?.index ?? -1)) return;
272
+ if (progress.index === undefined || progress.index !== (subagentRef.current?.index ?? -1)) return;
245
273
  }
246
- const progress = parseSubagentProgress(payload);
247
- if (!progress) return;
248
274
  setStatus(prev => {
249
275
  if (!prev || prev.id !== subagentId) return prev;
250
276
  const next: SubagentInfo = { ...prev, progress: { ...prev.progress, ...progress } };
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
- import { getLanguageFromPath } from '@/shared/lib/code/syntax-highlight';
2
+ import { getLanguageFromPath } from '@/shared/lib/code/language';
3
3
  import { getImageMimeType } from '@/shared/lib/fs/file-kind';
4
4
  import { buildFsRawUrl } from '@/shared/lib/fs/paths';
5
5
  import { detectLineEnding, toDiskText, toLf, type LineEnding } from '@/shared/lib/code/line-endings';
@@ -0,0 +1,33 @@
1
+ import { useCallback, useState } from 'preact/hooks';
2
+
3
+ /** Which of a two-pane settings category's screens a phone is showing. */
4
+ export type SettingsPane = 'list' | 'detail';
5
+
6
+ export interface SettingsMasterDetailHandle {
7
+ pane: SettingsPane;
8
+ /** Called by the category's own row handler, never derived from selection. */
9
+ openDetail: () => void;
10
+ back: () => void;
11
+ }
12
+
13
+ /**
14
+ * Pane state for the settings categories that are two panes on a desktop.
15
+ *
16
+ * A phone has no room for a 224px list beside a detail pane: measured on a
17
+ * 412x915 Android viewport, the detail was left 186px wide, and the categories
18
+ * whose container stacked gave it 48px of height. Below `md` the two panes are
19
+ * therefore two screens, and this is which one is up.
20
+ *
21
+ * The pane is opened by an EXPLICIT row choice, never by a selection that
22
+ * merely exists: `useCrudList` selects its first row on load and the provider
23
+ * list falls back to `connectedProviders[0]`, so deriving the pane from
24
+ * `selectedId` would drop the user into a detail pane they never asked for and
25
+ * hide the list they came to browse. Desktop ignores this state entirely —
26
+ * `md:` shows both panes.
27
+ */
28
+ export function useSettingsMasterDetail(): SettingsMasterDetailHandle {
29
+ const [pane, setPane] = useState<SettingsPane>('list');
30
+ const openDetail = useCallback(() => setPane('detail'), []);
31
+ const back = useCallback(() => setPane('list'), []);
32
+ return { pane, openDetail, back };
33
+ }