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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +279 -280
- package/dist/client/static/js/index-01dv383b.css +1 -0
- package/dist/client/static/js/{index-qhzsybkn.js → index-1q6165r3.js} +641 -641
- package/package.json +1 -1
- package/src/cli/lib/output.js +15 -8
- package/src/client/components/common/Modal.tsx +14 -4
- package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
- package/src/client/components/layout/session-sidebar/index.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
- package/src/client/components/mobile/SessionSidebar.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +66 -29
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +50 -12
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
- package/src/client/components/settings/Modal.tsx +35 -6
- package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
- package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
- package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
- package/src/client/components/settings/categories/McpSettings.tsx +48 -31
- package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
- package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
- package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
- package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
- package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
- package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
- package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
- package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
- package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/master-detail/index.tsx +68 -0
- package/src/client/components/workspace/SearchPanel.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
- package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
- package/src/client/components/workspace/diff-panel/index.tsx +36 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
- package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
- package/src/client/hooks/chat/omp/session-list.tsx +31 -1
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
- package/src/client/hooks/chat/subagent/index.ts +37 -11
- package/src/client/hooks/editor/use-file-editor.ts +1 -1
- package/src/client/hooks/settings/master-detail.ts +33 -0
- package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
- package/src/server/lib/db/client.ts +5 -1
- package/src/server/lib/fs/git-diff.ts +34 -10
- package/src/server/lib/fs/git-repos.ts +81 -0
- package/src/server/lib/lifecycle/flock.ts +199 -0
- package/src/server/lib/lifecycle/identity.ts +5 -1
- package/src/server/lib/lifecycle/port-guard.ts +18 -3
- package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
- package/src/server/lib/lifecycle/proc/linux.ts +29 -7
- package/src/server/lib/lifecycle/proc/ps.ts +7 -0
- package/src/server/lib/lifecycle/proc/types.ts +17 -0
- package/src/server/lib/lifecycle/proc/win32.ts +5 -0
- package/src/server/lib/observer-ws.ts +150 -0
- package/src/server/lib/omp/rpc/constants.ts +9 -0
- package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
- package/src/server/lib/omp/session/file-scan.ts +7 -1
- package/src/server/lib/omp/session/files.ts +95 -96
- package/src/server/lib/omp/session/locator.ts +18 -8
- package/src/server/lib/omp/session/messages.ts +7 -7
- package/src/server/lib/omp/session/scan.ts +108 -0
- package/src/server/lib/omp/session/stats.ts +50 -50
- package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
- package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
- package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
- package/src/server/lib/terminal/runtime.server.ts +3 -0
- package/src/server/lib/terminal/session.server.ts +30 -3
- package/src/server/routes/agent/command.test.ts +69 -0
- package/src/server/routes/agent/command.ts +14 -0
- package/src/server/routes/agent/ws.test.ts +159 -0
- package/src/server/routes/agent/ws.ts +22 -97
- package/src/server/routes/btw/ws.ts +22 -95
- package/src/server/routes/fs/git.ts +61 -70
- package/src/server/routes/omp/session-stats.ts +1 -1
- package/src/server/routes/telemetry/tokens.ts +6 -4
- package/src/server/routes/terminal/run.ts +4 -8
- package/src/server/routes/terminal/ws.ts +24 -2
- package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
- package/src/shared/lib/chat/omp/agent-events.ts +10 -0
- package/src/shared/lib/code/language.ts +146 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
- package/src/shared/lib/code/syntax-highlight.ts +0 -122
- package/src/shared/lib/fs/diff-parser.test.ts +5 -5
- package/src/shared/lib/fs/diff-parser.ts +67 -42
- package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
- package/src/shared/lib/omp/subagent/parse.ts +17 -0
- package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
- package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
- package/src/shared/types/omp/subagent.ts +6 -0
- 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=
|
|
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
|
-
|
|
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={`
|
|
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={`
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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=
|
|
45
|
-
|
|
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=
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
107
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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 (
|
|
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/
|
|
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
|
+
}
|