ompchamber 3.0.0 → 3.1.1

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 (114) hide show
  1. package/README.md +30 -8
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +333 -333
  4. package/dist/client/static/js/index-5yfre1kw.css +1 -0
  5. package/dist/client/static/js/{index-prbmx3yj.js → index-qhzsybkn.js} +643 -617
  6. package/package.json +1 -1
  7. package/src/cli/lib/args.js +4 -3
  8. package/src/cli/lib/commands/restart.js +7 -6
  9. package/src/cli/lib/commands/stop.js +38 -31
  10. package/src/cli/lib/commands/update.js +3 -2
  11. package/src/cli/lib/stop-scope.js +23 -0
  12. package/src/cli/lib/stop-scope.test.js +41 -0
  13. package/src/client/components/common/code-editor/handler.test.ts +274 -0
  14. package/src/client/components/common/code-editor/handler.ts +175 -0
  15. package/src/client/components/common/code-editor/index.tsx +185 -220
  16. package/src/client/components/common/code-surface/index.tsx +109 -12
  17. package/src/client/components/common/lazy-panels.tsx +5 -1
  18. package/src/client/components/common/viewport.ts +5 -3
  19. package/src/client/components/layout/RightActivityBar.tsx +2 -1
  20. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +21 -4
  21. package/src/client/components/layout/desktop-layout/index.tsx +24 -4
  22. package/src/client/components/mobile/RightSidebar.tsx +11 -4
  23. package/src/client/components/workspace/browser-panel/AddressBar.tsx +1 -10
  24. package/src/client/components/workspace/browser-panel/Screencast.tsx +4 -5
  25. package/src/client/components/workspace/browser-panel/index.tsx +6 -6
  26. package/src/client/components/workspace/browser-toolbar/index.tsx +22 -12
  27. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +1 -1
  28. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Todo.tsx +1 -1
  29. package/src/client/components/workspace/editor/CommandPalette.test.ts +242 -0
  30. package/src/client/components/workspace/editor/CommandPalette.tsx +138 -0
  31. package/src/client/components/workspace/editor/FindWidget.test.ts +261 -0
  32. package/src/client/components/workspace/editor/FindWidget.tsx +219 -0
  33. package/src/client/components/workspace/editor/Toolbar.tsx +19 -1
  34. package/src/client/components/workspace/editor/index.tsx +153 -53
  35. package/src/client/components/workspace/file-explorer/Modals.tsx +79 -2
  36. package/src/client/components/workspace/file-explorer/TreeItem.tsx +43 -113
  37. package/src/client/components/workspace/todo-panel/PhaseGroup.tsx +76 -0
  38. package/src/client/components/workspace/todo-panel/Row.tsx +69 -0
  39. package/src/client/components/workspace/todo-panel/index.tsx +161 -0
  40. package/src/client/hooks/editor/find-harness.test-util.ts +170 -0
  41. package/src/client/hooks/editor/scroll-container.test.ts +102 -0
  42. package/src/client/hooks/editor/scroll-container.ts +26 -0
  43. package/src/client/hooks/editor/use-code-window.ts +2 -20
  44. package/src/client/hooks/editor/use-editor-find-write.test.ts +170 -0
  45. package/src/client/hooks/editor/use-editor-find.test.ts +118 -0
  46. package/src/client/hooks/editor/use-editor-find.ts +321 -0
  47. package/src/client/hooks/editor/use-editor-history.test.ts +216 -0
  48. package/src/client/hooks/editor/use-editor-history.ts +134 -0
  49. package/src/client/hooks/editor/use-occurrences.test.ts +236 -0
  50. package/src/client/hooks/editor/use-occurrences.ts +98 -0
  51. package/src/client/hooks/workspace/file-tree-actions.test.ts +348 -0
  52. package/src/client/hooks/workspace/file-tree-actions.ts +250 -0
  53. package/src/client/hooks/workspace/session-todos.ts +102 -0
  54. package/src/client/tailwind.css +30 -0
  55. package/src/server/lib/bundler/css.test.ts +87 -0
  56. package/src/server/lib/bundler/css.ts +17 -2
  57. package/src/server/lib/bundler/nested-at-rules.test.ts +100 -0
  58. package/src/server/lib/bundler/nested-at-rules.ts +146 -0
  59. package/src/server/lib/lifecycle/launch-mode.test.ts +20 -1
  60. package/src/server/lib/lifecycle/launch-mode.ts +26 -0
  61. package/src/server/lib/lifecycle/restart.ts +9 -14
  62. package/src/server/lib/omp/session/todos.ts +100 -0
  63. package/src/server/routes/fs/action-create.test.ts +90 -0
  64. package/src/server/routes/fs/action.ts +23 -0
  65. package/src/server/routes/omp/index.ts +2 -0
  66. package/src/server/routes/omp/session-todos.ts +80 -0
  67. package/src/shared/lib/chat/{todo-parser.ts → todo/parser.ts} +17 -0
  68. package/src/shared/lib/chat/todo/progress.ts +97 -0
  69. package/src/shared/lib/chat/todo/snapshot.test.ts +156 -0
  70. package/src/shared/lib/chat/todo/snapshot.ts +215 -0
  71. package/src/shared/lib/code/editor/commands.test.ts +155 -0
  72. package/src/shared/lib/code/editor/commands.ts +260 -0
  73. package/src/shared/lib/code/editor/edits.test.ts +100 -0
  74. package/src/shared/lib/code/editor/edits.ts +122 -0
  75. package/src/shared/lib/code/editor/find-status.test.ts +50 -0
  76. package/src/shared/lib/code/editor/find-status.ts +36 -0
  77. package/src/shared/lib/code/editor/find.test.ts +203 -0
  78. package/src/shared/lib/code/editor/find.ts +224 -0
  79. package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.test.ts +1 -1
  80. package/src/shared/lib/code/editor/keymap.test.ts +109 -0
  81. package/src/shared/lib/code/editor/keymap.ts +151 -0
  82. package/src/shared/lib/code/editor/layers.test.ts +77 -0
  83. package/src/shared/lib/code/editor/layers.ts +67 -0
  84. package/src/shared/lib/code/editor/line-edits.test.ts +131 -0
  85. package/src/shared/lib/code/editor/line-edits.ts +159 -0
  86. package/src/shared/lib/code/editor/lines.test.ts +81 -0
  87. package/src/shared/lib/code/editor/lines.ts +83 -0
  88. package/src/shared/lib/code/editor/marks.test.ts +86 -0
  89. package/src/shared/lib/code/editor/marks.ts +106 -0
  90. package/src/shared/lib/code/editor/occurrences.test.ts +117 -0
  91. package/src/shared/lib/code/editor/occurrences.ts +111 -0
  92. package/src/shared/lib/code/editor/palette.test.ts +91 -0
  93. package/src/shared/lib/code/editor/palette.ts +92 -0
  94. package/src/shared/lib/code/editor/reset-css.test.ts +35 -0
  95. package/src/shared/lib/code/editor/reset-css.ts +32 -0
  96. package/src/shared/lib/code/editor/text-commands.test.ts +178 -0
  97. package/src/shared/lib/code/editor/text-commands.ts +154 -0
  98. package/src/shared/lib/code/syntax-highlight.test.ts +75 -0
  99. package/src/shared/lib/code/syntax-highlight.ts +29 -17
  100. package/src/shared/lib/code/windowed-highlight.ts +52 -1
  101. package/src/shared/lib/ui/key-binding.test.ts +115 -0
  102. package/src/shared/lib/ui/key-binding.ts +143 -0
  103. package/src/shared/lib/util/platform.test.ts +87 -0
  104. package/src/shared/lib/util/platform.ts +30 -0
  105. package/src/shared/lib/workspace/keymap.test.ts +82 -0
  106. package/src/shared/lib/workspace/keymap.ts +30 -0
  107. package/src/shared/lib/workspace/refresh-cadence.ts +9 -0
  108. package/src/shared/lib/workspace/right-panels.ts +8 -0
  109. package/src/shared/lib/workspace/session-state/store.ts +0 -1
  110. package/src/shared/types/index.ts +1 -0
  111. package/src/shared/types/todo.ts +108 -0
  112. package/dist/client/static/js/index-ranztes9.css +0 -1
  113. package/src/server/lib/lifecycle/restart.test.ts +0 -21
  114. /package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.ts +0 -0
@@ -1,8 +1,10 @@
1
- import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks';
1
+ import { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks';
2
+ import { forwardRef } from 'preact/compat';
2
3
  import type { CSSProperties } from 'preact';
3
4
 
4
- import { CodeEditor } from '@/client/components/common/code-editor';
5
+ import { CodeEditor, type CodeEditorHandle } from '@/client/components/common/code-editor';
5
6
  import { measureRowHeight, measureWrappedLines, type WrappedLines } from '@/client/components/common/code-surface/measure';
7
+ import { findScrollContainer } from '@/client/hooks/editor/scroll-container';
6
8
  import { useCodeWindow } from '@/client/hooks/editor/use-code-window';
7
9
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
8
10
  import {
@@ -12,17 +14,32 @@ import {
12
14
  type LineGeometry,
13
15
  type LineWindow,
14
16
  } from '@/shared/lib/code/lazy-window';
17
+ import type { FindMatch } from '@/shared/lib/code/editor/find';
18
+ import type { EditorCommand } from '@/shared/lib/code/editor/keymap';
19
+ import { lineIndexAt } from '@/shared/lib/code/editor/lines';
20
+ import type { TextRange } from '@/shared/lib/code/editor/commands';
21
+ import { countLines } from '@/shared/lib/code/editor/lines';
15
22
  import { highlightCode } from '@/shared/lib/code/syntax-highlight';
16
23
  import { highlightCodeWindow } from '@/shared/lib/code/windowed-highlight';
17
24
 
18
25
  const WRAP_ON = '!whitespace-pre-wrap !break-words';
19
26
  const WRAP_OFF = '!whitespace-pre !break-normal';
20
27
 
21
- /** Line count without materializing every line — a 20 000-line value per render is a megabyte of strings. */
22
- function countLines(text: string): number {
23
- let count = 1;
24
- for (let index = text.indexOf('\n'); index !== -1; index = text.indexOf('\n', index + 1)) count++;
25
- return count;
28
+ export interface CodeSurfaceHandle {
29
+ /** Current selection offsets in the buffer, or null before the field mounts. */
30
+ getSelection(): { start: number; end: number } | null;
31
+ /** Select `[start, end)`; `focus` moves keyboard focus into the editor. */
32
+ select(start: number, end: number, focus?: boolean): void;
33
+ /** Replace the buffer as one undoable edit, caret at `[caretStart, caretEnd)`, focus unchanged. */
34
+ applyDocument(value: string, caretStart: number, caretEnd: number): void;
35
+ /** Run an editor command by name (the palette's path into the editor). */
36
+ runCommand(command: EditorCommand): void;
37
+ /**
38
+ * Bring the line holding `offset` into view WITHOUT moving the caret — what
39
+ * "step to the next match" needs: the user's focus stays in the find field
40
+ * while the match scrolls under it.
41
+ */
42
+ revealOffset(offset: number): void;
26
43
  }
27
44
 
28
45
  interface CodeSurfaceProps {
@@ -31,6 +48,19 @@ interface CodeSurfaceProps {
31
48
  /** Shiki language id, already resolved from the file name. */
32
49
  language: string;
33
50
  wordWrap: boolean;
51
+ /** Find matches (document offsets) to paint; the current one is marked. */
52
+ marks?: readonly FindMatch[];
53
+ currentMark?: number;
54
+ /**
55
+ * Extra ranges the ⌘D workflow has selected. The textarea can only hold one
56
+ * selection, so the surface paints the others from here — and it must, or
57
+ * ⌘D would look like it did nothing.
58
+ */
59
+ occurrences?: readonly TextRange[];
60
+ /** Commands the editor does not own (find bar chords, word wrap, save). */
61
+ onCommand?: (command: EditorCommand) => void;
62
+ /** Called with the extra ranges whenever ⌘D / ⌘⇧L change them. */
63
+ onOccurrencesChange?: (ranges: readonly TextRange[]) => void;
34
64
  /** Outer flex row that holds the gutter and the editor column. */
35
65
  rootClassName?: string;
36
66
  gutterClassName?: string;
@@ -71,11 +101,16 @@ function sameWrappedLines(previous: WrappedLines | null, next: WrappedLines | nu
71
101
  * 20 000-line file from spending minutes in the tokenizer, and a keystroke in it
72
102
  * from re-tokenizing the file at all.
73
103
  */
74
- export function CodeSurface({
104
+ export const CodeSurface = forwardRef<CodeSurfaceHandle, CodeSurfaceProps>(function CodeSurface({
75
105
  value,
76
106
  onValueChange,
77
107
  language,
78
108
  wordWrap,
109
+ marks,
110
+ currentMark,
111
+ occurrences,
112
+ onCommand,
113
+ onOccurrencesChange,
79
114
  rootClassName,
80
115
  gutterClassName,
81
116
  gutterStyle,
@@ -84,10 +119,11 @@ export function CodeSurface({
84
119
  editorClassName,
85
120
  editorPadding = 0,
86
121
  editorStyle,
87
- }: CodeSurfaceProps) {
122
+ }, ref) {
88
123
  const wrapClass = wordWrap ? WRAP_ON : WRAP_OFF;
89
124
  const preRef = useRef<HTMLPreElement | null>(null);
90
125
  const rootRef = useRef<HTMLDivElement | null>(null);
126
+ const editorRef = useRef<CodeEditorHandle | null>(null);
91
127
  const [wrapped, setWrapped] = useState<WrappedLines | null>(null);
92
128
  const [rowHeight, setRowHeight] = useState<number | null>(null);
93
129
  /** Lines the last measurement pass described; -1 before the first one runs. */
@@ -150,10 +186,66 @@ export function CodeSurface({
150
186
  enabled: lazy && (measuredLines !== lineCount || geometry !== null),
151
187
  });
152
188
 
189
+ /**
190
+ * What the highlight layer paints: the find matches, plus every extra ⌘D
191
+ * range. The find widget's current match is the one the caret is aimed at, so
192
+ * it keeps `currentMark` — an occurrence range never claims that role.
193
+ */
194
+ const painted = useMemo<readonly FindMatch[]>(() => {
195
+ if (!occurrences || occurrences.length === 0) return marks ?? [];
196
+ return [...(marks ?? []), ...occurrences];
197
+ }, [marks, occurrences]);
198
+ /** Where the extra selections start inside `painted`; -1 when there are none. */
199
+ const occurrenceFrom = occurrences && occurrences.length > 0 ? (marks?.length ?? 0) : -1;
200
+
153
201
  const highlight = useCallback(
154
202
  (code: string, range: LineWindow | null) =>
155
- range ? highlightCodeWindow(code, language, range) : highlightCode(code, language),
156
- [language],
203
+ range
204
+ ? highlightCodeWindow(code, language, range, { marks: painted, currentMark, occurrenceFrom })
205
+ : highlightCode(code, language, { marks: painted, currentMark, occurrenceFrom }),
206
+ [language, painted, currentMark, occurrenceFrom],
207
+ );
208
+
209
+ /**
210
+ * Bring `offset`'s row into view without touching the caret.
211
+ *
212
+ * `scrollIntoView` cannot be used: the caret's row and the match's row are
213
+ * different elements (the highlighted `<pre>` is `aria-hidden` and
214
+ * `pointer-events: none`), and the browser's own scroll-into-view on
215
+ * `setSelectionRange` would fight this. Instead the row's pixel offset comes
216
+ * from the same geometry the window is resolved against, so the two agree by
217
+ * construction, and the scroll is a plain assignment on the container the
218
+ * window reads.
219
+ */
220
+ const revealOffset = useCallback(
221
+ (offset: number) => {
222
+ const root = rootRef.current;
223
+ if (!root || !geometry) return;
224
+ const rowTop = geometry.offsetOf(lineIndexAt(value, offset));
225
+ const container = findScrollContainer(root);
226
+ if (!container) return;
227
+ const frameTop = root.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop;
228
+ const band = container.clientHeight;
229
+ const target = frameTop + rowTop;
230
+ // Only scroll when the row is actually outside the band; a reveal that
231
+ // always re-centers would make every "next match" jump the whole view.
232
+ if (target < container.scrollTop + 8 || target + geometry.rowHeight > container.scrollTop + band - 8) {
233
+ container.scrollTop = Math.max(0, target - band / 3);
234
+ }
235
+ },
236
+ [geometry, value],
237
+ );
238
+
239
+ useImperativeHandle(
240
+ ref,
241
+ () => ({
242
+ getSelection: () => editorRef.current?.getSelection() ?? null,
243
+ select: (start, end, focus = true) => editorRef.current?.select(start, end, focus),
244
+ applyDocument: (next, caretStart, caretEnd) => editorRef.current?.applyDocument(next, caretStart, caretEnd),
245
+ runCommand: (command) => editorRef.current?.run(command),
246
+ revealOffset,
247
+ }),
248
+ [revealOffset],
157
249
  );
158
250
 
159
251
  const firstLine = virtual ? virtual.start : 0;
@@ -184,13 +276,18 @@ export function CodeSurface({
184
276
  highlight={highlight}
185
277
  padding={editorPadding}
186
278
  preRef={preRef}
279
+ handleRef={editorRef}
187
280
  textareaClassName={`focus:outline-none ${wrapClass}`}
188
281
  preClassName={wrapClass}
189
282
  style={editorStyle}
190
283
  className={editorClassName}
191
284
  virtual={virtual}
285
+ language={language}
286
+ occurrences={occurrences}
287
+ onCommand={onCommand}
288
+ onOccurrencesChange={onOccurrencesChange}
192
289
  />
193
290
  </div>
194
291
  </div>
195
292
  );
196
- }
293
+ });
@@ -32,6 +32,9 @@ export const LazyUserBrowserPanel = lazy(() =>
32
32
  export const LazyUsagePanel = lazy(() =>
33
33
  import('@/client/components/workspace/usage-panel/index').then((m) => ({ default: m.UsagePanel }))
34
34
  );
35
+ export const LazyTodoPanel = lazy(() =>
36
+ import('@/client/components/workspace/todo-panel/index').then((m) => ({ default: m.TodoPanel }))
37
+ );
35
38
 
36
39
  /**
37
40
  * One cached lazy per right-panel view for the desktop layout.
@@ -67,7 +70,8 @@ export function getDesktopPanelView(scope: object, view: RightPanelType): Compon
67
70
  : view === 'context' ? LazyContextPanel
68
71
  : view === 'user-browser' ? LazyUserBrowserPanel
69
72
  : view === 'browser' ? LazyBrowserPanel
70
- : LazyUsagePanel;
73
+ : view === 'usage' ? LazyUsagePanel
74
+ : LazyTodoPanel;
71
75
  const Cached: ComponentType<Record<string, unknown>> = ({ className, ...rest }) => (
72
76
  <div className={`w-full h-full ${typeof className === 'string' ? className : ''}`}>
73
77
  <Source {...rest} className="w-full h-full" />
@@ -4,9 +4,11 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Device-viewport presets shared by both browser panels. The agent viewer and
8
- * the user iframe must frame a page with identical geometry, so the preset
9
- * classes live here instead of in either panel.
7
+ * Device-viewport presets for the USER browser panel's iframe. There the box
8
+ * *is* the layout viewport, so a preset re-runs the page's media queries for
9
+ * real. The AGENT panel does not use these: its surface is a fixed-resolution
10
+ * screencast JPEG, which a preset could only letterbox while claiming to
11
+ * emulate a device (emulation belongs to the agent: `tab.emulate()`).
10
12
  */
11
13
 
12
14
  import type { ViewportMode } from '@/shared/types';
@@ -1,4 +1,4 @@
1
- import { BarChart3, Bot, Files, GitBranch, Globe, Layers, Search, Terminal } from 'lucide-preact';
1
+ import { BarChart3, Bot, Files, GitBranch, Globe, Layers, ListTodo, Search, Terminal } from 'lucide-preact';
2
2
  import type { ReactNode } from 'preact/compat';
3
3
  import { useGitStatus } from '@/client/hooks/workspace/git-status';
4
4
  import { GIT_STATUS_POLL_MS } from '@/shared/lib/workspace/refresh-cadence';
@@ -27,6 +27,7 @@ const PANEL_META: Record<RightPanelType, { title: string; icon: ReactNode }> = {
27
27
  'user-browser': { title: 'Browser (Anda)', icon: <Globe size={16} /> },
28
28
  browser: { title: 'Browser Agent', icon: <Bot size={16} /> },
29
29
  usage: { title: 'Usage', icon: <BarChart3 size={16} /> },
30
+ todo: { title: 'Todos', icon: <ListTodo size={16} /> },
30
31
  };
31
32
 
32
33
  export function RightActivityBar({ activePanel, onChangePanel, isPanelOpen, hasActiveContext, activeProjectPath, refreshKey }: RightActivityBarProps) {
@@ -2,6 +2,19 @@ import { LayoutTemplate, MoreHorizontal, PanelLeft, PanelRight, PanelRightClose,
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';
5
+ import { WORKSPACE_KEY_BINDINGS, type WorkspaceCommand } from '@/shared/lib/workspace/keymap';
6
+ import { describeBinding } from '@/shared/lib/ui/key-binding';
7
+
8
+ /**
9
+ * A tooltip's text with the chord that actually invokes it. Derived from the
10
+ * workspace keymap rather than typed out, because a hand-written "⌘B" beside a
11
+ * binding that listens for something else is exactly the drift the keymap
12
+ * table exists to prevent.
13
+ */
14
+ function withChord(command: WorkspaceCommand, label: string): string {
15
+ const binding = WORKSPACE_KEY_BINDINGS.find((entry) => entry.command === command);
16
+ return binding ? `${label} (${describeBinding(binding)})` : label;
17
+ }
5
18
 
6
19
  interface TopNavbarProps {
7
20
  sessionTitle: string | null;
@@ -42,8 +55,8 @@ export function TopNavbar({
42
55
  type="button"
43
56
  onClick={onToggleLeftPanel}
44
57
  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"
58
+ title={withChord('toggleSidebar', 'Expand Sidebar')}
59
+ aria-label={withChord('toggleSidebar', 'Expand Sidebar')}
47
60
  >
48
61
  <PanelLeft size={16} />
49
62
  </button>
@@ -86,7 +99,9 @@ export function TopNavbar({
86
99
  type="button"
87
100
  onClick={onToggleEditor}
88
101
  className={`p-1.5 rounded hover:bg-ink/10 transition-colors cursor-pointer ${showEditor ? 'text-ink' : 'text-ink/40'}`}
89
- title="Toggle Editor Layout"
102
+ title={withChord('toggleEditorPanel', 'Toggle Editor Layout')}
103
+ aria-label={withChord('toggleEditorPanel', 'Toggle Editor Layout')}
104
+ aria-pressed={showEditor}
90
105
  >
91
106
  <LayoutTemplate size={16} />
92
107
  </button>
@@ -94,7 +109,9 @@ export function TopNavbar({
94
109
  type="button"
95
110
  onClick={onToggleRightPanel}
96
111
  className={`p-1.5 rounded hover:bg-ink/10 transition-colors cursor-pointer ${showRightPanel ? 'text-ink' : 'text-ink/40'}`}
97
- title="Toggle Right Panel"
112
+ title={withChord('toggleRightPanel', 'Toggle Right Panel')}
113
+ aria-label={withChord('toggleRightPanel', 'Toggle Right Panel')}
114
+ aria-pressed={showRightPanel}
98
115
  >
99
116
  {showRightPanel ? <PanelRightClose size={16} /> : <PanelRight size={16} />}
100
117
  </button>
@@ -16,6 +16,8 @@ import { useSidebarData } from '@/client/hooks/chat/omp/session-list';
16
16
  import { DEFAULT_LEFT_PANEL_WIDTH, MAX_LEFT_PANEL_WIDTH, MIN_LEFT_PANEL_WIDTH, type EditorWidthMode } from '@/shared/lib/workspace/panel-widths';
17
17
  import { ResizeHandle } from '@/client/components/layout/desktop-layout/ResizeHandle';
18
18
  import { useChamberEvent, useWindowEvent } from '@/client/hooks/ui/window-event';
19
+ import { WORKSPACE_KEY_BINDINGS } from '@/shared/lib/workspace/keymap';
20
+ import { resolveBinding } from '@/shared/lib/ui/key-binding';
19
21
 
20
22
  interface DesktopLayoutProps {
21
23
  sessionId: string | null;
@@ -84,13 +86,31 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
84
86
  const [sessionTitle, setSessionTitle] = useState<string | null>(null);
85
87
  const streamStatus = useAgentStreamStatus();
86
88
 
87
- // Global keyboard shortcut for settings (Cmd/Ctrl + ,)
89
+ /**
90
+ * The workspace's own shortcuts, resolved from the shared keymap so the
91
+ * tooltips on the panel toggles name the chord that is actually bound.
92
+ *
93
+ * `⌘S` is deliberately absent here: the editor panel already owns it (it is
94
+ * the only surface with a buffer to save), and binding it twice would save
95
+ * the file twice.
96
+ */
88
97
  useWindowEvent('keydown', (e) => {
89
- if ((e.metaKey || e.ctrlKey) && e.key === ',') {
90
- e.preventDefault();
98
+ const command = resolveBinding(WORKSPACE_KEY_BINDINGS, e);
99
+ if (!command) return;
100
+ if (command === 'saveFile') return;
101
+ if (command === 'toggleEditorPanel' && !showEditor) {
102
+ // The editor panel is only meaningful with a file open, and `showEditor`
103
+ // already folds in the user's own toggle.
104
+ if (openedFiles.length === 0) return;
105
+ }
106
+ e.preventDefault();
107
+ if (command === 'toggleSidebar') setShowLeftPanel((previous) => !previous);
108
+ else if (command === 'toggleEditorPanel') setUserToggledEditor((previous) => !(previous ?? showEditor));
109
+ else if (command === 'toggleRightPanel') setShowRightPanel((previous) => !previous);
110
+ else if (command === 'openSettings') {
91
111
  setSettingsCategory('appearance');
92
112
  setAutoOpenAddProvider(false);
93
- setSettingsOpen(prev => !prev);
113
+ setSettingsOpen((previous) => !previous);
94
114
  }
95
115
  });
96
116
 
@@ -1,8 +1,8 @@
1
1
  import { useState } from 'preact/hooks';
2
2
  import { Suspense } from 'preact/compat';
3
3
  import type { ReactNode } from 'preact/compat';
4
- import { BarChart3, Bot, Files, GitBranch, Globe, Layers, Search, Terminal, X } from 'lucide-preact';
5
- import { LazyBrowserPanel, LazyContextPanel, LazyFileExplorer, LazyGitPanel, LazySearchPanel, LazyTerminalPanel, LazyUsagePanel, LazyUserBrowserPanel } from '@/client/components/common/lazy-panels';
4
+ import { BarChart3, Bot, Files, GitBranch, Globe, Layers, ListTodo, Search, Terminal, X } from 'lucide-preact';
5
+ import { LazyBrowserPanel, LazyContextPanel, LazyFileExplorer, LazyGitPanel, LazySearchPanel, LazyTerminalPanel, LazyTodoPanel, LazyUsagePanel, LazyUserBrowserPanel } from '@/client/components/common/lazy-panels';
6
6
  import { useGitStatus } from '@/client/hooks/workspace/git-status';
7
7
  import { GIT_STATUS_POLL_MS } from '@/shared/lib/workspace/refresh-cadence';
8
8
  import { RIGHT_PANEL_TYPES, type RightPanelType } from '@/shared/lib/workspace/right-panels';
@@ -31,6 +31,7 @@ const PANEL_META: Record<RightPanelType, { title: string; label: string; icon: R
31
31
  'user-browser': { title: 'Browser (Anda)', label: 'Browser', icon: <Globe size={14} className="flex-shrink-0" /> },
32
32
  browser: { title: 'Browser Agent', label: 'Agent', icon: <Bot size={14} className="flex-shrink-0" /> },
33
33
  usage: { title: 'Usage', label: 'Usage', icon: <BarChart3 size={14} className="flex-shrink-0" /> },
34
+ todo: { title: 'Todos', label: 'Todos', icon: <ListTodo size={14} className="flex-shrink-0" /> },
34
35
  };
35
36
 
36
37
  export function MobileRightSidebar({
@@ -100,12 +101,17 @@ export function MobileRightSidebar({
100
101
  </button>
101
102
  </div>
102
103
 
103
- {/* Main Tab Content — shared components so mobile == desktop features */}
104
+ {/* Main Tab Content — shared components so mobile == desktop features.
105
+ The workspace gate below applies per TAB, not to the whole drawer: a
106
+ todo list belongs to the session, not to the folder its cwd resolves
107
+ to, so the Todos tab stays reachable for a session running outside
108
+ every registered workspace. Every other view here reads the working
109
+ tree and genuinely needs one. */}
104
110
  <div
105
111
  className="flex-1 min-h-0 overflow-hidden relative"
106
112
  style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
107
113
  >
108
- {!enabled ? (
114
+ {!enabled && activeTab !== 'todo' ? (
109
115
  <div className="h-full flex items-center justify-center text-ink/40">
110
116
  <span className="text-xs font-mono">No session selected</span>
111
117
  </div>
@@ -130,6 +136,7 @@ export function MobileRightSidebar({
130
136
  {activeTab === 'user-browser' && <LazyUserBrowserPanel className="h-full w-full" />}
131
137
  {activeTab === 'browser' && <LazyBrowserPanel className="h-full w-full" active />}
132
138
  {activeTab === 'usage' && <LazyUsagePanel className="h-full w-full" />}
139
+ {activeTab === 'todo' && <LazyTodoPanel className="h-full w-full" />}
133
140
  </Suspense>
134
141
  </>
135
142
  )}
@@ -1,20 +1,17 @@
1
1
  import { Globe, RotateCw } from 'lucide-preact';
2
2
  import { BrowserToolbar, ICON_BUTTON } from '@/client/components/workspace/browser-toolbar/index';
3
- import type { BrowserViewStatus, ViewportMode } from '@/shared/types';
3
+ import type { BrowserViewStatus } from '@/shared/types';
4
4
 
5
5
  interface BrowserAddressBarProps {
6
6
  url?: string;
7
7
  status: BrowserViewStatus;
8
- viewportMode: ViewportMode;
9
8
  zoomLevel?: number;
10
9
  onIncludeInChat: () => void;
11
10
  onReconnect: () => void;
12
11
  onOpenExternal: () => void;
13
- onChangeViewport: (mode: ViewportMode) => void;
14
12
  onZoomIn: () => void;
15
13
  onZoomOut: () => void;
16
14
  onResetZoom: () => void;
17
- onSetZoom: (zoom: number) => void;
18
15
  }
19
16
 
20
17
  const STATUS_LABEL: Record<BrowserViewStatus, string> = {
@@ -32,16 +29,13 @@ const STATUS_LABEL: Record<BrowserViewStatus, string> = {
32
29
  export function BrowserAddressBar({
33
30
  url,
34
31
  status,
35
- viewportMode,
36
32
  zoomLevel = 100,
37
33
  onIncludeInChat,
38
34
  onReconnect,
39
35
  onOpenExternal,
40
- onChangeViewport,
41
36
  onZoomIn,
42
37
  onZoomOut,
43
38
  onResetZoom,
44
- onSetZoom,
45
39
  }: BrowserAddressBarProps) {
46
40
  const isLive = status === 'live';
47
41
 
@@ -50,13 +44,10 @@ export function BrowserAddressBar({
50
44
  url={url ?? ''}
51
45
  statusDotClass={isLive ? 'bg-success animate-pulse' : 'bg-ink/25'}
52
46
  statusLabel={STATUS_LABEL[status]}
53
- viewportMode={viewportMode}
54
47
  zoomLevel={zoomLevel}
55
- onChangeViewport={onChangeViewport}
56
48
  onZoomIn={onZoomIn}
57
49
  onZoomOut={onZoomOut}
58
50
  onResetZoom={onResetZoom}
59
- onSetZoom={onSetZoom}
60
51
  onIncludeInChat={onIncludeInChat}
61
52
  onOpenExternal={onOpenExternal}
62
53
  openExternalLabel="Buka di jendela baru"
@@ -1,7 +1,6 @@
1
1
  import { Globe, Loader2, Radio } from 'lucide-preact';
2
2
  import { ActivityToasts } from '@/client/components/workspace/browser-panel/ActivityToasts';
3
- import { VIEWPORT_CLASSES } from '@/client/components/common/viewport';
4
- import type { BrowserPanelAction, BrowserTabInfo, BrowserViewStatus, ViewportMode } from '@/shared/types';
3
+ import type { BrowserPanelAction, BrowserTabInfo, BrowserViewStatus } from '@/shared/types';
5
4
 
6
5
  interface BrowserScreencastProps {
7
6
  status: BrowserViewStatus;
@@ -11,7 +10,6 @@ interface BrowserScreencastProps {
11
10
  frameSrc?: string;
12
11
  actions: BrowserPanelAction[];
13
12
  onSelectTarget: (targetId: string) => void;
14
- viewportMode: ViewportMode;
15
13
  zoomLevel: number;
16
14
  }
17
15
 
@@ -38,7 +36,6 @@ export function BrowserScreencast({
38
36
  frameSrc,
39
37
  actions,
40
38
  onSelectTarget,
41
- viewportMode,
42
39
  zoomLevel,
43
40
  }: BrowserScreencastProps) {
44
41
  const isLive = status === 'live';
@@ -73,8 +70,10 @@ export function BrowserScreencast({
73
70
 
74
71
  <div className="flex-1 flex items-center justify-center p-2 min-h-0 overflow-auto">
75
72
  {isLive ? (
73
+ // Always fills the panel: the surface is a fixed-resolution JPEG, so
74
+ // there is no device box to frame it in — only the zoom scale.
76
75
  <div
77
- className={`relative flex flex-col bg-paper ${VIEWPORT_CLASSES[viewportMode]} transition-all duration-200`}
76
+ className="relative flex flex-col bg-paper w-full h-full transition-all duration-200"
78
77
  style={{
79
78
  zoom: zoomLevel !== 100 ? `${zoomLevel}%` : undefined,
80
79
  transformOrigin: 'top center',
@@ -4,7 +4,6 @@ import { BrowserScreencast } from '@/client/components/workspace/browser-panel/S
4
4
  import { useScreencast } from '@/client/hooks/browser/use-screencast';
5
5
  import { useSessionState } from '@/client/hooks/workspace/session-state';
6
6
  import { emitBrowserPageContext } from '@/shared/lib/browser/page-context';
7
- import type { ViewportMode } from '@/shared/types';
8
7
 
9
8
  interface BrowserPanelProps {
10
9
  className?: string;
@@ -18,9 +17,14 @@ interface BrowserPanelProps {
18
17
  *
19
18
  * This panel only mirrors what the agent does. Interactive browsing belongs to
20
19
  * the separate user-browser panel, which runs its own private Chromium.
20
+ *
21
+ * There is deliberately NO device/viewport selector here: the surface is a
22
+ * fixed-resolution screencast JPEG, so a preset could only letterbox the image
23
+ * while claiming to emulate a device. Emulation belongs to the agent itself
24
+ * (`tab.emulate()` / `browser.open({ viewport })`); the user-browser panel,
25
+ * whose iframe's layout viewport *is* its CSS box, keeps the selector.
21
26
  */
22
27
  export function BrowserPanel({ className = '', active = true }: BrowserPanelProps) {
23
- const [viewportMode, setViewportMode] = useSessionState<ViewportMode>('browser.viewportMode', 'responsive');
24
28
  const [zoomLevel, setZoomLevel] = useSessionState<number>('browser.zoomLevel', 100);
25
29
  const { status, url, title, tabs, targetId, frameSrc, actions, selectTarget, reconnect } = useScreencast(active);
26
30
 
@@ -41,16 +45,13 @@ export function BrowserPanel({ className = '', active = true }: BrowserPanelProp
41
45
  <BrowserAddressBar
42
46
  url={url}
43
47
  status={status}
44
- viewportMode={viewportMode}
45
48
  zoomLevel={zoomLevel}
46
49
  onIncludeInChat={handleIncludeInChat}
47
50
  onReconnect={reconnect}
48
51
  onOpenExternal={handleOpenExternal}
49
- onChangeViewport={setViewportMode}
50
52
  onZoomIn={() => setZoomLevel((prev) => Math.min(200, prev + 10))}
51
53
  onZoomOut={() => setZoomLevel((prev) => Math.max(50, prev - 10))}
52
54
  onResetZoom={() => setZoomLevel(100)}
53
- onSetZoom={setZoomLevel}
54
55
  />
55
56
 
56
57
  <BrowserScreencast
@@ -61,7 +62,6 @@ export function BrowserPanel({ className = '', active = true }: BrowserPanelProp
61
62
  frameSrc={frameSrc}
62
63
  actions={actions}
63
64
  onSelectTarget={selectTarget}
64
- viewportMode={viewportMode}
65
65
  zoomLevel={zoomLevel}
66
66
  />
67
67
  </div>
@@ -13,15 +13,22 @@ export interface BrowserToolbarProps {
13
13
  /** Status dot class plus its label — the only per-panel difference. */
14
14
  statusDotClass: string;
15
15
  statusLabel: string;
16
- viewportMode: ViewportMode;
17
16
  zoomLevel: number;
18
- onChangeViewport: (mode: ViewportMode) => void;
19
17
  onZoomIn: () => void;
20
18
  onZoomOut: () => void;
21
19
  onResetZoom: () => void;
22
- onSetZoom: (zoom: number) => void;
23
20
  onIncludeInChat: () => void;
24
21
  onOpenExternal: () => void;
22
+ /**
23
+ * Device-testing cluster, rendered only when both are supplied. The USER
24
+ * panel passes them: its iframe's layout viewport *is* its CSS box, so a
25
+ * preset genuinely re-runs the page's media queries. The AGENT panel omits
26
+ * them — its surface is a fixed-resolution screencast JPEG, so a preset
27
+ * could only letterbox the image while claiming to emulate a device.
28
+ */
29
+ viewportMode?: ViewportMode;
30
+ onChangeViewport?: (mode: ViewportMode) => void;
31
+ onSetZoom?: (zoom: number) => void;
25
32
  /** Shown after the include/copy buttons — the agent panel's reconnect button. */
26
33
  trailingControls?: ComponentChildren;
27
34
  /** Open-external button label/title, differing between the two panels. */
@@ -34,9 +41,10 @@ export interface BrowserToolbarProps {
34
41
  /**
35
42
  * Shared address bar for both browser panels. The two panels differ only in
36
43
  * their status semantics, the middle slot (read-only URL vs editable form),
37
- * and the agent panel's reconnect button; everything else — the include,
38
- * copy, open-external buttons and the zoom cluster — lives here so the two
39
- * toolbars cannot drift.
44
+ * the agent panel's reconnect button, and whether the device-testing cluster
45
+ * is offered at all (user panel yes, agent panel no — see `viewportMode`);
46
+ * everything else — the include, copy, open-external buttons and the zoom
47
+ * cluster — lives here so the two toolbars cannot drift.
40
48
  */
41
49
  export function BrowserToolbar({
42
50
  url,
@@ -86,12 +94,14 @@ export function BrowserToolbar({
86
94
  {children}
87
95
 
88
96
  <div className="flex items-center space-x-1.5 flex-shrink-0">
89
- <BrowserViewportSelector
90
- viewportMode={viewportMode}
91
- onChangeViewport={onChangeViewport}
92
- zoomLevel={zoomLevel}
93
- onSetZoom={onSetZoom}
94
- />
97
+ {viewportMode && onChangeViewport && (
98
+ <BrowserViewportSelector
99
+ viewportMode={viewportMode}
100
+ onChangeViewport={onChangeViewport}
101
+ zoomLevel={zoomLevel}
102
+ onSetZoom={onSetZoom}
103
+ />
104
+ )}
95
105
 
96
106
  <ZoomControls
97
107
  zoomLevel={zoomLevel}
@@ -12,7 +12,7 @@ import { toTitleCase } from '@/shared/lib/chat/title-case';
12
12
  import { FallbackOutput } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/FallbackOutput';
13
13
  import { tryParseJson } from '@/shared/lib/code/syntax-highlight';
14
14
  import { JsonCodeBlock } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock';
15
- import { getTodoSummary } from '@/shared/lib/chat/todo-parser';
15
+ import { getTodoSummary } from '@/shared/lib/chat/todo/parser';
16
16
  import { parseAskQuestions } from '@/shared/lib/chat/ask-questions';
17
17
 
18
18
  interface ToolCallCardProps {
@@ -1,7 +1,7 @@
1
1
  import { useMemo } from 'preact/hooks';
2
2
  import { CheckCircle2, ListTodo, Loader2, Square } from 'lucide-preact';
3
3
  import type { ToolCallData } from '@/shared/types';
4
- import { parseTodoData } from '@/shared/lib/chat/todo-parser';
4
+ import { parseTodoData } from '@/shared/lib/chat/todo/parser';
5
5
  import { FallbackOutput } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/FallbackOutput';
6
6
 
7
7
  /** Panel khusus untuk tool `todo` — task list dengan progress bar, phase groups, dan status yang readable. */