ompchamber 1.0.0 → 1.1.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 (64) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b859cc1cc5.css +2 -0
  3. package/dist/client/static/js/async/{5b.0d6ae50fe8.js → 5b.a5ccbc19dc.js} +2 -2
  4. package/dist/client/static/js/index.6e065017eb.js +33 -0
  5. package/package.json +1 -1
  6. package/src/client/components/common/ComposerPicker.tsx +16 -4
  7. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  8. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  9. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  10. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  11. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  12. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  13. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  14. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  15. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  16. package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
  17. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  18. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +3 -0
  19. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  20. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  21. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +23 -3
  22. package/src/client/components/workspace/chat-timeline/index.tsx +3 -0
  23. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  24. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  25. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  26. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  27. package/src/client/hooks/chat/composer/index.ts +28 -8
  28. package/src/client/hooks/chat/timeline/actions.ts +24 -3
  29. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  30. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  31. package/src/client/hooks/chat/timeline/index.ts +13 -1
  32. package/src/client/hooks/chat/timeline/send.ts +16 -5
  33. package/src/client/hooks/workspace/available-width.ts +33 -0
  34. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  35. package/src/server/lib/models/preferences.server.ts +110 -0
  36. package/src/server/lib/models/registry.server.ts +206 -0
  37. package/src/server/lib/omp/rpc/utility.ts +7 -1
  38. package/src/server/lib/queue/delivery.server.test.ts +203 -0
  39. package/src/server/lib/queue/delivery.server.ts +38 -8
  40. package/src/server/routes/models/root.ts +88 -215
  41. package/src/server/routes/settings/commands.ts +59 -18
  42. package/src/shared/lib/chat/composer/client.ts +17 -8
  43. package/src/shared/lib/chat/composer/commands.ts +290 -0
  44. package/src/shared/lib/chat/composer/composer.test.ts +157 -0
  45. package/src/shared/lib/chat/composer/filter.ts +21 -7
  46. package/src/shared/lib/chat/composer/trigger.ts +109 -20
  47. package/src/shared/lib/models/preferences.test.ts +131 -0
  48. package/src/shared/lib/models/preferences.ts +88 -0
  49. package/src/shared/lib/models/provider-models.ts +8 -1
  50. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  51. package/src/shared/lib/omp/subagent/label.ts +84 -0
  52. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  53. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  54. package/src/shared/lib/workspace/right-panels.ts +50 -11
  55. package/src/shared/lib/workspace/session-state/store.ts +3 -0
  56. package/src/shared/types/chat.ts +38 -65
  57. package/src/shared/types/index.ts +1 -0
  58. package/src/shared/types/models.ts +101 -0
  59. package/src/shared/types/omp/subagent.ts +4 -0
  60. package/src/shared/types/settings/command.ts +13 -0
  61. package/dist/client/static/css/index.e880517fcd.css +0 -2
  62. package/dist/client/static/js/index.0de15c5e17.js +0 -33
  63. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
  64. /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.6e065017eb.js.LICENSE.txt} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ompchamber",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Developer web console and diagnostic chamber for AI Oh-My-Pi — Elysia + Preact on Bun.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
@@ -1,10 +1,12 @@
1
1
  import { useEffect, useRef } from 'preact/hooks';
2
2
  import type { ReactElement } from 'preact/compat';
3
- import type { ComposerMatchItem, ComposerPickItem, ComposerPickKind } from '@/shared/types';
3
+ import type { ComposerMatchItem, ComposerPickItem, ComposerPickKind, ComposerTriggerPhase } from '@/shared/types';
4
4
 
5
5
  export interface ComposerPickerProps {
6
6
  open: boolean;
7
7
  kind: ComposerPickKind;
8
+ /** `args` swaps the header for the subcommand hint of the typed command. */
9
+ phase?: ComposerTriggerPhase;
8
10
  items: ComposerMatchItem[];
9
11
  activeIndex: number;
10
12
  loading: boolean;
@@ -37,6 +39,7 @@ function OptionName({ item }: { item: ComposerMatchItem }) {
37
39
  export function ComposerPicker({
38
40
  open,
39
41
  kind,
42
+ phase = 'name',
40
43
  items,
41
44
  activeIndex,
42
45
  loading,
@@ -56,9 +59,18 @@ export function ComposerPicker({
56
59
  if (!open) return null;
57
60
 
58
61
  const isMention = kind === 'mention';
59
- const headerText = isMention ? 'Type to search files and agents' : 'Type to search commands and skills';
60
- const ariaLabel = isMention ? 'Files and agents' : 'Commands and skills';
61
- const emptyText = isMention ? 'No matching files or agents' : 'No matching commands or skills';
62
+ const isArgs = !isMention && phase === 'args';
63
+ const headerText = isMention
64
+ ? 'Type to search files and agents'
65
+ : isArgs
66
+ ? 'Pick a subcommand'
67
+ : 'Type to search commands and skills';
68
+ const ariaLabel = isMention ? 'Files and agents' : isArgs ? 'Subcommands' : 'Commands and skills';
69
+ const emptyText = isMention
70
+ ? 'No matching files or agents'
71
+ : isArgs
72
+ ? 'No matching subcommands'
73
+ : 'No matching commands or skills';
62
74
 
63
75
  return (
64
76
  <div
@@ -117,6 +117,7 @@ export function ComposerTextarea({
117
117
  <ComposerPicker
118
118
  open={composer.isOpen}
119
119
  kind={composer.trigger?.kind ?? 'mention'}
120
+ phase={composer.trigger?.phase ?? 'name'}
120
121
  items={composer.matches}
121
122
  activeIndex={composer.activeIndex}
122
123
  loading={composer.loading}
@@ -34,7 +34,7 @@ export function RightActivityBar({ activePanel, onChangePanel, isPanelOpen, hasA
34
34
  const hasGitChanges = changes.length > 0;
35
35
 
36
36
  return (
37
- <nav className="w-12 flex-shrink-0 border-l border-ink/10 bg-paper flex flex-col items-center py-3 space-y-2 z-10">
37
+ <nav className="w-11 flex-shrink-0 border-l border-ink/10 bg-paper flex flex-col items-center py-3 space-y-2 z-10">
38
38
  {/* Top Icons */}
39
39
  <div className="flex flex-col items-center space-y-1 w-full">
40
40
  {RIGHT_PANEL_TYPES.map((panel) => {
@@ -3,8 +3,8 @@ import { Separator } from '@/client/components/layout/desktop-layout/resizer';
3
3
  /** Hairline drag handle shared by both resizable groups in the desktop layout. */
4
4
  export function ResizeHandle() {
5
5
  return (
6
- <Separator className="relative w-1 outline-none group flex justify-center cursor-col-resize z-10">
7
- <div className="h-full w-[1px] bg-ink/10 group-hover:bg-ink/40 group-active:bg-ink/60 group-hover:w-0.5 transition-all" />
6
+ <Separator className="relative w-[3px] outline-none group flex justify-center cursor-col-resize z-10">
7
+ <div className="h-full w-[1px] bg-ink/10 group-hover:bg-ink/40 group-active:bg-ink/60 group-hover:w-[3px] transition-all" />
8
8
  </Separator>
9
9
  );
10
10
  }
@@ -1,4 +1,4 @@
1
- import { LayoutTemplate, MoreHorizontal, PanelRight, PanelRightClose, Smartphone } from 'lucide-preact';
1
+ import { LayoutTemplate, MoreHorizontal, PanelLeft, PanelRight, PanelRightClose, Smartphone } from 'lucide-preact';
2
2
  import { PWAInstallButton } from '@/client/components/common/PWAInstallButton';
3
3
  import { StreamStatusDot } from '@/client/components/common/StreamStatusDot';
4
4
  import type { AgentStreamStatus } from '@/shared/lib/chat/omp/status';
@@ -13,6 +13,7 @@ interface TopNavbarProps {
13
13
  onSwitchToMobile?: () => void;
14
14
  onToggleEditor: () => void;
15
15
  onToggleRightPanel: () => void;
16
+ onToggleLeftPanel: () => void;
16
17
  }
17
18
 
18
19
  export function TopNavbar({
@@ -24,10 +25,11 @@ export function TopNavbar({
24
25
  onSwitchToMobile,
25
26
  onToggleEditor,
26
27
  onToggleRightPanel,
28
+ onToggleLeftPanel,
27
29
  }: TopNavbarProps) {
28
30
  return (
29
31
  <header
30
- className="h-12 flex-shrink-0 border-b border-ink/10 bg-paper flex items-center justify-between pr-4 z-20 titlebar-drag-region select-none"
32
+ className="h-14 flex-shrink-0 border-b border-ink/10 bg-paper flex items-center justify-between pr-4 z-20 titlebar-drag-region select-none"
31
33
  style={{
32
34
  paddingLeft: !showLeftPanel ? 'max(1rem, env(titlebar-area-x, 0px))' : undefined,
33
35
  paddingRight: 'max(1rem, calc(100vw - env(titlebar-area-width, 100vw)))',
@@ -36,6 +38,15 @@ export function TopNavbar({
36
38
  <div className="flex items-center space-x-2 px-4 min-w-0">
37
39
  {!showLeftPanel && (
38
40
  <>
41
+ <button
42
+ type="button"
43
+ onClick={onToggleLeftPanel}
44
+ className="p-1.5 rounded hover:bg-ink/10 transition-colors text-ink/60 hover:text-ink cursor-pointer flex-shrink-0 titlebar-no-drag"
45
+ title="Expand Sidebar"
46
+ aria-label="Expand Sidebar"
47
+ >
48
+ <PanelLeft size={16} />
49
+ </button>
39
50
  <span className="font-bold text-sm tracking-tight hidden sm:flex items-center flex-shrink-0">
40
51
  <span className="text-transparent bg-clip-text bg-gradient-to-r from-orange-600 to-amber-500 font-extrabold text-[15px] tracking-tighter">OMP</span>
41
52
  <span className="ml-[1px]">Chamber</span>
@@ -6,15 +6,32 @@ import { ChatTimeline } from '@/client/components/workspace/chat-timeline/index'
6
6
  import { getDesktopPanelView } from '@/client/components/common/lazy-panels';
7
7
  import { RightActivityBar } from '@/client/components/layout/RightActivityBar';
8
8
  import { ResizeHandle } from '@/client/components/layout/desktop-layout/ResizeHandle';
9
- import { DEFAULT_RIGHT_PANEL_WIDTHS, MAX_RIGHT_PANEL_WIDTH, MIN_RIGHT_PANEL_WIDTHS, RIGHT_PANEL_TYPES, type RightPanelType } from '@/shared/lib/workspace/right-panels';
10
- import { DEFAULT_PANEL_WIDTHS, MAX_EDITOR_PANEL_WIDTH, MIN_CHAT_PANEL_WIDTH, MIN_EDITOR_PANEL_WIDTH, type EditorWidthMode, type PanelWidths } from '@/shared/lib/workspace/panel-widths';
9
+ import { useAvailableWidth } from '@/client/hooks/workspace/available-width';
10
+ import {
11
+ DEFAULT_RIGHT_PANEL_FRACTIONS,
12
+ DEFAULT_RIGHT_PANEL_WIDTHS,
13
+ MIN_RIGHT_PANEL_WIDTHS,
14
+ RIGHT_PANEL_TYPES,
15
+ type RightPanelType,
16
+ } from '@/shared/lib/workspace/right-panels';
17
+ import {
18
+ DEFAULT_EDITOR_FRACTIONS,
19
+ DEFAULT_EDITOR_WIDTHS,
20
+ MIN_CHAT_PANEL_WIDTH,
21
+ MIN_EDITOR_PANEL_WIDTH,
22
+ resolvePanelWidth,
23
+ type EditorWidthMode,
24
+ type PanelWidths,
25
+ } from '@/shared/lib/workspace/panel-widths';
11
26
 
12
27
  const Editor = lazy(() => import('@/client/components/workspace/editor/index').then((m) => ({ default: m.Editor })));
13
28
 
14
29
  function PanelSuspense({ children }: { children: ReactNode }) {
15
30
  return (
16
31
  <Suspense fallback={
17
- <div className="w-full h-full flex items-center justify-center text-ink/40 text-xs">Loading…</div>
32
+ <div className="w-full h-full flex items-center justify-center text-ink/40">
33
+ <span className="text-xs font-mono">Loading…</span>
34
+ </div>
18
35
  }>
19
36
  {children}
20
37
  </Suspense>
@@ -72,9 +89,12 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
72
89
  onPanelWidths,
73
90
  } = props;
74
91
 
75
- // Read inside effects without making a drag re-trigger them.
76
- const panelWidthsRef = useRef(panelWidths);
77
- panelWidthsRef.current = panelWidths;
92
+ // The group's own area: the base every stored fraction is a share of, and the
93
+ // budget the chat floor is subtracted from. Measured through the group's
94
+ // element, not a wrapper — the wrapper also spans the activity bar, which is
95
+ // not area any panel may take.
96
+ const groupRef = useRef<HTMLDivElement>(null);
97
+ const available = useAvailableWidth(groupRef);
78
98
 
79
99
  // One cached lazy per view, scoped to this layout instance: switching the
80
100
  // right panel toggles CSS visibility instead of unmounting, so a view's
@@ -86,18 +106,55 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
86
106
  [viewScope],
87
107
  );
88
108
 
109
+ // Separators present in this group, which also consume width.
110
+ const handleCount = (showEditor ? 1 : 0) + (showRightPanel ? 1 : 0);
111
+
112
+ const editorWidth = resolvePanelWidth({
113
+ stored: panelWidths[editorWidthMode],
114
+ defaultFraction: DEFAULT_EDITOR_FRACTIONS[editorWidthMode],
115
+ defaultPx: DEFAULT_EDITOR_WIDTHS[editorWidthMode],
116
+ available,
117
+ min: MIN_EDITOR_PANEL_WIDTH,
118
+ // The right panel renders after the editor, so it absorbs the remainder.
119
+ // Reserving this view's own floor is enough: the editor's share is what
120
+ // the ceiling subtracts.
121
+ siblingMin: showRightPanel ? MIN_RIGHT_PANEL_WIDTHS[activeRightPanel] : 0,
122
+ handleCount,
123
+ });
124
+
125
+ const rightWidth = resolvePanelWidth({
126
+ stored: panelWidths.right?.[activeRightPanel],
127
+ defaultFraction: DEFAULT_RIGHT_PANEL_FRACTIONS[activeRightPanel],
128
+ defaultPx: DEFAULT_RIGHT_PANEL_WIDTHS[activeRightPanel],
129
+ available,
130
+ min: MIN_RIGHT_PANEL_WIDTHS[activeRightPanel],
131
+ // The editor took its share first; only its floor is reserved here.
132
+ siblingMin: showEditor ? MIN_EDITOR_PANEL_WIDTH : 0,
133
+ handleCount,
134
+ });
135
+
89
136
  /**
90
137
  * Widths of the fixed panels as they are right now, keyed by slot: the
91
138
  * editor (source or diff, whichever is showing) and the right panel under
92
139
  * the view it currently holds. The chat column is the group's filler, so it
93
140
  * has no width of its own to report.
141
+ *
142
+ * Each is stored as a share of the area it was measured in, so the layout
143
+ * follows a window resize instead of holding a pixel value that was only
144
+ * right on the display it was dragged on.
94
145
  */
95
146
  const readPanelWidths = (): PanelWidths => {
96
147
  const patch: PanelWidths = {};
97
148
  const editor = editorPanelRef.current?.getSize()?.inPixels;
98
149
  const right = rightPanelRef.current?.getSize()?.inPixels;
99
- if (editor != null && editor > 0) patch[editorWidthMode] = Math.round(editor);
100
- if (right != null && right > 0) patch.right = { [activeRightPanel]: Math.round(right) };
150
+ if (editor != null && editor > 0) {
151
+ patch[editorWidthMode] = { px: Math.round(editor), ...(available ? { fraction: editor / available } : {}) };
152
+ }
153
+ if (right != null && right > 0) {
154
+ patch.right = {
155
+ [activeRightPanel]: { px: Math.round(right), ...(available ? { fraction: right / available } : {}) },
156
+ };
157
+ }
101
158
  return patch;
102
159
  };
103
160
 
@@ -106,6 +163,8 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
106
163
  <Group
107
164
  orientation="horizontal"
108
165
  id="ompchamber-layout"
166
+ className="flex-1 min-w-0"
167
+ groupRef={groupRef}
109
168
  onLayoutChanged={() => {
110
169
  // Persist only real drags: imperative restores and constraint
111
170
  // recomputes would otherwise rewrite the saved widths with the
@@ -117,55 +176,47 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
117
176
  <ChatTimeline className="w-full h-full" appSettings={appSettings} onSessionTitle={onSessionTitle} />
118
177
  </Panel>
119
178
 
120
- {showEditor && (
121
- <>
122
- <ResizeHandle />
123
- <Panel panelRef={editorPanelRef} id="editor-panel" defaultSize={panelWidths[editorWidthMode] ?? DEFAULT_PANEL_WIDTHS[editorWidthMode]} minSize={MIN_EDITOR_PANEL_WIDTH} maxSize={MAX_EDITOR_PANEL_WIDTH}>
124
- <PanelSuspense>
125
- <Editor
126
- className="w-full h-full"
127
- openedFiles={openedFiles}
128
- activeFileId={activeFileId}
129
- onSelectFile={onSetActiveFileId}
130
- onCloseFile={onCloseFile}
131
- refreshKey={refreshKey}
132
- onFileSaved={onRefreshWorkspace}
133
- />
134
- </PanelSuspense>
135
- </Panel>
136
- </>
137
- )}
179
+ {showEditor && <ResizeHandle />}
180
+ <Panel panelRef={editorPanelRef} id="editor-panel" defaultSize={editorWidth} minSize={MIN_EDITOR_PANEL_WIDTH} collapsed={!showEditor}>
181
+ <PanelSuspense>
182
+ <Editor
183
+ className="w-full h-full"
184
+ openedFiles={openedFiles}
185
+ activeFileId={activeFileId}
186
+ onSelectFile={onSetActiveFileId}
187
+ onCloseFile={onCloseFile}
188
+ refreshKey={refreshKey}
189
+ onFileSaved={onRefreshWorkspace}
190
+ />
191
+ </PanelSuspense>
192
+ </Panel>
138
193
 
139
- {showRightPanel && (
140
- <>
141
- <ResizeHandle />
142
- <Panel panelRef={rightPanelRef} id="right-panel" defaultSize={panelWidths.right?.[activeRightPanel] ?? DEFAULT_RIGHT_PANEL_WIDTHS[activeRightPanel]} minSize={MIN_RIGHT_PANEL_WIDTHS[activeRightPanel]} maxSize={MAX_RIGHT_PANEL_WIDTH} collapsible>
143
- <PanelSuspense>
144
- {rightViews.map(({ view, Comp }) => {
145
- const isActiveView = activeRightPanel === view;
146
- const base = {
147
- className: 'w-full h-full',
148
- // The terminal must stay enabled while hidden: swapping it
149
- // to the placeholder mid-command would unmount the live
150
- // stream. Everything else polls/re-reads only while shown.
151
- enabled: view === 'terminal' ? hasActiveContext : hasActiveContext && isActiveView,
152
- active: isActiveView,
153
- refreshKey,
154
- rootPath: activeProjectPath ?? undefined,
155
- onRefresh: onRefreshWorkspace,
156
- onOpenFile,
157
- onClose: onToggleRightPanel,
158
- };
159
- return (
160
- <div key={view} className={isActiveView ? 'w-full h-full' : 'hidden'}>
161
- <Comp {...base} />
162
- </div>
163
- );
164
- })}
165
- </PanelSuspense>
166
- </Panel>
167
- </>
168
- )}
194
+ {showRightPanel && <ResizeHandle />}
195
+ <Panel panelRef={rightPanelRef} id="right-panel" defaultSize={rightWidth} minSize={MIN_RIGHT_PANEL_WIDTHS[activeRightPanel]} collapsed={!showRightPanel}>
196
+ <PanelSuspense>
197
+ {rightViews.map(({ view, Comp }) => {
198
+ const isActiveView = activeRightPanel === view;
199
+ const base = {
200
+ className: 'w-full h-full',
201
+ // The terminal must stay enabled while hidden: swapping it
202
+ // to the placeholder mid-command would unmount the live
203
+ // stream. Everything else polls/re-reads only while shown.
204
+ enabled: view === 'terminal' ? hasActiveContext : hasActiveContext && isActiveView,
205
+ active: isActiveView,
206
+ refreshKey,
207
+ rootPath: activeProjectPath ?? undefined,
208
+ onRefresh: onRefreshWorkspace,
209
+ onOpenFile,
210
+ onClose: onToggleRightPanel,
211
+ };
212
+ return (
213
+ <div key={view} className={isActiveView ? 'w-full h-full' : 'hidden'}>
214
+ <Comp {...base} />
215
+ </div>
216
+ );
217
+ })}
218
+ </PanelSuspense>
219
+ </Panel>
169
220
  </Group>
170
221
 
171
222
  <RightActivityBar activePanel={activeRightPanel} onChangePanel={onChangeRightPanel} isPanelOpen={showRightPanel} hasActiveContext={hasActiveContext} activeProjectPath={activeProjectPath} refreshKey={refreshKey} />
@@ -1,11 +1,9 @@
1
- import type { RefObject } from 'preact/compat';
2
- import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
1
+ import { useCallback, useMemo, useRef, useState } from 'preact/hooks';
3
2
  import { useSearchParams } from '@/client/lib/router/search-params';
4
3
  import { Group, Panel, type PanelImperativeHandle } from '@/client/components/layout/desktop-layout/resizer';
5
4
  import { SessionSidebar } from '@/client/components/layout/session-sidebar/index';
6
- import { DEFAULT_RIGHT_PANEL_WIDTHS, type RightPanelType } from '@/shared/lib/workspace/right-panels';
5
+ import type { RightPanelType } from '@/shared/lib/workspace/right-panels';
7
6
  import { SettingsModal } from '@/client/components/settings/LazyModal';
8
- import { PanelLeft } from 'lucide-preact';
9
7
  import type { SettingsCategoryId } from '@/shared/types';
10
8
  import { activeProjectForSession } from '@/shared/lib/workspace/active-project';
11
9
  import { useFileTabs } from '@/client/hooks/workspace/file-tabs';
@@ -15,9 +13,8 @@ import { WorkspacePanels } from '@/client/components/layout/desktop-layout/Works
15
13
  import { useAgentStreamStatus } from '@/client/hooks/chat/omp/status';
16
14
  import { usePanelWidths } from '@/client/hooks/workspace/panel-widths';
17
15
  import { useSidebarData } from '@/client/hooks/chat/omp/session-list';
18
- import { DEFAULT_PANEL_WIDTHS, MAX_LEFT_PANEL_WIDTH, MIN_LEFT_PANEL_WIDTH, type EditorWidthMode } from '@/shared/lib/workspace/panel-widths';
16
+ import { DEFAULT_LEFT_PANEL_WIDTH, MAX_LEFT_PANEL_WIDTH, MIN_LEFT_PANEL_WIDTH, type EditorWidthMode } from '@/shared/lib/workspace/panel-widths';
19
17
  import { ResizeHandle } from '@/client/components/layout/desktop-layout/ResizeHandle';
20
- import { writeSetting } from '@/shared/lib/settings/client';
21
18
  import { useChamberEvent, useWindowEvent } from '@/client/hooks/ui/window-event';
22
19
 
23
20
  interface DesktopLayoutProps {
@@ -26,29 +23,17 @@ interface DesktopLayoutProps {
26
23
  appSettings?: Record<string, any>;
27
24
  }
28
25
 
29
- /**
30
- * Push a remembered width onto a mounted panel. Panels report nothing while
31
- * unmounted, so a missing width (never resized) or a missing handle (panel
32
- * closed) simply leaves the panel at whatever its `defaultSize` derived.
33
- */
34
- function applyWidth(
35
- ref: RefObject<PanelImperativeHandle | null>,
36
- px: number | undefined,
37
- ) {
38
- if (px != null) ref.current?.resize(px);
39
- }
40
-
41
26
  export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }: DesktopLayoutProps) {
42
27
  const { folders } = useSidebarData();
43
28
  const [showRightPanel, setShowRightPanel] = useSessionState<boolean>('layout.showRightPanel', appSettings.showRightPanel ?? true);
44
29
  const [activeRightPanel, setActiveRightPanel] = useSessionState<RightPanelType>('layout.activeRightPanel', (appSettings.activeRightPanel as RightPanelType) ?? 'files');
45
- const [showLeftPanel, setShowLeftPanel] = useState(appSettings.showLeftPanel ?? true);
46
- const { widths: panelWidths, widthsRef, commitWidths } = usePanelWidths(appSettings, activeRightPanel);
30
+ const [showLeftPanel, setShowLeftPanel] = useSessionState<boolean>('layout.showLeftPanel', appSettings.showLeftPanel ?? true);
31
+ const { widths: panelWidths, commitWidths } = usePanelWidths(appSettings, activeRightPanel);
47
32
 
48
33
  const editorPanelRef = useRef<PanelImperativeHandle | null>(null);
49
34
  const rightPanelRef = useRef<PanelImperativeHandle | null>(null);
50
35
  const leftPanelRef = useRef<PanelImperativeHandle | null>(null);
51
- const [userToggledEditor, setUserToggledEditor] = useState<boolean | null>(appSettings.userToggledEditor ?? null);
36
+ const [userToggledEditor, setUserToggledEditor] = useSessionState<boolean | null>('layout.userToggledEditor', appSettings.userToggledEditor ?? null);
52
37
 
53
38
  const [searchParams] = useSearchParams();
54
39
  const folderIdParam = searchParams.get('folderId');
@@ -63,8 +48,7 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
63
48
 
64
49
  const handleOpenTab = useCallback(() => {
65
50
  setUserToggledEditor(true);
66
- saveSetting('userToggledEditor', true);
67
- }, []);
51
+ }, [setUserToggledEditor]);
68
52
 
69
53
  const {
70
54
  openedFiles,
@@ -83,42 +67,13 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
83
67
  [openedFiles, activeFileId],
84
68
  );
85
69
 
86
- const saveSetting = (key: string, value: any) => {
87
- writeSetting(key, value);
88
- };
89
-
90
- // The sidebar shares a group with the workspace stack, so the stack absorbs
91
- // its width and only the toggle can lose it: reapplying it after the panel
92
- // remounts is what keeps the sidebar where the user left it.
93
- useEffect(() => {
94
- const frame = requestAnimationFrame(() => {
95
- const widths = widthsRef.current;
96
- if (widths) applyWidth(leftPanelRef, widths.left);
97
- });
98
- return () => cancelAnimationFrame(frame);
99
- }, [showLeftPanel, widthsRef]);
100
-
101
- // The right panel survives activity-bar view switches (only CSS hides the
102
- // outgoing view), so a mounted panel never re-reads its defaultSize. A view
103
- // that has been resized gets that width pushed here; one that never has gets
104
- // the width it is supposed to open at, which the element would otherwise
105
- // have taken from the view it was mounted under.
106
- useEffect(() => {
107
- const frame = requestAnimationFrame(() => {
108
- applyWidth(rightPanelRef, widthsRef.current?.right?.[activeRightPanel] ?? DEFAULT_RIGHT_PANEL_WIDTHS[activeRightPanel]);
109
- });
110
- return () => cancelAnimationFrame(frame);
111
- }, [showRightPanel, activeRightPanel, widthsRef]);
112
-
113
- // Same story for the editor across a source↔diff tab switch: the panel
114
- // stays mounted, so the width its tab kind remembers — or opens at, for a
115
- // diff, which is wider than a source view — is re-applied here.
116
- useEffect(() => {
117
- const frame = requestAnimationFrame(() => {
118
- applyWidth(editorPanelRef, widthsRef.current?.[editorWidthMode] ?? DEFAULT_PANEL_WIDTHS[editorWidthMode]);
119
- });
120
- return () => cancelAnimationFrame(frame);
121
- }, [showEditor, editorWidthMode, widthsRef]);
70
+ // Width restoration is declarative: every panel's `defaultSize` is derived
71
+ // from the remembered width for its current slot, and `Panel` adopts a
72
+ // changed `defaultSize` itself. That covers the two cases that used to need
73
+ // an imperative push — a right-panel view switch and an editor source↔diff
74
+ // switch, both of which keep the panel mounted while only its width slot
75
+ // changes — and it cannot go stale, because there is no cached layout to
76
+ // disagree with the props.
122
77
 
123
78
  const [settingsOpen, setSettingsOpen] = useState(false);
124
79
  const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('appearance');
@@ -153,41 +108,30 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
153
108
  const handleChangeRightPanel = (panel: RightPanelType) => {
154
109
  let nextShow = showRightPanel;
155
110
  let nextActive = activeRightPanel;
156
-
111
+
157
112
  if (activeRightPanel === panel && showRightPanel) {
158
113
  nextShow = false;
159
114
  } else {
160
115
  nextActive = panel;
161
116
  nextShow = true;
162
117
  }
163
-
118
+
164
119
  setShowRightPanel(nextShow);
165
120
  setActiveRightPanel(nextActive);
166
-
167
- // The width is not set here: the restore effect re-applies whichever width
168
- // this view remembers, and a view that opens for the first time derives it
169
- // from its own defaultSize. Reopening the same view keeps its width.
170
- writeSetting('showRightPanel', nextShow);
171
- writeSetting('activeRightPanel', nextActive);
172
121
  };
173
122
 
174
123
  const handleToggleLeftPanel = (show: boolean) => {
175
124
  setShowLeftPanel(show);
176
- saveSetting('showLeftPanel', show);
177
125
  };
178
126
 
179
127
  const handleToggleRightPanel = () => {
180
- const nextShow = !showRightPanel;
181
- setShowRightPanel(nextShow);
182
- saveSetting('showRightPanel', nextShow);
128
+ setShowRightPanel(!showRightPanel);
183
129
  };
184
130
 
185
131
  const handleToggleEditor = () => {
186
- const nextShow = !showEditor;
187
- setUserToggledEditor(nextShow);
188
- saveSetting('userToggledEditor', nextShow);
189
- // Reopening the editor re-applies the width its tab kind remembers (see the
190
- // restore effect), so a dragged width survives the toggle.
132
+ setUserToggledEditor(!showEditor);
133
+ // The editor panel keeps its own remembered width per tab kind, so a
134
+ // dragged width survives the toggle without being re-applied here.
191
135
  };
192
136
 
193
137
  return (
@@ -195,18 +139,6 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
195
139
 
196
140
  {/* Main Workspace: full-height left sidebar (resizable) + right stack */}
197
141
  <div className="flex flex-1 overflow-hidden">
198
- {!showLeftPanel && (
199
- <div
200
- className="w-12 h-full bg-paper border-r border-ink/10 flex flex-col items-center py-3 flex-shrink-0 cursor-pointer hover:bg-ink/5 transition-colors titlebar-no-drag"
201
- style={{ paddingTop: 'max(0.75rem, env(titlebar-area-height, 0px))' }}
202
- onClick={() => handleToggleLeftPanel(true)}
203
- title="Expand Sidebar"
204
- >
205
- <PanelLeft size={16} className="text-ink/60" />
206
- <div className="w-[1px] flex-1 bg-ink/10 my-4" />
207
- </div>
208
- )}
209
-
210
142
  <Group
211
143
  orientation="horizontal"
212
144
  id="ompchamber-main"
@@ -215,14 +147,10 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
215
147
  if (left != null && left > 0) commitWidths({ left: Math.round(left) });
216
148
  }}
217
149
  >
218
- {showLeftPanel && (
219
- <>
220
- <Panel panelRef={leftPanelRef} id="left-panel" defaultSize={panelWidths.left ?? DEFAULT_PANEL_WIDTHS.left} minSize={MIN_LEFT_PANEL_WIDTH} maxSize={MAX_LEFT_PANEL_WIDTH} collapsible>
221
- <SessionSidebar className="w-full h-full" onClose={() => handleToggleLeftPanel(false)} appSettings={appSettings} />
222
- </Panel>
223
- <ResizeHandle />
224
- </>
225
- )}
150
+ <Panel panelRef={leftPanelRef} id="left-panel" defaultSize={panelWidths.left ?? DEFAULT_LEFT_PANEL_WIDTH} minSize={MIN_LEFT_PANEL_WIDTH} maxSize={MAX_LEFT_PANEL_WIDTH} collapsed={!showLeftPanel}>
151
+ <SessionSidebar className="w-full h-full" onClose={() => handleToggleLeftPanel(false)} appSettings={appSettings} />
152
+ </Panel>
153
+ {showLeftPanel && <ResizeHandle />}
226
154
 
227
155
  {/* Right stack: top navbar + resizable workspace (the group's filler) */}
228
156
  <Panel id="main-right-stack" filler minSize={0}>
@@ -236,6 +164,7 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
236
164
  onSwitchToMobile={onSwitchToMobile}
237
165
  onToggleEditor={handleToggleEditor}
238
166
  onToggleRightPanel={handleToggleRightPanel}
167
+ onToggleLeftPanel={() => handleToggleLeftPanel(!showLeftPanel)}
239
168
  />
240
169
 
241
170
  <WorkspacePanels